update the email
This commit is contained in:
parent
3ff3c25734
commit
c6fcb27613
Binary file not shown.
@ -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"
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
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
|
||||
|
||||
@ -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
|
||||
|
||||
Binary file not shown.
@ -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}"
|
||||
|
||||
|
||||
@ -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 `<h2>` headings and `<ul>`/`<li>` bullet points. Encapsulate the entire formatted block within a single `<div>`.
|
||||
3. **Format the Benefits:** Organize and format the raw Requirements data into clear, readable sections using `<h2>` headings and `<ul>`/`<li>` bullet points. Encapsulate the entire formatted block within a single `<div>`.
|
||||
4. **Application Instructions:** Organize and format the raw Requirements data into clear, readable sections using `<h2>` headings and `<ul>`/`<li>` bullet points. Encapsulate the entire formatted block within a single `<div>`.
|
||||
|
||||
|
||||
|
||||
**TASK 2: LinkedIn Post Creation**
|
||||
1. **Write the Post:** Create an engaging, professional, and concise LinkedIn post (maximum 1300 characters) summarizing the opportunity.
|
||||
@ -744,3 +744,72 @@ def sync_candidate_to_source_task(candidate_id, source_id):
|
||||
error_msg = f"Unexpected error during sync: {str(e)}"
|
||||
logger.error(error_msg, exc_info=True)
|
||||
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")
|
||||
|
||||
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
|
||||
|
||||
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}
|
||||
|
||||
@ -65,7 +65,7 @@ urlpatterns = [
|
||||
path('forms/builder/<slug:template_slug>/', views.form_builder, name='form_builder'),
|
||||
path('forms/', views.form_templates_list, name='form_templates_list'),
|
||||
path('forms/create-template/', views.create_form_template, name='create_form_template'),
|
||||
|
||||
|
||||
path('jobs/<slug:slug>/edit_linkedin_post_content/',views.edit_linkedin_post_content,name='edit_linkedin_post_content'),
|
||||
path('jobs/<slug:slug>/candidate_screening_view/', views.candidate_screening_view, name='candidate_screening_view'),
|
||||
path('jobs/<slug:slug>/candidate_exam_view/', views.candidate_exam_view, name='candidate_exam_view'),
|
||||
@ -225,5 +225,8 @@ urlpatterns = [
|
||||
path('participants/create/', views_frontend.ParticipantsCreateView.as_view(), name='participants_create'),
|
||||
path('participants/<slug:slug>/', views_frontend.ParticipantsDetailView.as_view(), name='participants_detail'),
|
||||
path('participants/<slug:slug>/update/', views_frontend.ParticipantsUpdateView.as_view(), name='participants_update'),
|
||||
path('participants/<slug:slug>/delete/', views_frontend.ParticipantsDeleteView.as_view(), name='participants_delete'),
|
||||
path('participants/<slug:slug>/delete/', views_frontend.ParticipantsDeleteView.as_view(), name='participants_delete'),
|
||||
|
||||
# Email composition URLs
|
||||
path('jobs/<slug:job_slug>/candidates/<slug:candidate_slug>/compose-email/', views.compose_candidate_email, name='compose_candidate_email'),
|
||||
]
|
||||
|
||||
@ -41,7 +41,8 @@ from .forms import (
|
||||
AgencyAccessLinkForm,
|
||||
AgencyJobAssignmentForm,
|
||||
LinkedPostContentForm,
|
||||
ParticipantsSelectForm
|
||||
ParticipantsSelectForm,
|
||||
CandidateEmailForm
|
||||
)
|
||||
from easyaudit.models import CRUDEvent, LoginEvent, RequestEvent
|
||||
from rest_framework import viewsets
|
||||
@ -415,12 +416,12 @@ def job_detail(request, slug):
|
||||
)
|
||||
)
|
||||
total_candidates=applicants.count()
|
||||
avg_match_score_result = candidates_with_score.aggregate(avg_score=Avg('annotated_match_score'))['avg_score']
|
||||
avg_match_score = round(avg_match_score_result or 0, 1)
|
||||
avg_match_score_result = candidates_with_score.aggregate(avg_score=Avg('annotated_match_score'))['avg_score']
|
||||
avg_match_score = round(avg_match_score_result or 0, 1)
|
||||
high_potential_count = candidates_with_score.filter(annotated_match_score__gte=HIGH_POTENTIAL_THRESHOLD).count()
|
||||
high_potential_ratio = round( (high_potential_count / total_candidates) * 100, 1 ) if total_candidates > 0 else 0
|
||||
|
||||
|
||||
|
||||
# --- 3. Time Metrics (Duration Aggregation) ---
|
||||
|
||||
# Metric: Average Time from Applied to Interview (T2I)
|
||||
@ -539,14 +540,14 @@ def edit_linkedin_post_content(request,slug):
|
||||
else:
|
||||
messages.error(request,"Error update the Linkedin Post content")
|
||||
return redirect('job_detail',job.slug)
|
||||
|
||||
|
||||
else:
|
||||
linkedin_content_form=LinkedPostContentForm()
|
||||
return redirect('job_detail',job.slug)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def kaauh_career(request):
|
||||
@ -1434,41 +1435,41 @@ def candidate_update_status(request, slug):
|
||||
@login_required
|
||||
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:
|
||||
# 🛑 FIX: Explicitly pass the initial data for M2M fields
|
||||
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
|
||||
'form':form,
|
||||
'participants_count': job.participants.count() + job.users.count(),
|
||||
}
|
||||
return render(request,"recruitment/candidate_interview_view.html",context)
|
||||
|
||||
@ -3557,3 +3558,132 @@ def api_candidate_detail(request, candidate_id):
|
||||
|
||||
except Exception as e:
|
||||
return JsonResponse({'success': False, 'error': str(e)})
|
||||
|
||||
|
||||
@login_required
|
||||
def compose_candidate_email(request, job_slug, candidate_slug):
|
||||
"""Compose email to participants about a candidate"""
|
||||
from .email_service import send_bulk_email
|
||||
|
||||
job = get_object_or_404(JobPosting, slug=job_slug)
|
||||
candidate = get_object_or_404(Candidate, slug=candidate_slug, job=job)
|
||||
if request.method == 'POST':
|
||||
form = CandidateEmailForm(job, candidate, request.POST)
|
||||
if form.is_valid():
|
||||
# Get email addresses
|
||||
email_addresses = form.get_email_addresses()
|
||||
if not email_addresses:
|
||||
messages.error(request, 'No valid email addresses found for selected recipients.')
|
||||
return render(request, 'includes/email_compose_form.html', {
|
||||
'form': form,
|
||||
'job': job,
|
||||
'candidate': candidate
|
||||
})
|
||||
|
||||
# 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(
|
||||
candidate=candidate,
|
||||
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_=False # Changed to False to avoid pickle issues
|
||||
)
|
||||
|
||||
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,
|
||||
'candidate': candidate
|
||||
})
|
||||
|
||||
# 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.errors)
|
||||
messages.error(request, 'Please correct the errors below.')
|
||||
|
||||
# For HTMX requests, return error response
|
||||
if 'HX-Request' in request.headers:
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': 'Please correct the form errors and try again.'
|
||||
})
|
||||
|
||||
return render(request, 'includes/email_compose_form.html', {
|
||||
'form': form,
|
||||
'job': job,
|
||||
'candidate': candidate
|
||||
})
|
||||
|
||||
else:
|
||||
# GET request - show the form
|
||||
form = CandidateEmailForm(job, candidate)
|
||||
|
||||
return render(request, 'includes/email_compose_form.html', {
|
||||
'form': form,
|
||||
'job': job,
|
||||
'candidate': candidate
|
||||
})
|
||||
|
||||
@ -133,7 +133,7 @@
|
||||
data-bs-auto-close="outside"
|
||||
data-bs-offset="0, 16" {# Vertical offset remains 16px to prevent clipping #}
|
||||
>
|
||||
{% if user.profile.profile_image %}
|
||||
{% if user.profile and user.profile.profile_image %}
|
||||
<img src="{{ user.profile.profile_image.url }}" alt="{{ user.username }}" class="profile-avatar"
|
||||
style="width: 36px; height: 36px; object-fit: cover; background-color: var(--kaauh-teal); display: inline-block; vertical-align: middle;"
|
||||
title="{% trans 'Your account' %}">
|
||||
@ -151,7 +151,7 @@
|
||||
<li class="px-4 py-3 ">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="me-3 d-flex align-items-center justify-content-center" style="min-width: 48px;">
|
||||
{% if user.profile.profile_image %}
|
||||
{% if user.profile and user.profile.profile_image %}
|
||||
<img src="{{ user.profile.profile_image.url }}" alt="{{ user.username }}" class="profile-avatar shadow-sm border"
|
||||
style="width: 44px; height: 44px; object-fit: cover; background-color: var(--kaauh-teal); display: block;"
|
||||
title="{% trans 'Your account' %}">
|
||||
|
||||
139
templates/emails/interview_invitation.html
Normal file
139
templates/emails/interview_invitation.html
Normal file
@ -0,0 +1,139 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Interview Invitation</title>
|
||||
<style>
|
||||
/* Basic reset and typography */
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333333;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
/* Container for the main content */
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 20px auto;
|
||||
background-color: #ffffff;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
/* Header styling */
|
||||
.header {
|
||||
text-align: center;
|
||||
border-bottom: 2px solid #007bff;
|
||||
padding-bottom: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.header h1 {
|
||||
color: #007bff;
|
||||
font-size: 24px;
|
||||
margin: 0;
|
||||
}
|
||||
/* Section headings */
|
||||
.section-header {
|
||||
color: #007bff;
|
||||
font-size: 18px;
|
||||
margin-top: 25px;
|
||||
margin-bottom: 10px;
|
||||
border-left: 4px solid #007bff;
|
||||
padding-left: 10px;
|
||||
}
|
||||
/* Key detail rows */
|
||||
.detail-row {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.detail-row strong {
|
||||
display: inline-block;
|
||||
width: 120px;
|
||||
color: #555555;
|
||||
}
|
||||
/* Button style for the Join URL */
|
||||
.button {
|
||||
display: block;
|
||||
width: 80%;
|
||||
margin: 25px auto;
|
||||
padding: 12px 0;
|
||||
background-color: #28a745; /* Success/Go color */
|
||||
color: #ffffff;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
border-radius: 5px;
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
}
|
||||
/* Footer/closing section */
|
||||
.footer {
|
||||
margin-top: 30px;
|
||||
padding-top: 15px;
|
||||
border-top: 1px dashed #cccccc;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: #777777;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>Interview Confirmation</h1>
|
||||
</div>
|
||||
|
||||
<p>Dear <strong>{{ candidate_name }}</strong>,</p>
|
||||
<p>Thank you for your interest in the position. We are pleased to invite you to a virtual interview. Please find the details below.</p>
|
||||
|
||||
<h2 class="section-header">Interview Details</h2>
|
||||
<div class="detail-row">
|
||||
<strong>Topic:</strong> {{ meeting_topic }}
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<strong>Date & Time:</strong> <strong>{{ meeting_date_time }}</strong>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<strong>Duration:</strong> {{ meeting_duration }}
|
||||
</div>
|
||||
|
||||
{% if join_url %}
|
||||
<a href="{{ join_url }}" class="button" target="_blank">
|
||||
Join Interview Now
|
||||
</a>
|
||||
<p style="text-align: center; font-size: 14px; color: #777;">Please click the button above to join the meeting at the scheduled time.</p>
|
||||
{% endif %}
|
||||
|
||||
<h2 class="section-header">Your Information</h2>
|
||||
<div class="detail-row">
|
||||
<strong>Name:</strong> {{ candidate_name }}
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<strong>Email:</strong> {{ candidate_email }}
|
||||
</div>
|
||||
{% if candidate_phone %}
|
||||
<div class="detail-row">
|
||||
<strong>Phone:</strong> {{ candidate_phone }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if job_title %}
|
||||
<h2 class="section-header">Position Details</h2>
|
||||
<div class="detail-row">
|
||||
<strong>Position:</strong> {{ job_title }}
|
||||
</div>
|
||||
{% if department %}
|
||||
<div class="detail-row">
|
||||
<strong>Department:</strong> {{ department }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<div class="footer">
|
||||
<p>We look forward to speaking with you.</p>
|
||||
<p>If you have any questions, please reply to this email.</p>
|
||||
<p>Best regards,<br>The {{ company_name|default:"Norah University" }} Team</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
509
templates/includes/email_compose_form.html
Normal file
509
templates/includes/email_compose_form.html
Normal file
@ -0,0 +1,509 @@
|
||||
{% load i18n %}
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-envelope me-2"></i>
|
||||
{% trans "Compose Email" %}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" id="email-compose-form" action="{% url 'compose_candidate_email' job.slug candidate.slug %}">
|
||||
{% csrf_token %}
|
||||
|
||||
<!-- Subject Field -->
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.subject.id_for_label }}" class="form-label fw-bold">
|
||||
{% trans "Subject" %}
|
||||
</label>
|
||||
{{ form.subject }}
|
||||
{% if form.subject.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{% for error in form.subject.errors %}
|
||||
<span>{{ error }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Recipients Field -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">
|
||||
{% trans "Recipients" %}
|
||||
</label>
|
||||
<div class="border rounded p-3 bg-light" style="max-height: 200px; overflow-y: auto;">
|
||||
{% for choice in form.recipients %}
|
||||
<div class="form-check mb-2">
|
||||
{{ choice }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if form.recipients.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{% for error in form.recipients.errors %}
|
||||
<span>{{ error }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Message Field -->
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.message.id_for_label }}" class="form-label fw-bold">
|
||||
{% trans "Message" %}
|
||||
</label>
|
||||
{{ form.message }}
|
||||
{% if form.message.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{% for error in form.message.errors %}
|
||||
<span>{{ error }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Options Checkboxes -->
|
||||
<div class="mb-4">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="form-check">
|
||||
<input type="checkbox"
|
||||
class="form-check-input"
|
||||
name="{{ form.include_candidate_info.name }}"
|
||||
id="{{ form.include_candidate_info.id_for_label }}"
|
||||
{% if form.include_candidate_info.value %}checked{% endif %}>
|
||||
<label class="form-check-label" for="{{ form.include_candidate_info.id_for_label }}">
|
||||
{{ form.include_candidate_info.label }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-check">
|
||||
<input type="checkbox"
|
||||
class="form-check-input"
|
||||
name="{{ form.include_meeting_details.name }}"
|
||||
id="{{ form.include_meeting_details.id_for_label }}"
|
||||
{% if form.include_meeting_details.value %}checked{% endif %}>
|
||||
<label class="form-check-label" for="{{ form.include_meeting_details.id_for_label }}">
|
||||
{{ form.include_meeting_details.label }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div class="text-muted small">
|
||||
<i class="fas fa-info-circle me-1"></i>
|
||||
{% trans "Email will be sent to all selected recipients" %}
|
||||
</div>
|
||||
<div>
|
||||
<button type="button"
|
||||
class="btn btn-secondary me-2"
|
||||
data-bs-dismiss="modal">
|
||||
<i class="fas fa-times me-1"></i>
|
||||
{% trans "Cancel" %}
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="btn btn-primary"
|
||||
id="send-email-btn">
|
||||
<i class="fas fa-paper-plane me-1"></i>
|
||||
{% trans "Send Email" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading Overlay -->
|
||||
<div id="email-loading-overlay" class="d-none">
|
||||
<div class="d-flex justify-content-center align-items-center" style="min-height: 200px;">
|
||||
<div class="text-center">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">{% trans "Loading..." %}</span>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
{% trans "Sending email..." %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success/Error Messages Container -->
|
||||
<div id="email-messages-container"></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.card {
|
||||
border: none;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
border-radius: 8px 8px 0 0 !important;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
border-color: #00636e;
|
||||
box-shadow: 0 0 0 0.2rem rgba(0,99,110,0.25);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #00636e;
|
||||
border-color: #00636e;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #004a53;
|
||||
border-color: #004a53;
|
||||
}
|
||||
|
||||
.form-check-input:checked {
|
||||
background-color: #00636e;
|
||||
border-color: #00636e;
|
||||
}
|
||||
|
||||
.border {
|
||||
border-color: #dee2e6 !important;
|
||||
}
|
||||
|
||||
.bg-light {
|
||||
background-color: #f8f9fa !important;
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
color: #dc3545 !important;
|
||||
}
|
||||
|
||||
.spinner-border {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const form = document.getElementById('email-compose-form');
|
||||
const sendBtn = document.getElementById('send-email-btn');
|
||||
const loadingOverlay = document.getElementById('email-loading-overlay');
|
||||
const messagesContainer = document.getElementById('email-messages-container');
|
||||
|
||||
if (form) {
|
||||
form.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// Show loading state
|
||||
if (sendBtn) {
|
||||
sendBtn.disabled = true;
|
||||
sendBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i> {% trans "Sending..." %}';
|
||||
}
|
||||
|
||||
if (loadingOverlay) {
|
||||
loadingOverlay.classList.remove('d-none');
|
||||
}
|
||||
|
||||
// Clear previous messages
|
||||
if (messagesContainer) {
|
||||
messagesContainer.innerHTML = '';
|
||||
}
|
||||
|
||||
// Submit form via fetch
|
||||
const formData = new FormData(form);
|
||||
|
||||
fetch(form.action || window.location.href, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'HX-Request': 'true'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
// Hide loading state
|
||||
if (sendBtn) {
|
||||
sendBtn.disabled = false;
|
||||
sendBtn.innerHTML = '<i class="fas fa-paper-plane me-1"></i> {% trans "Send Email" %}';
|
||||
}
|
||||
|
||||
if (loadingOverlay) {
|
||||
loadingOverlay.classList.add('d-none');
|
||||
}
|
||||
|
||||
// Show result message
|
||||
if (data.success) {
|
||||
showMessage(data.message || 'Email sent successfully!', 'success');
|
||||
|
||||
// Close modal after a short delay
|
||||
setTimeout(() => {
|
||||
const modal = form.closest('.modal');
|
||||
if (modal) {
|
||||
const bootstrapModal = bootstrap.Modal.getInstance(modal);
|
||||
if (bootstrapModal) {
|
||||
bootstrapModal.hide();
|
||||
}
|
||||
}
|
||||
}, 1500);
|
||||
} else {
|
||||
showMessage(data.error || 'Failed to send email. Please try again.', 'danger');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
|
||||
// Hide loading state
|
||||
if (sendBtn) {
|
||||
sendBtn.disabled = false;
|
||||
sendBtn.innerHTML = '<i class="fas fa-paper-plane me-1"></i> {% trans "Send Email" %}';
|
||||
}
|
||||
|
||||
if (loadingOverlay) {
|
||||
loadingOverlay.classList.add('d-none');
|
||||
}
|
||||
|
||||
showMessage('An error occurred while sending the email. Please try again.', 'danger');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function showMessage(message, type) {
|
||||
if (!messagesContainer) return;
|
||||
|
||||
const alertClass = type === 'success' ? 'alert-success' : 'alert-danger';
|
||||
const icon = type === 'success' ? 'fa-check-circle' : 'fa-exclamation-triangle';
|
||||
|
||||
const messageHtml = `
|
||||
<div class="alert ${alertClass} alert-dismissible fade show" role="alert">
|
||||
<i class="fas ${icon} me-2"></i>
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
messagesContainer.innerHTML = messageHtml;
|
||||
|
||||
// Auto-hide success messages after 5 seconds
|
||||
if (type === 'success') {
|
||||
setTimeout(() => {
|
||||
const alert = messagesContainer.querySelector('.alert');
|
||||
if (alert) {
|
||||
const bsAlert = new bootstrap.Alert(alert);
|
||||
bsAlert.close();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
// Form validation
|
||||
function validateForm() {
|
||||
let isValid = true;
|
||||
const subject = form.querySelector('#{{ form.subject.id_for_label }}');
|
||||
const message = form.querySelector('#{{ form.message.id_for_label }}');
|
||||
const recipients = form.querySelectorAll('input[name="{{ form.recipients.name }}"]:checked');
|
||||
|
||||
// Clear previous validation states
|
||||
form.querySelectorAll('.is-invalid').forEach(field => {
|
||||
field.classList.remove('is-invalid');
|
||||
});
|
||||
form.querySelectorAll('.invalid-feedback').forEach(feedback => {
|
||||
feedback.remove();
|
||||
});
|
||||
|
||||
// Validate subject
|
||||
if (!subject || !subject.value.trim()) {
|
||||
showFieldError(subject, 'Subject is required');
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
// Validate message
|
||||
if (!message || !message.value.trim()) {
|
||||
showFieldError(message, 'Message is required');
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
// Validate recipients
|
||||
if (recipients.length === 0) {
|
||||
const recipientsContainer = form.querySelector('.border.rounded.p-3.bg-light');
|
||||
if (recipientsContainer) {
|
||||
recipientsContainer.classList.add('border-danger');
|
||||
showFieldError(recipientsContainer, 'Please select at least one recipient');
|
||||
}
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
function showFieldError(field, message) {
|
||||
if (!field) return;
|
||||
|
||||
field.classList.add('is-invalid');
|
||||
|
||||
const feedback = document.createElement('div');
|
||||
feedback.className = 'invalid-feedback';
|
||||
feedback.textContent = message;
|
||||
|
||||
if (field.classList.contains('border')) {
|
||||
// For container elements (like recipients)
|
||||
field.parentNode.appendChild(feedback);
|
||||
} else {
|
||||
// For form fields
|
||||
field.parentNode.appendChild(feedback);
|
||||
}
|
||||
}
|
||||
|
||||
// Character counter for message field
|
||||
function setupCharacterCounter() {
|
||||
const messageField = form.querySelector('#{{ form.message.id_for_label }}');
|
||||
if (!messageField) return;
|
||||
|
||||
const counter = document.createElement('div');
|
||||
counter.className = 'text-muted small mt-1';
|
||||
counter.id = 'message-counter';
|
||||
|
||||
messageField.parentNode.appendChild(counter);
|
||||
|
||||
function updateCounter() {
|
||||
const length = messageField.value.length;
|
||||
const maxLength = 5000; // Adjust as needed
|
||||
counter.textContent = `${length} / ${maxLength} characters`;
|
||||
|
||||
if (length > maxLength * 0.9) {
|
||||
counter.classList.add('text-warning');
|
||||
counter.classList.remove('text-muted');
|
||||
} else {
|
||||
counter.classList.remove('text-warning');
|
||||
counter.classList.add('text-muted');
|
||||
}
|
||||
}
|
||||
|
||||
messageField.addEventListener('input', updateCounter);
|
||||
updateCounter(); // Initial count
|
||||
}
|
||||
|
||||
// Auto-save functionality
|
||||
let autoSaveTimer;
|
||||
function setupAutoSave() {
|
||||
const subject = form.querySelector('#{{ form.subject.id_for_label }}');
|
||||
const message = form.querySelector('#{{ form.message.id_for_label }}');
|
||||
|
||||
if (!subject || !message) return;
|
||||
|
||||
function saveDraft() {
|
||||
const draftData = {
|
||||
subject: subject.value,
|
||||
message: message.value,
|
||||
recipients: Array.from(form.querySelectorAll('input[name="{{ form.recipients.name }}"]:checked')).map(cb => cb.value),
|
||||
include_candidate_info: form.querySelector('#{{ form.include_candidate_info.id_for_label }}').checked,
|
||||
include_meeting_details: form.querySelector('#{{ form.include_meeting_details.id_for_label }}').checked
|
||||
};
|
||||
|
||||
localStorage.setItem('email_draft_' + window.location.pathname, JSON.stringify(draftData));
|
||||
}
|
||||
|
||||
function autoSave() {
|
||||
clearTimeout(autoSaveTimer);
|
||||
autoSaveTimer = setTimeout(saveDraft, 2000); // Save after 2 seconds of inactivity
|
||||
}
|
||||
|
||||
[subject, message].forEach(field => {
|
||||
field.addEventListener('input', autoSave);
|
||||
});
|
||||
|
||||
form.addEventListener('change', autoSave);
|
||||
}
|
||||
|
||||
function loadDraft() {
|
||||
const draftData = localStorage.getItem('email_draft_' + window.location.pathname);
|
||||
if (!draftData) return;
|
||||
|
||||
try {
|
||||
const draft = JSON.parse(draftData);
|
||||
|
||||
const subject = form.querySelector('#{{ form.subject.id_for_label }}');
|
||||
const message = form.querySelector('#{{ form.message.id_for_label }}');
|
||||
|
||||
if (subject && draft.subject) subject.value = draft.subject;
|
||||
if (message && draft.message) message.value = draft.message;
|
||||
|
||||
// Restore recipients
|
||||
if (draft.recipients) {
|
||||
form.querySelectorAll('input[name="{{ form.recipients.name }}"]').forEach(cb => {
|
||||
cb.checked = draft.recipients.includes(cb.value);
|
||||
});
|
||||
}
|
||||
|
||||
// Restore checkboxes
|
||||
if (draft.include_candidate_info) {
|
||||
form.querySelector('#{{ form.include_candidate_info.id_for_label }}').checked = draft.include_candidate_info;
|
||||
}
|
||||
if (draft.include_meeting_details) {
|
||||
form.querySelector('#{{ form.include_meeting_details.id_for_label }}').checked = draft.include_meeting_details;
|
||||
}
|
||||
|
||||
// Show draft restored notification
|
||||
showMessage('Draft restored from local storage', 'success');
|
||||
} catch (e) {
|
||||
console.error('Error loading draft:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function clearDraft() {
|
||||
localStorage.removeItem('email_draft_' + window.location.pathname);
|
||||
}
|
||||
|
||||
// Initialize form enhancements
|
||||
setupCharacterCounter();
|
||||
setupAutoSave();
|
||||
|
||||
// Load draft on page load
|
||||
setTimeout(loadDraft, 100);
|
||||
|
||||
// Clear draft on successful submission
|
||||
const originalSubmitHandler = form.onsubmit;
|
||||
form.addEventListener('submit', function(e) {
|
||||
const isValid = validateForm();
|
||||
if (!isValid) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Clear draft on successful submission
|
||||
setTimeout(clearDraft, 2000);
|
||||
});
|
||||
|
||||
// Add keyboard shortcuts
|
||||
document.addEventListener('keydown', function(e) {
|
||||
// Ctrl/Cmd + Enter to submit
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||
const activeElement = document.activeElement;
|
||||
if (activeElement && (activeElement.tagName === 'TEXTAREA' || activeElement.tagName === 'INPUT')) {
|
||||
form.dispatchEvent(new Event('submit'));
|
||||
}
|
||||
}
|
||||
|
||||
// Escape to cancel/close modal
|
||||
if (e.key === 'Escape') {
|
||||
const modal = form.closest('.modal');
|
||||
if (modal) {
|
||||
const bootstrapModal = bootstrap.Modal.getInstance(modal);
|
||||
if (bootstrapModal) {
|
||||
bootstrapModal.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Email compose form initialized');
|
||||
});
|
||||
@ -21,7 +21,7 @@
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #f0f2f5;
|
||||
background-color: #f0f2f5;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
@ -148,7 +148,7 @@ body {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background-color: var(--kaauh-teal-dark);
|
||||
border: none;
|
||||
color: white;
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.btn-copy-simple:hover {
|
||||
@ -209,7 +209,7 @@ body {
|
||||
<a href="{% url 'list_meetings' %}" class="btn btn-secondary-back">
|
||||
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to Meetings" %}
|
||||
</a>
|
||||
|
||||
|
||||
{# Edit and Delete Buttons #}
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{% url 'update_meeting' meeting.slug %}" class="btn btn-primary-teal btn-sm">
|
||||
@ -226,26 +226,28 @@ body {
|
||||
</div>
|
||||
|
||||
{# ========================================================= #}
|
||||
{# --- SECTION 1: PROMINENT TOP DETAILS & JOIN INFO --- #}
|
||||
{# --- MAIN TITLE AT TOP --- #}
|
||||
{# ========================================================= #}
|
||||
<div class="row g-4 mb-5">
|
||||
|
||||
{# --- LEFT HALF: MAIN TOPIC & JOB CONTEXT --- #}
|
||||
<div class="col-lg-6">
|
||||
<div class="main-title-container">
|
||||
<h1 class="text-start" style="color: var(--kaauh-teal-dark);">
|
||||
<i class="fas fa-video me-2" style="color: var(--kaauh-teal);"></i>
|
||||
{{ meeting.topic|default:"[Meeting Topic N/A]" }}
|
||||
<span class="status-badge bg-{{ meeting.status|lower|default:'bg-secondary' }} ms-3">
|
||||
{{ meeting.status|title|default:'N/A' }}
|
||||
</span>
|
||||
</h1>
|
||||
</div>
|
||||
<div class="main-title-container mb-4">
|
||||
<h1 class="text-start" style="color: var(--kaauh-teal-dark);">
|
||||
<i class="fas fa-video me-2" style="color: var(--kaauh-teal);"></i>
|
||||
{{ meeting.topic|default:"[Meeting Topic N/A]" }}
|
||||
<span class="status-badge bg-{{ meeting.status|lower|default:'bg-secondary' }} ms-3">
|
||||
{{ meeting.status|title|default:'N/A' }}
|
||||
</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{# JOB CONTEXT DETAILS (Simple Divs) #}
|
||||
<div class="p-3 bg-white rounded shadow-sm">
|
||||
{# ========================================================= #}
|
||||
{# --- SECTION 1: INTERVIEW & CONNECTION CARDS SIDE BY SIDE --- #}
|
||||
{# ========================================================= #}
|
||||
<div class="row g-4 mb-5 align-items-stretch">
|
||||
|
||||
{# --- LEFT HALF: INTERVIEW DETAIL CARD --- #}
|
||||
<div class="col-lg-6">
|
||||
<div class="p-3 bg-white rounded shadow-sm h-100 d-flex flex-column">
|
||||
<h2 class="text-start"><i class="fas fa-briefcase me-2"></i> {% trans "Interview Detail" %}</h2>
|
||||
<div class="detail-row-group">
|
||||
<div class="detail-row-group flex-grow-1">
|
||||
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Job Title" %}:</div><div class="detail-value-simple">{{ meeting.get_job.title|default:"N/A" }}</div></div>
|
||||
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Candidate Name" %}:</div><div class="detail-value-simple"><a href="">{{ meeting.get_candidate.name|default:"N/A" }}</a></div></div>
|
||||
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Candidate Email" %}:</div><div class="detail-value-simple"><a href="">{{ meeting.get_candidate.email|default:"N/A" }}</a></div></div>
|
||||
@ -254,11 +256,11 @@ body {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# --- RIGHT HALF: ZOOM LINK / ACTIONS --- #}
|
||||
<div class="col-lg-6">
|
||||
<div class="p-3 bg-white rounded shadow-sm">
|
||||
{# --- RIGHT HALF: CONNECTION DETAILS CARD --- #}
|
||||
<div class="col-lg-6">
|
||||
<div class="p-3 bg-white rounded shadow-sm h-100 d-flex flex-column">
|
||||
<h2 class="text-start"><i class="fas fa-info-circle me-2"></i> {% trans "Connection Details" %}</h2>
|
||||
<div class="detail-row-group">
|
||||
<div class="detail-row-group flex-grow-1">
|
||||
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Date & Time" %}:</div><div class="detail-value-simple">{{ meeting.start_time|date:"M d, Y H:i"|default:"N/A" }}</div></div>
|
||||
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Duration" %}:</div><div class="detail-value-simple">{{ meeting.duration|default:"N/A" }} {% trans "minutes" %}</div></div>
|
||||
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Meeting ID" %}:</div><div class="detail-value-simple">{{ meeting.meeting_id|default:"N/A" }}</div></div>
|
||||
@ -266,7 +268,7 @@ body {
|
||||
{% if meeting.join_url %}
|
||||
<div class="join-url-container pt-3">
|
||||
<div id="copy-message" class="text-white rounded px-2 py-1 small fw-bold mb-2 text-center" style="opacity: 0; transition: opacity 0.3s; position: absolute; right: 0; top: 5px; background-color: var(--kaauh-success); z-index: 10;">{% trans "Copied!" %}</div>
|
||||
|
||||
|
||||
<div class="join-url-display d-flex justify-content-between align-items-center position-relative">
|
||||
<div class="text-truncate me-2">
|
||||
<strong>{% trans "Join URL" %}:</strong>
|
||||
@ -281,15 +283,15 @@ body {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
{# ========================================================= #}
|
||||
{# --- SECTION 2: PERSONNEL TABLES --- #}
|
||||
{# ========================================================= #}
|
||||
<div class="row g-4 mt-1 mb-5">
|
||||
|
||||
|
||||
|
||||
|
||||
{# --- PARTICIPANTS TABLE --- #}
|
||||
<div class="col-lg-12">
|
||||
<div class="p-3 bg-white rounded shadow-sm">
|
||||
@ -306,7 +308,7 @@ body {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for participant in meeting.get_external_participants %}
|
||||
{% for participant in meeting.get_participants %}
|
||||
<tr>
|
||||
<td>{{participant.name}}</td>
|
||||
<td>{{participant.designation}}</td>
|
||||
@ -315,29 +317,29 @@ body {
|
||||
<td>{% trans "External Participants" %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% for participant in meeting.get_users_participants %}
|
||||
{% for user in meeting.get_users %}
|
||||
<tr>
|
||||
<td>{{participant.name}}</td>
|
||||
<td>{{participant.designation}}</td>
|
||||
<td>{{participant.email}}</td>
|
||||
<td>{{participant.phone}}</td>
|
||||
<td>{{user.get_full_name}}</td>
|
||||
<td>Admin</td>
|
||||
<td>{{user.email}}</td>
|
||||
<td>{{user.phone}}</td>
|
||||
<td>{% trans "System User" %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{# ========================================================= #}
|
||||
{# --- SECTION 3: COMMENTS (CORRECTED) --- #}
|
||||
{# ========================================================= #}
|
||||
<div class="row g-4 mt-1">
|
||||
|
||||
|
||||
<div class="col-lg-12">
|
||||
<div class="card flex-grow-1" id="comments-card" style="height: 100%;">
|
||||
<div class="card" id="comments-card" style="height: 100%;">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="card-title mb-0" style="color: var(--kaauh-teal-dark);">
|
||||
<i class="fas fa-comments me-2"></i>
|
||||
@ -345,29 +347,29 @@ body {
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body overflow-auto">
|
||||
|
||||
|
||||
{# 1. COMMENT DISPLAY & IN-PAGE EDIT FORMS #}
|
||||
<div id="comment-section" class="mb-4">
|
||||
{% if meeting.comments.all %}
|
||||
{% for comment in meeting.comments.all|dictsortreversed:"created_at" %}
|
||||
|
||||
|
||||
<div class="comment-item mb-3 p-3">
|
||||
|
||||
|
||||
{# Read-Only Comment View #}
|
||||
<div id="comment-view-{{ comment.pk }}">
|
||||
<p class="mb-1 d-flex justify-content-between align-items-start" style="font-size: 0.9rem;">
|
||||
<div>
|
||||
<strong>{{ comment.author.get_full_name|default:comment.author.username }}</strong>
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<div class="comment-metadata" style="font-size: 0.9rem;">
|
||||
<strong>{{ comment.author.get_full_name|default:comment.author.username }}</strong>
|
||||
<span class="text-muted small ms-2">{{ comment.created_at|date:"M d, Y H:i" }}</span>
|
||||
</div>
|
||||
|
||||
|
||||
{% if comment.author == user or user.is_staff %}
|
||||
<div class="btn-group btn-group-sm">
|
||||
<div class="comment-actions d-flex align-items-center gap-1">
|
||||
{# Edit Button: Toggles the hidden form #}
|
||||
<button type="button" class="btn btn-edit-comment py-0 px-1 me-2" onclick="toggleCommentEdit('{{ comment.pk }}')" id="edit-btn-{{ comment.pk }}" title="{% trans 'Edit Comment' %}">
|
||||
<button type="button" class="btn btn-edit-comment py-0 px-1" onclick="toggleCommentEdit('{{ comment.pk }}')" id="edit-btn-{{ comment.pk }}" title="{% trans 'Edit Comment' %}">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
|
||||
|
||||
{# Delete Form: Submits a POST request #}
|
||||
<form method="post" action="{% url 'delete_meeting_comment' meeting.slug comment.pk %}" style="display: inline;" id="delete-form-{{ comment.pk }}">
|
||||
{% csrf_token %}
|
||||
@ -377,10 +379,10 @@ body {
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<p class="mb-0 comment-content" style="font-size: 0.85rem; white-space: pre-wrap;">{{ comment.content|linebreaksbr }}</p>
|
||||
</div>
|
||||
|
||||
|
||||
{# Hidden Edit Form #}
|
||||
<div id="comment-edit-form-{{ comment.pk }}" style="display: none; margin-top: 10px; padding-top: 10px; border-top: 1px dashed var(--kaauh-border);">
|
||||
<form method="POST" action="{% url 'edit_meeting_comment' meeting.slug comment.pk %}" id="form-{{ comment.pk }}">
|
||||
@ -405,7 +407,7 @@ body {
|
||||
<p class="text-muted">{% trans "No comments yet. Be the first to comment!" %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
||||
<hr>
|
||||
|
||||
{# 2. NEW COMMENT SUBMISSION (Remains the same) #}
|
||||
@ -479,7 +481,7 @@ body {
|
||||
// Note: This positioning logic relies on the .join-url-container being position:relative or position:absolute
|
||||
messageElement.style.left = (rect.width / 2) - (messageElement.offsetWidth / 2) + 'px';
|
||||
messageElement.style.top = '-35px';
|
||||
|
||||
|
||||
window.copyMessageTimeout = setTimeout(() => {
|
||||
messageElement.style.opacity = '0';
|
||||
}, 2000);
|
||||
@ -520,4 +522,4 @@ body {
|
||||
callback(success);
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@ -231,13 +231,14 @@
|
||||
<i class="fas fa-calendar-plus me-1"></i> {% trans "Schedule Interviews" %}
|
||||
</button>
|
||||
</form>
|
||||
<div class="vr" style="height: 28px;"></div>
|
||||
<div class="vr" style="height: 28px;"></div>
|
||||
<!--manage participants for interview-->
|
||||
<button type="button" class="btn btn-main-action btn-sm"
|
||||
data-bs-toggle="modal"
|
||||
<button type="button" class="btn btn-main-action btn-sm"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#jobAssignmentModal">
|
||||
<i class="fas fa-users-cog me-1"></i> {% trans "Manage Participants" %}
|
||||
<i class="fas fa-users-cog me-1"></i> {% trans "Manage Participants" %} ({{participants_count}})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="table-responsive">
|
||||
@ -369,6 +370,14 @@
|
||||
title="View Profile">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-info btn-sm"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#emailModal"
|
||||
hx-get="{% url 'compose_candidate_email' job.slug candidate.slug %}"
|
||||
hx-target="#emailModalBody"
|
||||
title="Email Participants">
|
||||
<i class="fas fa-envelope"></i>
|
||||
</button>
|
||||
{% if candidate.get_latest_meeting %}
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||
data-bs-toggle="modal"
|
||||
@ -432,7 +441,7 @@
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="jobAssignmentModal" tabindex="-1" aria-labelledby="jobAssignmentLabel" aria-hidden="true">
|
||||
@ -440,39 +449,59 @@
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="jobAssignmentLabel">{% trans "Manage all participants" %}</h5>
|
||||
|
||||
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{% url 'candidate_interview_view' job.slug %}">
|
||||
|
||||
<form method="post" action="{% url 'candidate_interview_view' job.slug %}">
|
||||
{% csrf_token %}
|
||||
|
||||
|
||||
<div class="modal-body">
|
||||
{{ job.internal_job_id }} {{ job.title}}
|
||||
|
||||
|
||||
<hr>
|
||||
|
||||
|
||||
<h3>👥 {% trans "Participants" %}</h3>
|
||||
{{ form.participants.errors }}
|
||||
{{ form.participants }}
|
||||
|
||||
{{ form.participants }}
|
||||
|
||||
<hr>
|
||||
|
||||
|
||||
<h3>🧑💼 {% trans "Users" %}</h3>
|
||||
{{ form.users.errors }}
|
||||
{{ form.users }}
|
||||
</div>
|
||||
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% trans "Close" %}</button>
|
||||
<button type="submit" class="btn btn-main-action">{% trans "Save" %}</button>
|
||||
<button type="submit" class="btn btn-main-action">{% trans "Save" %}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Email Modal -->
|
||||
<div class="modal fade" id="emailModal" tabindex="-1" aria-labelledby="emailModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg" role="document">
|
||||
<div class="modal-content kaauh-card">
|
||||
<div class="modal-header" style="border-bottom: 1px solid var(--kaauh-border);">
|
||||
<h5 class="modal-title" id="emailModalLabel" style="color: var(--kaauh-teal-dark);">
|
||||
<i class="fas fa-envelope me-2"></i>{% trans "Compose Email" %}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div id="emailModalBody" class="modal-body">
|
||||
<div class="text-center py-5 text-muted">
|
||||
<i class="fas fa-spinner fa-spin fa-2x"></i><br>
|
||||
{% trans "Loading email form..." %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block customJS %}
|
||||
@ -579,7 +608,7 @@
|
||||
$(document).ready(function() {
|
||||
// Check the flag passed from the Django view
|
||||
var shouldOpenModal = {{ show_modal_on_load|yesno:"true,false" }};
|
||||
|
||||
|
||||
// If the view detected an invalid form submission (POST request), open the modal
|
||||
if (shouldOpenModal) {
|
||||
// Use the native Bootstrap 5 JS function to show the modal
|
||||
|
||||
105
test_async_email.py
Normal file
105
test_async_email.py
Normal file
@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Test script for async email functionality
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import django
|
||||
|
||||
# Setup Django
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'NorahUniversity.settings')
|
||||
django.setup()
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from recruitment.email_service import send_bulk_email
|
||||
from recruitment.models import JobPosting, Candidate
|
||||
|
||||
def test_async_email():
|
||||
"""Test async email sending functionality"""
|
||||
|
||||
print("🧪 Testing Async Email Functionality")
|
||||
print("=" * 50)
|
||||
|
||||
try:
|
||||
# Get a test user
|
||||
test_user = User.objects.first()
|
||||
if not test_user:
|
||||
print("❌ No users found in database. Please create a user first.")
|
||||
return
|
||||
|
||||
# Get a test job and candidate
|
||||
test_job = JobPosting.objects.first()
|
||||
test_candidate = Candidate.objects.first()
|
||||
|
||||
if not test_job or not test_candidate:
|
||||
print("❌ No test job or candidate found. Please create some test data first.")
|
||||
return
|
||||
|
||||
print(f"📧 Test User: {test_user.email}")
|
||||
print(f"💼 Test Job: {test_job.title}")
|
||||
print(f"👤 Test Candidate: {test_candidate.name}")
|
||||
|
||||
# Test synchronous email sending
|
||||
print("\n1. Testing Synchronous Email Sending...")
|
||||
try:
|
||||
sync_result = send_bulk_email(
|
||||
subject="Test Synchronous Email",
|
||||
message="This is a test synchronous email from the ATS system.",
|
||||
recipient_list=[test_user.email],
|
||||
async_task=False
|
||||
)
|
||||
print(f" ✅ Sync result: {sync_result}")
|
||||
except Exception as e:
|
||||
print(f" ❌ Sync error: {e}")
|
||||
|
||||
# Test asynchronous email sending
|
||||
print("\n2. Testing Asynchronous Email Sending...")
|
||||
try:
|
||||
async_result = send_bulk_email(
|
||||
subject="Test Asynchronous Email",
|
||||
message="This is a test asynchronous email from the ATS system.",
|
||||
recipient_list=[test_user.email],
|
||||
async_task=True
|
||||
)
|
||||
print(f" ✅ Async result: {async_result}")
|
||||
except Exception as e:
|
||||
print(f" ❌ Async error: {e}")
|
||||
|
||||
print("\n3. Testing Email Service Status...")
|
||||
|
||||
# Check Django Q configuration
|
||||
try:
|
||||
import django_q
|
||||
from django_q.models import Task
|
||||
pending_tasks = Task.objects.count()
|
||||
print(f" 📊 Django Q Status: Installed, {pending_tasks} tasks in queue")
|
||||
except ImportError:
|
||||
print(" ⚠️ Django Q not installed")
|
||||
except Exception as e:
|
||||
print(f" 📊 Django Q Status: Installed but error checking status: {e}")
|
||||
|
||||
# Check email backend configuration
|
||||
from django.conf import settings
|
||||
email_backend = getattr(settings, 'EMAIL_BACKEND', 'not configured')
|
||||
print(f" 📧 Email Backend: {email_backend}")
|
||||
|
||||
email_host = getattr(settings, 'EMAIL_HOST', 'not configured')
|
||||
print(f" 🌐 Email Host: {email_host}")
|
||||
|
||||
email_port = getattr(settings, 'EMAIL_PORT', 'not configured')
|
||||
print(f" 🔌 Email Port: {email_port}")
|
||||
|
||||
print("\n✅ Async email functionality test completed!")
|
||||
print("💡 If emails are not being received, check:")
|
||||
print(" - Email server configuration in settings.py")
|
||||
print(" - Django Q cluster status (python manage.py qmonitor)")
|
||||
print(" - Email logs and spam folders")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Test failed with error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_async_email()
|
||||
100
test_email_attachments.py
Normal file
100
test_email_attachments.py
Normal file
File diff suppressed because one or more lines are too long
267
test_email_attachments_clean.py
Normal file
267
test_email_attachments_clean.py
Normal file
@ -0,0 +1,267 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Clean test script for email attachment functionality
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import django
|
||||
from django.conf import settings
|
||||
|
||||
# Configure Django settings BEFORE importing any Django modules
|
||||
if not settings.configured:
|
||||
settings.configure(
|
||||
DEBUG=True,
|
||||
DATABASES={
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': ':memory:',
|
||||
}
|
||||
},
|
||||
USE_TZ=True,
|
||||
SECRET_KEY='test-secret-key',
|
||||
INSTALLED_APPS=[
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.sessions',
|
||||
'recruitment',
|
||||
],
|
||||
EMAIL_BACKEND='django.core.mail.backends.console.EmailBackend',
|
||||
MEDIA_ROOT='/tmp/test_media',
|
||||
FILE_UPLOAD_TEMP_DIR='/tmp/test_uploads',
|
||||
)
|
||||
|
||||
# Setup Django
|
||||
django.setup()
|
||||
|
||||
# Now import Django modules
|
||||
from django.test import TestCase, Client
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.core.files.base import ContentFile
|
||||
import io
|
||||
from unittest.mock import Mock
|
||||
from recruitment.email_service import send_bulk_email
|
||||
from recruitment.forms import CandidateEmailForm
|
||||
from recruitment.models import JobPosting, Candidate
|
||||
from django.test import RequestFactory
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
# Setup test database
|
||||
from django.db import connection
|
||||
from django.core.management import execute_from_command_line
|
||||
|
||||
def setup_test_data():
|
||||
"""Create test data for email attachment testing"""
|
||||
# Create test user
|
||||
user = User.objects.create_user(
|
||||
username='testuser',
|
||||
email='test@example.com',
|
||||
first_name='Test',
|
||||
last_name='User'
|
||||
)
|
||||
|
||||
# Create test job
|
||||
job = JobPosting.objects.create(
|
||||
title='Test Job Position',
|
||||
description='This is a test job for email attachment testing.',
|
||||
status='ACTIVE',
|
||||
internal_job_id='TEST-001'
|
||||
)
|
||||
|
||||
# Create test candidate
|
||||
candidate = Candidate.objects.create(
|
||||
first_name='John',
|
||||
last_name='Doe',
|
||||
email='john.doe@example.com',
|
||||
phone='+1234567890',
|
||||
address='123 Test Street',
|
||||
job=job,
|
||||
stage='Interview'
|
||||
)
|
||||
|
||||
return user, job, candidate
|
||||
|
||||
def test_email_service_with_attachments():
|
||||
"""Test the email service directly with attachments"""
|
||||
print("Testing email service with attachments...")
|
||||
|
||||
# Create test files
|
||||
test_files = []
|
||||
|
||||
# Test 1: Simple text file
|
||||
text_content = "This is a test attachment content."
|
||||
text_file = ContentFile(
|
||||
text_content.encode('utf-8'),
|
||||
name='test_document.txt'
|
||||
)
|
||||
test_files.append(('test_document.txt', text_file, 'text/plain'))
|
||||
|
||||
# Test 2: PDF content (simulated)
|
||||
pdf_content = b'%PDF-1.4\n1 0 obj\n<<\n/Length 100\n>>stream\nxref\nstartxref\n1234\n5678\n/ModDate(D:20250101)\n'
|
||||
pdf_file = ContentFile(
|
||||
pdf_content,
|
||||
name='test_document.pdf'
|
||||
)
|
||||
test_files.append(('test_document.pdf', pdf_file, 'application/pdf'))
|
||||
|
||||
# Test 3: Image file (simulated PNG header)
|
||||
image_content = b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01'
|
||||
image_file = ContentFile(
|
||||
image_content,
|
||||
name='test_image.png'
|
||||
)
|
||||
test_files.append(('test_image.png', image_file, 'image/png'))
|
||||
|
||||
try:
|
||||
# Test email service with attachments
|
||||
result = send_bulk_email(
|
||||
subject='Test Email with Attachments',
|
||||
body='This is a test email with attachments.',
|
||||
from_email='test@example.com',
|
||||
recipient_list=['recipient@example.com'],
|
||||
attachments=test_files
|
||||
)
|
||||
|
||||
print(f"Email service result: {result}")
|
||||
print("✓ Email service with attachments test passed")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Email service test failed: {e}")
|
||||
return False
|
||||
|
||||
def test_candidate_email_form_with_attachments():
|
||||
"""Test the CandidateEmailForm with attachments"""
|
||||
print("\nTesting CandidateEmailForm with attachments...")
|
||||
|
||||
user, job, candidate = setup_test_data()
|
||||
|
||||
# Create test files for form
|
||||
text_file = SimpleUploadedFile(
|
||||
"test.txt",
|
||||
b"This is test content for form attachment"
|
||||
)
|
||||
|
||||
pdf_file = SimpleUploadedFile(
|
||||
"test.pdf",
|
||||
b"%PDF-1.4 test content"
|
||||
)
|
||||
|
||||
form_data = {
|
||||
'subject': 'Test Subject',
|
||||
'body': 'Test body content',
|
||||
'from_email': 'test@example.com',
|
||||
'recipient_list': 'recipient@example.com',
|
||||
}
|
||||
|
||||
files_data = {
|
||||
'attachments': [text_file, pdf_file]
|
||||
}
|
||||
|
||||
try:
|
||||
form = CandidateEmailForm(data=form_data, files=files_data)
|
||||
|
||||
if form.is_valid():
|
||||
print("✓ Form validation passed")
|
||||
print(f"Form cleaned data: {form.cleaned_data}")
|
||||
|
||||
# Test form processing
|
||||
try:
|
||||
result = form.save()
|
||||
print(f"✓ Form save result: {result}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"✗ Form save failed: {e}")
|
||||
return False
|
||||
else:
|
||||
print(f"✗ Form validation failed: {form.errors}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Form test failed: {e}")
|
||||
return False
|
||||
|
||||
def test_email_view_with_attachments():
|
||||
"""Test the email view with attachments"""
|
||||
print("\nTesting email view with attachments...")
|
||||
|
||||
user, job, candidate = setup_test_data()
|
||||
factory = RequestFactory()
|
||||
|
||||
# Create a mock request with files
|
||||
text_file = SimpleUploadedFile(
|
||||
"test.txt",
|
||||
b"This is test content for view attachment"
|
||||
)
|
||||
|
||||
request = factory.post(
|
||||
'/recruitment/send-candidate-email/',
|
||||
data={
|
||||
'subject': 'Test Subject',
|
||||
'body': 'Test body content',
|
||||
'from_email': 'test@example.com',
|
||||
'recipient_list': 'recipient@example.com',
|
||||
},
|
||||
format='multipart'
|
||||
)
|
||||
request.FILES['attachments'] = [text_file]
|
||||
request.user = user
|
||||
|
||||
try:
|
||||
# Import and test the view
|
||||
from recruitment.views import send_candidate_email
|
||||
|
||||
response = send_candidate_email(request)
|
||||
print(f"View response status: {response.status_code}")
|
||||
|
||||
if response.status_code == 200:
|
||||
print("✓ Email view test passed")
|
||||
return True
|
||||
else:
|
||||
print(f"✗ Email view test failed with status: {response.status_code}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Email view test failed: {e}")
|
||||
return False
|
||||
|
||||
def main():
|
||||
"""Run all email attachment tests"""
|
||||
print("=" * 60)
|
||||
print("EMAIL ATTACHMENT FUNCTIONALITY TESTS")
|
||||
print("=" * 60)
|
||||
|
||||
# Initialize Django
|
||||
django.setup()
|
||||
|
||||
# Create tables
|
||||
from django.core.management import execute_from_command_line
|
||||
execute_from_command_line(['manage.py', 'migrate', '--run-syncdb'])
|
||||
|
||||
results = []
|
||||
|
||||
# Run tests
|
||||
results.append(test_email_service_with_attachments())
|
||||
results.append(test_candidate_email_form_with_attachments())
|
||||
results.append(test_email_view_with_attachments())
|
||||
|
||||
# Summary
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST SUMMARY")
|
||||
print("=" * 60)
|
||||
|
||||
passed = sum(results)
|
||||
total = len(results)
|
||||
|
||||
print(f"Tests passed: {passed}/{total}")
|
||||
|
||||
if passed == total:
|
||||
print("🎉 All email attachment tests passed!")
|
||||
return True
|
||||
else:
|
||||
print("❌ Some email attachment tests failed!")
|
||||
return False
|
||||
|
||||
if __name__ == '__main__':
|
||||
success = main()
|
||||
exit(0 if success else 1)
|
||||
218
test_email_composition.py
Normal file
218
test_email_composition.py
Normal file
@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script to verify email composition functionality
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import django
|
||||
|
||||
# Setup Django environment
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'NorahUniversity.settings')
|
||||
django.setup()
|
||||
|
||||
from django.test import TestCase, Client
|
||||
from django.urls import reverse
|
||||
from django.contrib.auth.models import User
|
||||
from django.utils import timezone
|
||||
from recruitment.models import JobPosting, Candidate
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
def test_email_composition_view():
|
||||
"""Test the email composition view"""
|
||||
print("Testing email composition view...")
|
||||
|
||||
# Create test user (delete if exists)
|
||||
User.objects.filter(username='testuser').delete()
|
||||
user = User.objects.create_user(
|
||||
username='testuser',
|
||||
email='test@example.com',
|
||||
password='testpass123'
|
||||
)
|
||||
|
||||
# Create test job
|
||||
job = JobPosting.objects.create(
|
||||
title='Test Job',
|
||||
internal_job_id='TEST001',
|
||||
description='Test job description',
|
||||
status='active',
|
||||
application_deadline=timezone.now() + timezone.timedelta(days=30)
|
||||
)
|
||||
|
||||
# Add user to job participants so they appear in recipient choices
|
||||
job.users.add(user)
|
||||
|
||||
# Create test candidate
|
||||
candidate = Candidate.objects.create(
|
||||
first_name='Test Candidate',
|
||||
last_name='',
|
||||
email='candidate@example.com',
|
||||
phone='1234567890',
|
||||
job=job
|
||||
)
|
||||
|
||||
# Create client and login
|
||||
client = Client()
|
||||
client.login(username='testuser', password='testpass123')
|
||||
|
||||
# Test GET request to email composition view
|
||||
url = reverse('compose_candidate_email', kwargs={
|
||||
'job_slug': job.slug,
|
||||
'candidate_slug': candidate.slug
|
||||
})
|
||||
|
||||
try:
|
||||
response = client.get(url)
|
||||
print(f"✓ GET request successful: {response.status_code}")
|
||||
|
||||
if response.status_code == 200:
|
||||
print("✓ Email composition form rendered successfully")
|
||||
|
||||
# Check if form contains expected fields
|
||||
content = response.content.decode('utf-8')
|
||||
if 'subject' in content.lower():
|
||||
print("✓ Subject field found in form")
|
||||
if 'message' in content.lower():
|
||||
print("✓ Message field found in form")
|
||||
if 'recipients' in content.lower():
|
||||
print("✓ Recipients field found in form")
|
||||
|
||||
else:
|
||||
print(f"✗ Unexpected status code: {response.status_code}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Error testing GET request: {e}")
|
||||
|
||||
# Test POST request with mock email sending
|
||||
post_data = {
|
||||
'subject': 'Test Subject',
|
||||
'message': 'Test message content',
|
||||
'recipients': ['candidate@example.com'],
|
||||
'include_candidate_info': True,
|
||||
'include_meeting_details': False
|
||||
}
|
||||
|
||||
with patch('django.core.mail.send_mass_mail') as mock_send_mail:
|
||||
mock_send_mail.return_value = 1
|
||||
|
||||
try:
|
||||
response = client.post(url, data=post_data)
|
||||
print(f"✓ POST request successful: {response.status_code}")
|
||||
|
||||
if response.status_code == 200:
|
||||
# Check if JSON response is correct
|
||||
try:
|
||||
json_data = response.json()
|
||||
if json_data.get('success'):
|
||||
print("✓ Email sent successfully")
|
||||
print(f"✓ Success message: {json_data.get('message')}")
|
||||
else:
|
||||
print(f"✗ Email send failed: {json_data.get('error')}")
|
||||
except:
|
||||
print("✗ Invalid JSON response")
|
||||
else:
|
||||
print(f"✗ Unexpected status code: {response.status_code}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Error testing POST request: {e}")
|
||||
|
||||
# Clean up
|
||||
user.delete()
|
||||
job.delete()
|
||||
candidate.delete()
|
||||
|
||||
print("Email composition test completed!")
|
||||
|
||||
def test_email_form():
|
||||
"""Test the CandidateEmailForm"""
|
||||
print("\nTesting CandidateEmailForm...")
|
||||
|
||||
from recruitment.forms import CandidateEmailForm
|
||||
|
||||
# Create test user for form (delete if exists)
|
||||
User.objects.filter(username='formuser').delete()
|
||||
form_user = User.objects.create_user(
|
||||
username='formuser',
|
||||
email='form@example.com',
|
||||
password='formpass123'
|
||||
)
|
||||
|
||||
# Create test job and candidate for form
|
||||
job = JobPosting.objects.create(
|
||||
title='Test Job Form',
|
||||
internal_job_id='TEST002',
|
||||
description='Test job description for form',
|
||||
status='active',
|
||||
application_deadline=timezone.now() + timezone.timedelta(days=30)
|
||||
)
|
||||
|
||||
# Add user to job participants so they appear in recipient choices
|
||||
job.users.add(form_user)
|
||||
|
||||
candidate = Candidate.objects.create(
|
||||
first_name='Test Candidate',
|
||||
last_name='Form',
|
||||
email='candidate_form@example.com',
|
||||
phone='1234567890',
|
||||
job=job
|
||||
)
|
||||
|
||||
try:
|
||||
# Test valid form data - get available choices from form
|
||||
form = CandidateEmailForm(job, candidate)
|
||||
available_choices = [choice[0] for choice in form.fields['recipients'].choices]
|
||||
|
||||
# Use first available choice for testing
|
||||
test_recipient = available_choices[0] if available_choices else None
|
||||
|
||||
if test_recipient:
|
||||
form = CandidateEmailForm(job, candidate, data={
|
||||
'subject': 'Test Subject',
|
||||
'message': 'Test message content',
|
||||
'recipients': [test_recipient],
|
||||
'include_candidate_info': True,
|
||||
'include_meeting_details': False
|
||||
})
|
||||
|
||||
if form.is_valid():
|
||||
print("✓ Form validation passed")
|
||||
print(f"✓ Cleaned recipients: {form.cleaned_data['recipients']}")
|
||||
else:
|
||||
print(f"✗ Form validation failed: {form.errors}")
|
||||
else:
|
||||
print("✗ No recipient choices available for testing")
|
||||
except Exception as e:
|
||||
print(f"✗ Error testing form: {e}")
|
||||
|
||||
try:
|
||||
# Test invalid form data (empty subject)
|
||||
form = CandidateEmailForm(job, candidate, data={
|
||||
'subject': '',
|
||||
'message': 'Test message content',
|
||||
'recipients': [],
|
||||
'include_candidate_info': True,
|
||||
'include_meeting_details': False
|
||||
})
|
||||
|
||||
if not form.is_valid():
|
||||
print("✓ Form correctly rejected empty subject")
|
||||
if 'subject' in form.errors:
|
||||
print("✓ Subject field has validation error")
|
||||
else:
|
||||
print("✗ Form should have failed validation")
|
||||
except Exception as e:
|
||||
print(f"✗ Error testing invalid form: {e}")
|
||||
|
||||
# Clean up
|
||||
job.delete()
|
||||
candidate.delete()
|
||||
|
||||
if __name__ == '__main__':
|
||||
print("Running Email Composition Tests")
|
||||
print("=" * 50)
|
||||
|
||||
test_email_form()
|
||||
test_email_composition_view()
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
print("All tests completed!")
|
||||
507
test_email_form_js.html
Normal file
507
test_email_form_js.html
Normal file
@ -0,0 +1,507 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Email Compose Form Test</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container mt-4">
|
||||
<h1>Email Compose Form JavaScript Test</h1>
|
||||
|
||||
<!-- Mock form for testing JavaScript -->
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-envelope me-2"></i>
|
||||
Compose Email
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" id="email-compose-form" action="/test/">
|
||||
<input type="hidden" name="csrfmiddlewaretoken" value="test-token">
|
||||
|
||||
<!-- Subject Field -->
|
||||
<div class="mb-3">
|
||||
<label for="id_subject" class="form-label fw-bold">
|
||||
Subject
|
||||
</label>
|
||||
<input type="text" class="form-control" id="id_subject" name="subject">
|
||||
</div>
|
||||
|
||||
<!-- Recipients Field -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">
|
||||
Recipients
|
||||
</label>
|
||||
<div class="border rounded p-3 bg-light" style="max-height: 200px; overflow-y: auto;">
|
||||
<div class="form-check mb-2">
|
||||
<input type="checkbox" class="form-check-input" name="recipients" value="user1@example.com" id="recipient1">
|
||||
<label class="form-check-label" for="recipient1">user1@example.com</label>
|
||||
</div>
|
||||
<div class="form-check mb-2">
|
||||
<input type="checkbox" class="form-check-input" name="recipients" value="user2@example.com" id="recipient2">
|
||||
<label class="form-check-label" for="recipient2">user2@example.com</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Message Field -->
|
||||
<div class="mb-3">
|
||||
<label for="id_message" class="form-label fw-bold">
|
||||
Message
|
||||
</label>
|
||||
<textarea class="form-control" id="id_message" name="message" rows="5"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Options Checkboxes -->
|
||||
<div class="mb-4">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" name="include_candidate_info" id="id_include_candidate_info">
|
||||
<label class="form-check-label" for="id_include_candidate_info">
|
||||
Include candidate information
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" name="include_meeting_details" id="id_include_meeting_details">
|
||||
<label class="form-check-label" for="id_include_meeting_details">
|
||||
Include meeting details
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div class="text-muted small">
|
||||
<i class="fas fa-info-circle me-1"></i>
|
||||
Email will be sent to all selected recipients
|
||||
</div>
|
||||
<div>
|
||||
<button type="button" class="btn btn-secondary me-2" data-bs-dismiss="modal">
|
||||
<i class="fas fa-times me-1"></i>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" id="send-email-btn">
|
||||
<i class="fas fa-paper-plane me-1"></i>
|
||||
Send Email
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading Overlay -->
|
||||
<div id="email-loading-overlay" class="d-none">
|
||||
<div class="d-flex justify-content-center align-items-center" style="min-height: 200px;">
|
||||
<div class="text-center">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
Sending email...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success/Error Messages Container -->
|
||||
<div id="email-messages-container"></div>
|
||||
</div>
|
||||
|
||||
<!-- Test Results -->
|
||||
<div class="mt-4">
|
||||
<h3>Test Results</h3>
|
||||
<div id="test-results"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.card {
|
||||
border: none;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
border-radius: 8px 8px 0 0 !important;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
border-color: #00636e;
|
||||
box-shadow: 0 0 0 0.2rem rgba(0,99,110,0.25);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #00636e;
|
||||
border-color: #00636e;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #004a53;
|
||||
border-color: #004a53;
|
||||
}
|
||||
|
||||
.form-check-input:checked {
|
||||
background-color: #00636e;
|
||||
border-color: #00636e;
|
||||
}
|
||||
|
||||
.border {
|
||||
border-color: #dee2e6 !important;
|
||||
}
|
||||
|
||||
.bg-light {
|
||||
background-color: #f8f9fa !important;
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
color: #dc3545 !important;
|
||||
}
|
||||
|
||||
.spinner-border {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const form = document.getElementById('email-compose-form');
|
||||
const sendBtn = document.getElementById('send-email-btn');
|
||||
const loadingOverlay = document.getElementById('email-loading-overlay');
|
||||
const messagesContainer = document.getElementById('email-messages-container');
|
||||
const testResults = document.getElementById('test-results');
|
||||
|
||||
// Test results tracking
|
||||
let tests = [];
|
||||
|
||||
function addTestResult(testName, passed, message) {
|
||||
tests.push({ name: testName, passed, message });
|
||||
updateTestResults();
|
||||
}
|
||||
|
||||
function updateTestResults() {
|
||||
const passedTests = tests.filter(t => t.passed).length;
|
||||
const totalTests = tests.length;
|
||||
|
||||
testResults.innerHTML = `
|
||||
<div class="alert alert-info">
|
||||
<strong>Tests: ${passedTests}/${totalTests} passed</strong>
|
||||
</div>
|
||||
<ul class="list-group">
|
||||
${tests.map(test => `
|
||||
<li class="list-group-item ${test.passed ? 'list-group-item-success' : 'list-group-item-danger'}">
|
||||
<i class="fas fa-${test.passed ? 'check' : 'times'} me-2"></i>
|
||||
<strong>${test.name}:</strong> ${test.message}
|
||||
</li>
|
||||
`).join('')}
|
||||
</ul>
|
||||
`;
|
||||
}
|
||||
|
||||
if (form) {
|
||||
form.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
addTestResult('Form Submit Handler', true, 'Form submission intercepted successfully');
|
||||
|
||||
// Show loading state
|
||||
if (sendBtn) {
|
||||
sendBtn.disabled = true;
|
||||
sendBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i> Sending...';
|
||||
addTestResult('Loading State', true, 'Button loading state activated');
|
||||
}
|
||||
|
||||
if (loadingOverlay) {
|
||||
loadingOverlay.classList.remove('d-none');
|
||||
addTestResult('Loading Overlay', true, 'Loading overlay displayed');
|
||||
}
|
||||
|
||||
// Clear previous messages
|
||||
if (messagesContainer) {
|
||||
messagesContainer.innerHTML = '';
|
||||
}
|
||||
|
||||
// Mock form submission
|
||||
setTimeout(() => {
|
||||
// Hide loading state
|
||||
if (sendBtn) {
|
||||
sendBtn.disabled = false;
|
||||
sendBtn.innerHTML = '<i class="fas fa-paper-plane me-1"></i> Send Email';
|
||||
}
|
||||
|
||||
if (loadingOverlay) {
|
||||
loadingOverlay.classList.add('d-none');
|
||||
}
|
||||
|
||||
// Show success message
|
||||
showMessage('Email sent successfully!', 'success');
|
||||
addTestResult('Success Message', true, 'Success message displayed');
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
function showMessage(message, type) {
|
||||
if (!messagesContainer) return;
|
||||
|
||||
const alertClass = type === 'success' ? 'alert-success' : 'alert-danger';
|
||||
const icon = type === 'success' ? 'fa-check-circle' : 'fa-exclamation-triangle';
|
||||
|
||||
const messageHtml = `
|
||||
<div class="alert ${alertClass} alert-dismissible fade show" role="alert">
|
||||
<i class="fas ${icon} me-2"></i>
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
messagesContainer.innerHTML = messageHtml;
|
||||
|
||||
// Auto-hide success messages after 5 seconds
|
||||
if (type === 'success') {
|
||||
setTimeout(() => {
|
||||
const alert = messagesContainer.querySelector('.alert');
|
||||
if (alert) {
|
||||
const bsAlert = bootstrap.Alert(alert);
|
||||
bsAlert.close();
|
||||
addTestResult('Auto-hide Message', true, 'Message auto-hidden after 5 seconds');
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
// Form validation
|
||||
function validateForm() {
|
||||
let isValid = true;
|
||||
const subject = form.querySelector('#id_subject');
|
||||
const message = form.querySelector('#id_message');
|
||||
const recipients = form.querySelectorAll('input[name="recipients"]:checked');
|
||||
|
||||
// Clear previous validation states
|
||||
form.querySelectorAll('.is-invalid').forEach(field => {
|
||||
field.classList.remove('is-invalid');
|
||||
});
|
||||
form.querySelectorAll('.invalid-feedback').forEach(feedback => {
|
||||
feedback.remove();
|
||||
});
|
||||
|
||||
// Validate subject
|
||||
if (!subject || !subject.value.trim()) {
|
||||
showFieldError(subject, 'Subject is required');
|
||||
isValid = false;
|
||||
addTestResult('Subject Validation', false, 'Subject validation triggered - field empty');
|
||||
} else {
|
||||
addTestResult('Subject Validation', true, 'Subject validation passed');
|
||||
}
|
||||
|
||||
// Validate message
|
||||
if (!message || !message.value.trim()) {
|
||||
showFieldError(message, 'Message is required');
|
||||
isValid = false;
|
||||
addTestResult('Message Validation', false, 'Message validation triggered - field empty');
|
||||
} else {
|
||||
addTestResult('Message Validation', true, 'Message validation passed');
|
||||
}
|
||||
|
||||
// Validate recipients
|
||||
if (recipients.length === 0) {
|
||||
const recipientsContainer = form.querySelector('.border.rounded.p-3.bg-light');
|
||||
if (recipientsContainer) {
|
||||
recipientsContainer.classList.add('border-danger');
|
||||
showFieldError(recipientsContainer, 'Please select at least one recipient');
|
||||
}
|
||||
isValid = false;
|
||||
addTestResult('Recipients Validation', false, 'Recipients validation triggered - none selected');
|
||||
} else {
|
||||
addTestResult('Recipients Validation', true, `Recipients validation passed - ${recipients.length} selected`);
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
function showFieldError(field, message) {
|
||||
if (!field) return;
|
||||
|
||||
field.classList.add('is-invalid');
|
||||
|
||||
const feedback = document.createElement('div');
|
||||
feedback.className = 'invalid-feedback';
|
||||
feedback.textContent = message;
|
||||
|
||||
if (field.classList.contains('border')) {
|
||||
// For container elements (like recipients)
|
||||
field.parentNode.appendChild(feedback);
|
||||
} else {
|
||||
// For form fields
|
||||
field.parentNode.appendChild(feedback);
|
||||
}
|
||||
}
|
||||
|
||||
// Character counter for message field
|
||||
function setupCharacterCounter() {
|
||||
const messageField = form.querySelector('#id_message');
|
||||
if (!messageField) return;
|
||||
|
||||
const counter = document.createElement('div');
|
||||
counter.className = 'text-muted small mt-1';
|
||||
counter.id = 'message-counter';
|
||||
|
||||
messageField.parentNode.appendChild(counter);
|
||||
|
||||
function updateCounter() {
|
||||
const length = messageField.value.length;
|
||||
const maxLength = 5000; // Adjust as needed
|
||||
counter.textContent = `${length} / ${maxLength} characters`;
|
||||
|
||||
if (length > maxLength * 0.9) {
|
||||
counter.classList.add('text-warning');
|
||||
counter.classList.remove('text-muted');
|
||||
} else {
|
||||
counter.classList.remove('text-warning');
|
||||
counter.classList.add('text-muted');
|
||||
}
|
||||
}
|
||||
|
||||
messageField.addEventListener('input', updateCounter);
|
||||
updateCounter(); // Initial count
|
||||
addTestResult('Character Counter', true, 'Character counter initialized');
|
||||
}
|
||||
|
||||
// Auto-save functionality
|
||||
let autoSaveTimer;
|
||||
function setupAutoSave() {
|
||||
const subject = form.querySelector('#id_subject');
|
||||
const message = form.querySelector('#id_message');
|
||||
|
||||
if (!subject || !message) return;
|
||||
|
||||
function saveDraft() {
|
||||
const draftData = {
|
||||
subject: subject.value,
|
||||
message: message.value,
|
||||
recipients: Array.from(form.querySelectorAll('input[name="recipients"]:checked')).map(cb => cb.value),
|
||||
include_candidate_info: form.querySelector('#id_include_candidate_info').checked,
|
||||
include_meeting_details: form.querySelector('#id_include_meeting_details').checked
|
||||
};
|
||||
|
||||
localStorage.setItem('email_draft_test', JSON.stringify(draftData));
|
||||
addTestResult('Auto-save', true, 'Draft saved to localStorage');
|
||||
}
|
||||
|
||||
function autoSave() {
|
||||
clearTimeout(autoSaveTimer);
|
||||
autoSaveTimer = setTimeout(saveDraft, 2000); // Save after 2 seconds of inactivity
|
||||
}
|
||||
|
||||
[subject, message].forEach(field => {
|
||||
field.addEventListener('input', autoSave);
|
||||
});
|
||||
|
||||
form.addEventListener('change', autoSave);
|
||||
addTestResult('Auto-save Setup', true, 'Auto-save functionality initialized');
|
||||
}
|
||||
|
||||
function loadDraft() {
|
||||
const draftData = localStorage.getItem('email_draft_test');
|
||||
if (!draftData) return;
|
||||
|
||||
try {
|
||||
const draft = JSON.parse(draftData);
|
||||
|
||||
const subject = form.querySelector('#id_subject');
|
||||
const message = form.querySelector('#id_message');
|
||||
|
||||
if (subject && draft.subject) subject.value = draft.subject;
|
||||
if (message && draft.message) message.value = draft.message;
|
||||
|
||||
// Restore recipients
|
||||
if (draft.recipients) {
|
||||
form.querySelectorAll('input[name="recipients"]').forEach(cb => {
|
||||
cb.checked = draft.recipients.includes(cb.value);
|
||||
});
|
||||
}
|
||||
|
||||
// Restore checkboxes
|
||||
if (draft.include_candidate_info) {
|
||||
form.querySelector('#id_include_candidate_info').checked = draft.include_candidate_info;
|
||||
}
|
||||
if (draft.include_meeting_details) {
|
||||
form.querySelector('#id_include_meeting_details').checked = draft.include_meeting_details;
|
||||
}
|
||||
|
||||
addTestResult('Draft Loading', true, 'Draft loaded from localStorage');
|
||||
showMessage('Draft restored from local storage', 'success');
|
||||
} catch (e) {
|
||||
console.error('Error loading draft:', e);
|
||||
addTestResult('Draft Loading', false, 'Error loading draft: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function clearDraft() {
|
||||
localStorage.removeItem('email_draft_test');
|
||||
}
|
||||
|
||||
// Initialize form enhancements
|
||||
setupCharacterCounter();
|
||||
setupAutoSave();
|
||||
|
||||
// Load draft on page load
|
||||
setTimeout(loadDraft, 100);
|
||||
|
||||
// Clear draft on successful submission
|
||||
const originalSubmitHandler = form.onsubmit;
|
||||
form.addEventListener('submit', function(e) {
|
||||
const isValid = validateForm();
|
||||
if (!isValid) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Clear draft on successful submission
|
||||
setTimeout(clearDraft, 2000);
|
||||
});
|
||||
|
||||
// Add keyboard shortcuts
|
||||
document.addEventListener('keydown', function(e) {
|
||||
// Ctrl/Cmd + Enter to submit
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||
const activeElement = document.activeElement;
|
||||
if (activeElement && (activeElement.tagName === 'TEXTAREA' || activeElement.tagName === 'INPUT')) {
|
||||
form.dispatchEvent(new Event('submit'));
|
||||
addTestResult('Keyboard Shortcut', true, 'Ctrl+Enter shortcut triggered');
|
||||
}
|
||||
}
|
||||
|
||||
// Escape to cancel/close modal
|
||||
if (e.key === 'Escape') {
|
||||
addTestResult('Keyboard Shortcut', true, 'Escape key pressed');
|
||||
}
|
||||
});
|
||||
|
||||
// Test validation with empty form
|
||||
setTimeout(() => {
|
||||
addTestResult('Initial Validation Test', validateForm() === false, 'Empty form correctly rejected');
|
||||
}, 500);
|
||||
|
||||
console.log('Email compose form initialized');
|
||||
addTestResult('Initialization', true, 'Email compose form JavaScript initialized successfully');
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
176
test_html_email_template.py
Normal file
176
test_html_email_template.py
Normal file
@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Test script for HTML email template functionality
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import django
|
||||
|
||||
# Setup Django
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'NorahUniversity.settings')
|
||||
django.setup()
|
||||
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
from recruitment.models import Candidate, JobPosting
|
||||
from recruitment.email_service import send_interview_invitation_email
|
||||
|
||||
def test_html_template():
|
||||
"""Test the HTML email template rendering"""
|
||||
print("Testing HTML email template...")
|
||||
|
||||
# Create test context
|
||||
context = {
|
||||
'candidate_name': 'John Doe',
|
||||
'candidate_email': 'john.doe@example.com',
|
||||
'candidate_phone': '+966 50 123 4567',
|
||||
'job_title': 'Senior Software Developer',
|
||||
'department': 'Information Technology',
|
||||
'company_name': 'Norah University',
|
||||
'meeting_topic': 'Interview for Senior Software Developer',
|
||||
'meeting_date_time': 'November 15, 2025 at 2:00 PM',
|
||||
'meeting_duration': '60 minutes',
|
||||
'join_url': 'https://zoom.us/j/123456789',
|
||||
}
|
||||
|
||||
try:
|
||||
# Test template rendering
|
||||
html_content = render_to_string('emails/interview_invitation.html', context)
|
||||
plain_content = strip_tags(html_content)
|
||||
|
||||
print("✅ HTML template rendered successfully!")
|
||||
print(f"HTML content length: {len(html_content)} characters")
|
||||
print(f"Plain text length: {len(plain_content)} characters")
|
||||
|
||||
# Save rendered HTML to file for inspection
|
||||
with open('test_interview_email.html', 'w', encoding='utf-8') as f:
|
||||
f.write(html_content)
|
||||
print("✅ HTML content saved to 'test_interview_email.html'")
|
||||
|
||||
# Save plain text to file for inspection
|
||||
with open('test_interview_email.txt', 'w', encoding='utf-8') as f:
|
||||
f.write(plain_content)
|
||||
print("✅ Plain text content saved to 'test_interview_email.txt'")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error rendering template: {e}")
|
||||
return False
|
||||
|
||||
def test_email_service_function():
|
||||
"""Test the email service function with mock data"""
|
||||
print("\nTesting email service function...")
|
||||
|
||||
try:
|
||||
# Get a real candidate and job for testing
|
||||
candidate = Candidate.objects.first()
|
||||
job = JobPosting.objects.first()
|
||||
|
||||
if not candidate:
|
||||
print("❌ No candidates found in database")
|
||||
return False
|
||||
|
||||
if not job:
|
||||
print("❌ No jobs found in database")
|
||||
return False
|
||||
|
||||
print(f"Using candidate: {candidate.name}")
|
||||
print(f"Using job: {job.title}")
|
||||
|
||||
# Test meeting details
|
||||
meeting_details = {
|
||||
'topic': f'Interview for {job.title}',
|
||||
'date_time': 'November 15, 2025 at 2:00 PM',
|
||||
'duration': '60 minutes',
|
||||
'join_url': 'https://zoom.us/j/test123456',
|
||||
}
|
||||
|
||||
# Test the email function (without actually sending)
|
||||
result = send_interview_invitation_email(
|
||||
candidate=candidate,
|
||||
job=job,
|
||||
meeting_details=meeting_details,
|
||||
recipient_list=['test@example.com']
|
||||
)
|
||||
|
||||
if result['success']:
|
||||
print("✅ Email service function executed successfully!")
|
||||
print(f"Recipients: {result.get('recipients_count', 'N/A')}")
|
||||
print(f"Message: {result.get('message', 'N/A')}")
|
||||
else:
|
||||
print(f"❌ Email service function failed: {result.get('error', 'Unknown error')}")
|
||||
|
||||
return result['success']
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error testing email service: {e}")
|
||||
return False
|
||||
|
||||
def test_template_variables():
|
||||
"""Test all template variables"""
|
||||
print("\nTesting template variables...")
|
||||
|
||||
# Test with minimal data
|
||||
minimal_context = {
|
||||
'candidate_name': 'Test Candidate',
|
||||
'candidate_email': 'test@example.com',
|
||||
'job_title': 'Test Position',
|
||||
}
|
||||
|
||||
try:
|
||||
html_content = render_to_string('emails/interview_invitation.html', minimal_context)
|
||||
print("✅ Template works with minimal data")
|
||||
|
||||
# Check for required variables
|
||||
required_vars = ['candidate_name', 'candidate_email', 'job_title']
|
||||
missing_vars = []
|
||||
|
||||
for var in required_vars:
|
||||
if f'{{ {var} }}' in html_content:
|
||||
missing_vars.append(var)
|
||||
|
||||
if missing_vars:
|
||||
print(f"⚠️ Missing variables: {missing_vars}")
|
||||
else:
|
||||
print("✅ All required variables are present")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error with minimal data: {e}")
|
||||
return False
|
||||
|
||||
def main():
|
||||
"""Run all tests"""
|
||||
print("🧪 Testing HTML Email Template System")
|
||||
print("=" * 50)
|
||||
|
||||
# Test 1: Template rendering
|
||||
test1_passed = test_html_template()
|
||||
|
||||
# Test 2: Template variables
|
||||
test2_passed = test_template_variables()
|
||||
|
||||
# Test 3: Email service function
|
||||
test3_passed = test_email_service_function()
|
||||
|
||||
# Summary
|
||||
print("\n" + "=" * 50)
|
||||
print("📊 TEST SUMMARY")
|
||||
print(f"Template Rendering: {'✅ PASS' if test1_passed else '❌ FAIL'}")
|
||||
print(f"Template Variables: {'✅ PASS' if test2_passed else '❌ FAIL'}")
|
||||
print(f"Email Service: {'✅ PASS' if test3_passed else '❌ FAIL'}")
|
||||
|
||||
overall_success = test1_passed and test2_passed and test3_passed
|
||||
print(f"\nOverall Result: {'✅ ALL TESTS PASSED' if overall_success else '❌ SOME TESTS FAILED'}")
|
||||
|
||||
if overall_success:
|
||||
print("\n🎉 HTML email template system is ready!")
|
||||
print("You can now send professional interview invitations using the new template.")
|
||||
else:
|
||||
print("\n🔧 Please fix the issues before using the template system.")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
139
test_interview_email.html
Normal file
139
test_interview_email.html
Normal file
@ -0,0 +1,139 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Interview Invitation</title>
|
||||
<style>
|
||||
/* Basic reset and typography */
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333333;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
/* Container for the main content */
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 20px auto;
|
||||
background-color: #ffffff;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
/* Header styling */
|
||||
.header {
|
||||
text-align: center;
|
||||
border-bottom: 2px solid #007bff;
|
||||
padding-bottom: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.header h1 {
|
||||
color: #007bff;
|
||||
font-size: 24px;
|
||||
margin: 0;
|
||||
}
|
||||
/* Section headings */
|
||||
.section-header {
|
||||
color: #007bff;
|
||||
font-size: 18px;
|
||||
margin-top: 25px;
|
||||
margin-bottom: 10px;
|
||||
border-left: 4px solid #007bff;
|
||||
padding-left: 10px;
|
||||
}
|
||||
/* Key detail rows */
|
||||
.detail-row {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.detail-row strong {
|
||||
display: inline-block;
|
||||
width: 120px;
|
||||
color: #555555;
|
||||
}
|
||||
/* Button style for the Join URL */
|
||||
.button {
|
||||
display: block;
|
||||
width: 80%;
|
||||
margin: 25px auto;
|
||||
padding: 12px 0;
|
||||
background-color: #28a745; /* Success/Go color */
|
||||
color: #ffffff;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
border-radius: 5px;
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
}
|
||||
/* Footer/closing section */
|
||||
.footer {
|
||||
margin-top: 30px;
|
||||
padding-top: 15px;
|
||||
border-top: 1px dashed #cccccc;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: #777777;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>Interview Confirmation</h1>
|
||||
</div>
|
||||
|
||||
<p>Dear <strong>John Doe</strong>,</p>
|
||||
<p>Thank you for your interest in the position. We are pleased to invite you to a virtual interview. Please find the details below.</p>
|
||||
|
||||
<h2 class="section-header">Interview Details</h2>
|
||||
<div class="detail-row">
|
||||
<strong>Topic:</strong> Interview for Senior Software Developer
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<strong>Date & Time:</strong> <strong>November 15, 2025 at 2:00 PM</strong>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<strong>Duration:</strong> 60 minutes
|
||||
</div>
|
||||
|
||||
|
||||
<a href="https://zoom.us/j/123456789" class="button" target="_blank">
|
||||
Join Interview Now
|
||||
</a>
|
||||
<p style="text-align: center; font-size: 14px; color: #777;">Please click the button above to join the meeting at the scheduled time.</p>
|
||||
|
||||
|
||||
<h2 class="section-header">Your Information</h2>
|
||||
<div class="detail-row">
|
||||
<strong>Name:</strong> John Doe
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<strong>Email:</strong> john.doe@example.com
|
||||
</div>
|
||||
|
||||
<div class="detail-row">
|
||||
<strong>Phone:</strong> +966 50 123 4567
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<h2 class="section-header">Position Details</h2>
|
||||
<div class="detail-row">
|
||||
<strong>Position:</strong> Senior Software Developer
|
||||
</div>
|
||||
|
||||
<div class="detail-row">
|
||||
<strong>Department:</strong> Information Technology
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="footer">
|
||||
<p>We look forward to speaking with you.</p>
|
||||
<p>If you have any questions, please reply to this email.</p>
|
||||
<p>Best regards,<br>The Norah University Team</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
139
test_interview_email.txt
Normal file
139
test_interview_email.txt
Normal file
@ -0,0 +1,139 @@
|
||||
|
||||
|
||||
|
||||
|
||||
Interview Invitation
|
||||
|
||||
/* Basic reset and typography */
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333333;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
/* Container for the main content */
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 20px auto;
|
||||
background-color: #ffffff;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
/* Header styling */
|
||||
.header {
|
||||
text-align: center;
|
||||
border-bottom: 2px solid #007bff;
|
||||
padding-bottom: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.header h1 {
|
||||
color: #007bff;
|
||||
font-size: 24px;
|
||||
margin: 0;
|
||||
}
|
||||
/* Section headings */
|
||||
.section-header {
|
||||
color: #007bff;
|
||||
font-size: 18px;
|
||||
margin-top: 25px;
|
||||
margin-bottom: 10px;
|
||||
border-left: 4px solid #007bff;
|
||||
padding-left: 10px;
|
||||
}
|
||||
/* Key detail rows */
|
||||
.detail-row {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.detail-row strong {
|
||||
display: inline-block;
|
||||
width: 120px;
|
||||
color: #555555;
|
||||
}
|
||||
/* Button style for the Join URL */
|
||||
.button {
|
||||
display: block;
|
||||
width: 80%;
|
||||
margin: 25px auto;
|
||||
padding: 12px 0;
|
||||
background-color: #28a745; /* Success/Go color */
|
||||
color: #ffffff;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
border-radius: 5px;
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
}
|
||||
/* Footer/closing section */
|
||||
.footer {
|
||||
margin-top: 30px;
|
||||
padding-top: 15px;
|
||||
border-top: 1px dashed #cccccc;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: #777777;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Interview Confirmation
|
||||
|
||||
|
||||
Dear John Doe,
|
||||
Thank you for your interest in the position. We are pleased to invite you to a virtual interview. Please find the details below.
|
||||
|
||||
Interview Details
|
||||
|
||||
Topic: Interview for Senior Software Developer
|
||||
|
||||
|
||||
Date & Time: November 15, 2025 at 2:00 PM
|
||||
|
||||
|
||||
Duration: 60 minutes
|
||||
|
||||
|
||||
|
||||
|
||||
Join Interview Now
|
||||
|
||||
Please click the button above to join the meeting at the scheduled time.
|
||||
|
||||
|
||||
Your Information
|
||||
|
||||
Name: John Doe
|
||||
|
||||
|
||||
Email: john.doe@example.com
|
||||
|
||||
|
||||
|
||||
Phone: +966 50 123 4567
|
||||
|
||||
|
||||
|
||||
|
||||
Position Details
|
||||
|
||||
Position: Senior Software Developer
|
||||
|
||||
|
||||
|
||||
Department: Information Technology
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
We look forward to speaking with you.
|
||||
If you have any questions, please reply to this email.
|
||||
Best regards,The Norah University Team
|
||||
|
||||
|
||||
|
||||
|
||||
239
test_simple_email.py
Normal file
239
test_simple_email.py
Normal file
@ -0,0 +1,239 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Simple test script for basic email functionality without attachments
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import django
|
||||
from django.conf import settings
|
||||
|
||||
# Configure Django settings BEFORE importing any Django modules
|
||||
if not settings.configured:
|
||||
settings.configure(
|
||||
DEBUG=True,
|
||||
DATABASES={
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': ':memory:',
|
||||
}
|
||||
},
|
||||
USE_TZ=True,
|
||||
SECRET_KEY='test-secret-key',
|
||||
INSTALLED_APPS=[
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.sessions',
|
||||
'recruitment',
|
||||
],
|
||||
EMAIL_BACKEND='django.core.mail.backends.console.EmailBackend',
|
||||
)
|
||||
|
||||
# Setup Django
|
||||
django.setup()
|
||||
|
||||
# Now import Django modules
|
||||
from django.test import TestCase, Client
|
||||
from django.test import RequestFactory
|
||||
from django.contrib.auth.models import User
|
||||
from recruitment.email_service import send_bulk_email
|
||||
from recruitment.forms import CandidateEmailForm
|
||||
from recruitment.models import JobPosting, Candidate, Participants
|
||||
|
||||
def setup_test_data():
|
||||
"""Create test data for email testing"""
|
||||
# Create test user (get or create to avoid duplicates)
|
||||
user, created = User.objects.get_or_create(
|
||||
username='testuser',
|
||||
defaults={
|
||||
'email': 'test@example.com',
|
||||
'first_name': 'Test',
|
||||
'last_name': 'User'
|
||||
}
|
||||
)
|
||||
|
||||
# Create test job
|
||||
from datetime import datetime, timedelta
|
||||
job = JobPosting.objects.create(
|
||||
title='Test Job Position',
|
||||
description='This is a test job for email testing.',
|
||||
status='ACTIVE',
|
||||
internal_job_id='TEST-001',
|
||||
application_deadline=datetime.now() + timedelta(days=30)
|
||||
)
|
||||
|
||||
# Create test candidate
|
||||
candidate = Candidate.objects.create(
|
||||
first_name='John',
|
||||
last_name='Doe',
|
||||
email='john.doe@example.com',
|
||||
phone='+1234567890',
|
||||
address='123 Test Street',
|
||||
job=job,
|
||||
stage='Interview'
|
||||
)
|
||||
|
||||
# Create test participants
|
||||
participant1 = Participants.objects.create(
|
||||
name='Alice Smith',
|
||||
email='alice@example.com',
|
||||
phone='+1234567891',
|
||||
designation='Interviewer'
|
||||
)
|
||||
|
||||
participant2 = Participants.objects.create(
|
||||
name='Bob Johnson',
|
||||
email='bob@example.com',
|
||||
phone='+1234567892',
|
||||
designation='Hiring Manager'
|
||||
)
|
||||
|
||||
# Add participants to job
|
||||
job.participants.add(participant1, participant2)
|
||||
|
||||
return user, job, candidate, [participant1, participant2]
|
||||
|
||||
def test_email_service_basic():
|
||||
"""Test the email service with basic functionality"""
|
||||
print("Testing basic email service...")
|
||||
|
||||
try:
|
||||
# Test email service without attachments
|
||||
result = send_bulk_email(
|
||||
subject='Test Basic Email',
|
||||
message='This is a test email without attachments.',
|
||||
recipient_list=['recipient1@example.com', 'recipient2@example.com']
|
||||
)
|
||||
|
||||
print(f"Email service result: {result}")
|
||||
print("✓ Basic email service test passed")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Basic email service test failed: {e}")
|
||||
return False
|
||||
|
||||
def test_candidate_email_form_basic():
|
||||
"""Test the CandidateEmailForm without attachments"""
|
||||
print("\nTesting CandidateEmailForm without attachments...")
|
||||
|
||||
user, job, candidate, participants = setup_test_data()
|
||||
|
||||
form_data = {
|
||||
'subject': 'Test Subject',
|
||||
'message': 'Test body content',
|
||||
'recipients': [f'participant_{p.id}' for p in participants],
|
||||
'include_candidate_info': True,
|
||||
'include_meeting_details': True,
|
||||
}
|
||||
|
||||
try:
|
||||
form = CandidateEmailForm(data=form_data, job=job, candidate=candidate)
|
||||
|
||||
if form.is_valid():
|
||||
print("✓ Form validation passed")
|
||||
print(f"Form cleaned data keys: {list(form.cleaned_data.keys())}")
|
||||
|
||||
# Test getting email addresses
|
||||
email_addresses = form.get_email_addresses()
|
||||
print(f"Email addresses: {email_addresses}")
|
||||
|
||||
# Test getting formatted message
|
||||
formatted_message = form.get_formatted_message()
|
||||
print(f"Formatted message length: {len(formatted_message)} characters")
|
||||
|
||||
return True
|
||||
else:
|
||||
print(f"✗ Form validation failed: {form.errors}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Form test failed: {e}")
|
||||
return False
|
||||
|
||||
def test_email_sending_workflow():
|
||||
"""Test the complete email sending workflow"""
|
||||
print("\nTesting complete email sending workflow...")
|
||||
|
||||
user, job, candidate, participants = setup_test_data()
|
||||
|
||||
form_data = {
|
||||
'subject': 'Interview Update: John Doe - Test Job Position',
|
||||
'message': 'Please find the interview update below.',
|
||||
'recipients': [f'participant_{p.id}' for p in participants],
|
||||
'include_candidate_info': True,
|
||||
'include_meeting_details': True,
|
||||
}
|
||||
|
||||
try:
|
||||
# Create and validate form
|
||||
form = CandidateEmailForm(data=form_data, job=job, candidate=candidate)
|
||||
|
||||
if not form.is_valid():
|
||||
print(f"✗ Form validation failed: {form.errors}")
|
||||
return False
|
||||
|
||||
# Get email data
|
||||
subject = form.cleaned_data['subject']
|
||||
message = form.get_formatted_message()
|
||||
recipient_emails = form.get_email_addresses()
|
||||
|
||||
print(f"Subject: {subject}")
|
||||
print(f"Recipients: {recipient_emails}")
|
||||
print(f"Message preview: {message[:200]}...")
|
||||
|
||||
# Send email using service
|
||||
result = send_bulk_email(
|
||||
subject=subject,
|
||||
message=message,
|
||||
recipient_list=recipient_emails
|
||||
)
|
||||
|
||||
print(f"Email sending result: {result}")
|
||||
print("✓ Complete email workflow test passed")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Email workflow test failed: {e}")
|
||||
return False
|
||||
|
||||
def main():
|
||||
"""Run all simple email tests"""
|
||||
print("=" * 60)
|
||||
print("SIMPLE EMAIL FUNCTIONALITY TESTS")
|
||||
print("=" * 60)
|
||||
|
||||
# Initialize Django
|
||||
django.setup()
|
||||
|
||||
# Create tables
|
||||
from django.core.management import execute_from_command_line
|
||||
execute_from_command_line(['manage.py', 'migrate', '--run-syncdb'])
|
||||
|
||||
results = []
|
||||
|
||||
# Run tests
|
||||
results.append(test_email_service_basic())
|
||||
results.append(test_candidate_email_form_basic())
|
||||
results.append(test_email_sending_workflow())
|
||||
|
||||
# Summary
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST SUMMARY")
|
||||
print("=" * 60)
|
||||
|
||||
passed = sum(results)
|
||||
total = len(results)
|
||||
|
||||
print(f"Tests passed: {passed}/{total}")
|
||||
|
||||
if passed == total:
|
||||
print("🎉 All simple email tests passed!")
|
||||
return True
|
||||
else:
|
||||
print("❌ Some simple email tests failed!")
|
||||
return False
|
||||
|
||||
if __name__ == '__main__':
|
||||
success = main()
|
||||
exit(0 if success else 1)
|
||||
Loading…
x
Reference in New Issue
Block a user