Compare commits
7 Commits
8da8d89433
...
f4bddfc391
| Author | SHA1 | Date | |
|---|---|---|---|
| f4bddfc391 | |||
| 2cfab7c3ef | |||
| c6fcb27613 | |||
| 08774489bc | |||
| 3ff3c25734 | |||
| 2e62700146 | |||
| b37af920ba |
Binary file not shown.
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.
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
|
||||
|
||||
@ -679,11 +679,9 @@ class Candidate(Base):
|
||||
|
||||
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
|
||||
|
||||
@property
|
||||
def scoring_timeout(self):
|
||||
return timezone.now() <= (self.created_at + timezone.timedelta(minutes=5))
|
||||
|
||||
|
||||
class TrainingMaterial(Base):
|
||||
@ -751,43 +749,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 +1603,8 @@ class InterviewSchedule(Base):
|
||||
models.Index(fields=['end_date']),
|
||||
models.Index(fields=['created_by']),
|
||||
]
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class ScheduledInterview(Base):
|
||||
"""Stores individual scheduled interviews"""
|
||||
@ -1641,8 +1615,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 +1740,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}
|
||||
|
||||
@ -34,6 +34,7 @@ urlpatterns = [
|
||||
path('candidate/<slug:slug>/view/', views_frontend.candidate_detail, name='candidate_detail'),
|
||||
path('candidate/<slug:slug>/resume-template/', views_frontend.candidate_resume_template_view, name='candidate_resume_template'),
|
||||
path('candidate/<slug:slug>/update-stage/', views_frontend.candidate_update_stage, name='candidate_update_stage'),
|
||||
path('candidate/<slug:slug>/retry-scoring/', views_frontend.retry_scoring_view, name='candidate_retry_scoring'),
|
||||
|
||||
# Training URLs
|
||||
path('training/', views_frontend.TrainingListView.as_view(), name='training_list'),
|
||||
@ -65,7 +66,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'),
|
||||
@ -201,23 +202,23 @@ urlpatterns = [
|
||||
# API URLs for candidate management
|
||||
path('api/candidate/<int:candidate_id>/', views.api_candidate_detail, name='api_candidate_detail'),
|
||||
|
||||
# Admin Notification API
|
||||
path('api/admin/notification-count/', views.api_notification_count, name='admin_notification_count'),
|
||||
# # Admin Notification API
|
||||
# path('api/admin/notification-count/', views.api_notification_count, name='admin_notification_count'),
|
||||
|
||||
# Agency Notification API
|
||||
path('api/agency/notification-count/', views.api_notification_count, name='api_agency_notification_count'),
|
||||
# # Agency Notification API
|
||||
# path('api/agency/notification-count/', views.api_notification_count, name='api_agency_notification_count'),
|
||||
|
||||
# SSE Notification Stream
|
||||
path('api/notifications/stream/', views.notification_stream, name='notification_stream'),
|
||||
# # SSE Notification Stream
|
||||
# path('api/notifications/stream/', views.notification_stream, name='notification_stream'),
|
||||
|
||||
# Notification URLs
|
||||
path('notifications/', views.notification_list, name='notification_list'),
|
||||
path('notifications/<int:notification_id>/', views.notification_detail, name='notification_detail'),
|
||||
path('notifications/<int:notification_id>/mark-read/', views.notification_mark_read, name='notification_mark_read'),
|
||||
path('notifications/<int:notification_id>/mark-unread/', views.notification_mark_unread, name='notification_mark_unread'),
|
||||
path('notifications/<int:notification_id>/delete/', views.notification_delete, name='notification_delete'),
|
||||
path('notifications/mark-all-read/', views.notification_mark_all_read, name='notification_mark_all_read'),
|
||||
path('api/notification-count/', views.api_notification_count, name='api_notification_count'),
|
||||
# # Notification URLs
|
||||
# path('notifications/', views.notification_list, name='notification_list'),
|
||||
# path('notifications/<int:notification_id>/', views.notification_detail, name='notification_detail'),
|
||||
# path('notifications/<int:notification_id>/mark-read/', views.notification_mark_read, name='notification_mark_read'),
|
||||
# path('notifications/<int:notification_id>/mark-unread/', views.notification_mark_unread, name='notification_mark_unread'),
|
||||
# path('notifications/<int:notification_id>/delete/', views.notification_delete, name='notification_delete'),
|
||||
# path('notifications/mark-all-read/', views.notification_mark_all_read, name='notification_mark_all_read'),
|
||||
# path('api/notification-count/', views.api_notification_count, name='api_notification_count'),
|
||||
|
||||
|
||||
#participants urls
|
||||
@ -225,5 +226,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)
|
||||
|
||||
@ -2582,314 +2583,314 @@ def agency_delete(request, slug):
|
||||
|
||||
|
||||
# Notification Views
|
||||
@login_required
|
||||
def notification_list(request):
|
||||
"""List all notifications for the current user"""
|
||||
# Get filter parameters
|
||||
status_filter = request.GET.get('status', '')
|
||||
type_filter = request.GET.get('type', '')
|
||||
# @login_required
|
||||
# def notification_list(request):
|
||||
# """List all notifications for the current user"""
|
||||
# # Get filter parameters
|
||||
# status_filter = request.GET.get('status', '')
|
||||
# type_filter = request.GET.get('type', '')
|
||||
|
||||
# Base queryset
|
||||
notifications = Notification.objects.filter(recipient=request.user).order_by('-created_at')
|
||||
# # Base queryset
|
||||
# notifications = Notification.objects.filter(recipient=request.user).order_by('-created_at')
|
||||
|
||||
# Apply filters
|
||||
if status_filter:
|
||||
if status_filter == 'unread':
|
||||
notifications = notifications.filter(status=Notification.Status.PENDING)
|
||||
elif status_filter == 'read':
|
||||
notifications = notifications.filter(status=Notification.Status.READ)
|
||||
elif status_filter == 'sent':
|
||||
notifications = notifications.filter(status=Notification.Status.SENT)
|
||||
# # Apply filters
|
||||
# if status_filter:
|
||||
# if status_filter == 'unread':
|
||||
# notifications = notifications.filter(status=Notification.Status.PENDING)
|
||||
# elif status_filter == 'read':
|
||||
# notifications = notifications.filter(status=Notification.Status.READ)
|
||||
# elif status_filter == 'sent':
|
||||
# notifications = notifications.filter(status=Notification.Status.SENT)
|
||||
|
||||
if type_filter:
|
||||
if type_filter == 'in_app':
|
||||
notifications = notifications.filter(notification_type=Notification.NotificationType.IN_APP)
|
||||
elif type_filter == 'email':
|
||||
notifications = notifications.filter(notification_type=Notification.NotificationType.EMAIL)
|
||||
# if type_filter:
|
||||
# if type_filter == 'in_app':
|
||||
# notifications = notifications.filter(notification_type=Notification.NotificationType.IN_APP)
|
||||
# elif type_filter == 'email':
|
||||
# notifications = notifications.filter(notification_type=Notification.NotificationType.EMAIL)
|
||||
|
||||
# Pagination
|
||||
paginator = Paginator(notifications, 20) # Show 20 notifications per page
|
||||
page_number = request.GET.get('page')
|
||||
page_obj = paginator.get_page(page_number)
|
||||
# # Pagination
|
||||
# paginator = Paginator(notifications, 20) # Show 20 notifications per page
|
||||
# page_number = request.GET.get('page')
|
||||
# page_obj = paginator.get_page(page_number)
|
||||
|
||||
# Statistics
|
||||
total_notifications = notifications.count()
|
||||
unread_notifications = notifications.filter(status=Notification.Status.PENDING).count()
|
||||
email_notifications = notifications.filter(notification_type=Notification.NotificationType.EMAIL).count()
|
||||
# # Statistics
|
||||
# total_notifications = notifications.count()
|
||||
# unread_notifications = notifications.filter(status=Notification.Status.PENDING).count()
|
||||
# email_notifications = notifications.filter(notification_type=Notification.NotificationType.EMAIL).count()
|
||||
|
||||
context = {
|
||||
'page_obj': page_obj,
|
||||
'total_notifications': total_notifications,
|
||||
'unread_notifications': unread_notifications,
|
||||
'email_notifications': email_notifications,
|
||||
'status_filter': status_filter,
|
||||
'type_filter': type_filter,
|
||||
}
|
||||
return render(request, 'recruitment/notification_list.html', context)
|
||||
# context = {
|
||||
# 'page_obj': page_obj,
|
||||
# 'total_notifications': total_notifications,
|
||||
# 'unread_notifications': unread_notifications,
|
||||
# 'email_notifications': email_notifications,
|
||||
# 'status_filter': status_filter,
|
||||
# 'type_filter': type_filter,
|
||||
# }
|
||||
# return render(request, 'recruitment/notification_list.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
def notification_detail(request, notification_id):
|
||||
"""View details of a specific notification"""
|
||||
notification = get_object_or_404(Notification, id=notification_id, recipient=request.user)
|
||||
# @login_required
|
||||
# def notification_detail(request, notification_id):
|
||||
# """View details of a specific notification"""
|
||||
# notification = get_object_or_404(Notification, id=notification_id, recipient=request.user)
|
||||
|
||||
# Mark as read if it was pending
|
||||
if notification.status == Notification.Status.PENDING:
|
||||
notification.status = Notification.Status.READ
|
||||
notification.save(update_fields=['status'])
|
||||
# # Mark as read if it was pending
|
||||
# if notification.status == Notification.Status.PENDING:
|
||||
# notification.status = Notification.Status.READ
|
||||
# notification.save(update_fields=['status'])
|
||||
|
||||
context = {
|
||||
'notification': notification,
|
||||
}
|
||||
return render(request, 'recruitment/notification_detail.html', context)
|
||||
# context = {
|
||||
# 'notification': notification,
|
||||
# }
|
||||
# return render(request, 'recruitment/notification_detail.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
def notification_mark_read(request, notification_id):
|
||||
"""Mark a notification as read"""
|
||||
notification = get_object_or_404(Notification, id=notification_id, recipient=request.user)
|
||||
# @login_required
|
||||
# def notification_mark_read(request, notification_id):
|
||||
# """Mark a notification as read"""
|
||||
# notification = get_object_or_404(Notification, id=notification_id, recipient=request.user)
|
||||
|
||||
if notification.status == Notification.Status.PENDING:
|
||||
notification.status = Notification.Status.READ
|
||||
notification.save(update_fields=['status'])
|
||||
# if notification.status == Notification.Status.PENDING:
|
||||
# notification.status = Notification.Status.READ
|
||||
# notification.save(update_fields=['status'])
|
||||
|
||||
if 'HX-Request' in request.headers:
|
||||
return HttpResponse(status=200) # HTMX success response
|
||||
# if 'HX-Request' in request.headers:
|
||||
# return HttpResponse(status=200) # HTMX success response
|
||||
|
||||
return redirect('notification_list')
|
||||
# return redirect('notification_list')
|
||||
|
||||
|
||||
@login_required
|
||||
def notification_mark_unread(request, notification_id):
|
||||
"""Mark a notification as unread"""
|
||||
notification = get_object_or_404(Notification, id=notification_id, recipient=request.user)
|
||||
# @login_required
|
||||
# def notification_mark_unread(request, notification_id):
|
||||
# """Mark a notification as unread"""
|
||||
# notification = get_object_or_404(Notification, id=notification_id, recipient=request.user)
|
||||
|
||||
if notification.status == Notification.Status.READ:
|
||||
notification.status = Notification.Status.PENDING
|
||||
notification.save(update_fields=['status'])
|
||||
# if notification.status == Notification.Status.READ:
|
||||
# notification.status = Notification.Status.PENDING
|
||||
# notification.save(update_fields=['status'])
|
||||
|
||||
if 'HX-Request' in request.headers:
|
||||
return HttpResponse(status=200) # HTMX success response
|
||||
# if 'HX-Request' in request.headers:
|
||||
# return HttpResponse(status=200) # HTMX success response
|
||||
|
||||
return redirect('notification_list')
|
||||
# return redirect('notification_list')
|
||||
|
||||
|
||||
@login_required
|
||||
def notification_delete(request, notification_id):
|
||||
"""Delete a notification"""
|
||||
notification = get_object_or_404(Notification, id=notification_id, recipient=request.user)
|
||||
# @login_required
|
||||
# def notification_delete(request, notification_id):
|
||||
# """Delete a notification"""
|
||||
# notification = get_object_or_404(Notification, id=notification_id, recipient=request.user)
|
||||
|
||||
if request.method == 'POST':
|
||||
notification.delete()
|
||||
messages.success(request, 'Notification deleted successfully!')
|
||||
return redirect('notification_list')
|
||||
# if request.method == 'POST':
|
||||
# notification.delete()
|
||||
# messages.success(request, 'Notification deleted successfully!')
|
||||
# return redirect('notification_list')
|
||||
|
||||
# For GET requests, show confirmation page
|
||||
context = {
|
||||
'notification': notification,
|
||||
'title': 'Delete Notification',
|
||||
'message': f'Are you sure you want to delete this notification?',
|
||||
'cancel_url': reverse('notification_detail', kwargs={'notification_id': notification.id}),
|
||||
}
|
||||
return render(request, 'recruitment/notification_confirm_delete.html', context)
|
||||
# # For GET requests, show confirmation page
|
||||
# context = {
|
||||
# 'notification': notification,
|
||||
# 'title': 'Delete Notification',
|
||||
# 'message': f'Are you sure you want to delete this notification?',
|
||||
# 'cancel_url': reverse('notification_detail', kwargs={'notification_id': notification.id}),
|
||||
# }
|
||||
# return render(request, 'recruitment/notification_confirm_delete.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
def notification_mark_all_read(request):
|
||||
"""Mark all notifications as read for the current user"""
|
||||
if request.method == 'POST':
|
||||
Notification.objects.filter(
|
||||
recipient=request.user,
|
||||
status=Notification.Status.PENDING
|
||||
).update(status=Notification.Status.READ)
|
||||
# @login_required
|
||||
# def notification_mark_all_read(request):
|
||||
# """Mark all notifications as read for the current user"""
|
||||
# if request.method == 'POST':
|
||||
# Notification.objects.filter(
|
||||
# recipient=request.user,
|
||||
# status=Notification.Status.PENDING
|
||||
# ).update(status=Notification.Status.READ)
|
||||
|
||||
messages.success(request, 'All notifications marked as read!')
|
||||
return redirect('notification_list')
|
||||
# messages.success(request, 'All notifications marked as read!')
|
||||
# return redirect('notification_list')
|
||||
|
||||
# For GET requests, show confirmation page
|
||||
unread_count = Notification.objects.filter(
|
||||
recipient=request.user,
|
||||
status=Notification.Status.PENDING
|
||||
).count()
|
||||
# # For GET requests, show confirmation page
|
||||
# unread_count = Notification.objects.filter(
|
||||
# recipient=request.user,
|
||||
# status=Notification.Status.PENDING
|
||||
# ).count()
|
||||
|
||||
context = {
|
||||
'unread_count': unread_count,
|
||||
'title': 'Mark All as Read',
|
||||
'message': f'Are you sure you want to mark all {unread_count} notifications as read?',
|
||||
'cancel_url': reverse('notification_list'),
|
||||
}
|
||||
return render(request, 'recruitment/notification_confirm_all_read.html', context)
|
||||
# context = {
|
||||
# 'unread_count': unread_count,
|
||||
# 'title': 'Mark All as Read',
|
||||
# 'message': f'Are you sure you want to mark all {unread_count} notifications as read?',
|
||||
# 'cancel_url': reverse('notification_list'),
|
||||
# }
|
||||
# return render(request, 'recruitment/notification_confirm_all_read.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
def api_notification_count(request):
|
||||
"""API endpoint to get unread notification count and recent notifications"""
|
||||
# Get unread notifications
|
||||
unread_notifications = Notification.objects.filter(
|
||||
recipient=request.user,
|
||||
status=Notification.Status.PENDING
|
||||
).order_by('-created_at')
|
||||
# @login_required
|
||||
# def api_notification_count(request):
|
||||
# """API endpoint to get unread notification count and recent notifications"""
|
||||
# # Get unread notifications
|
||||
# unread_notifications = Notification.objects.filter(
|
||||
# recipient=request.user,
|
||||
# status=Notification.Status.PENDING
|
||||
# ).order_by('-created_at')
|
||||
|
||||
# Get recent notifications (last 5)
|
||||
recent_notifications = Notification.objects.filter(
|
||||
recipient=request.user
|
||||
).order_by('-created_at')[:5]
|
||||
# # Get recent notifications (last 5)
|
||||
# recent_notifications = Notification.objects.filter(
|
||||
# recipient=request.user
|
||||
# ).order_by('-created_at')[:5]
|
||||
|
||||
# Prepare recent notifications data
|
||||
recent_data = []
|
||||
for notification in recent_notifications:
|
||||
time_ago = ''
|
||||
if notification.created_at:
|
||||
from datetime import datetime, timezone
|
||||
now = timezone.now()
|
||||
diff = now - notification.created_at
|
||||
# # Prepare recent notifications data
|
||||
# recent_data = []
|
||||
# for notification in recent_notifications:
|
||||
# time_ago = ''
|
||||
# if notification.created_at:
|
||||
# from datetime import datetime, timezone
|
||||
# now = timezone.now()
|
||||
# diff = now - notification.created_at
|
||||
|
||||
if diff.days > 0:
|
||||
time_ago = f'{diff.days}d ago'
|
||||
elif diff.seconds > 3600:
|
||||
hours = diff.seconds // 3600
|
||||
time_ago = f'{hours}h ago'
|
||||
elif diff.seconds > 60:
|
||||
minutes = diff.seconds // 60
|
||||
time_ago = f'{minutes}m ago'
|
||||
else:
|
||||
time_ago = 'Just now'
|
||||
# if diff.days > 0:
|
||||
# time_ago = f'{diff.days}d ago'
|
||||
# elif diff.seconds > 3600:
|
||||
# hours = diff.seconds // 3600
|
||||
# time_ago = f'{hours}h ago'
|
||||
# elif diff.seconds > 60:
|
||||
# minutes = diff.seconds // 60
|
||||
# time_ago = f'{minutes}m ago'
|
||||
# else:
|
||||
# time_ago = 'Just now'
|
||||
|
||||
recent_data.append({
|
||||
'id': notification.id,
|
||||
'message': notification.message[:100] + ('...' if len(notification.message) > 100 else ''),
|
||||
'type': notification.get_notification_type_display(),
|
||||
'status': notification.get_status_display(),
|
||||
'time_ago': time_ago,
|
||||
'url': reverse('notification_detail', kwargs={'notification_id': notification.id})
|
||||
})
|
||||
# recent_data.append({
|
||||
# 'id': notification.id,
|
||||
# 'message': notification.message[:100] + ('...' if len(notification.message) > 100 else ''),
|
||||
# 'type': notification.get_notification_type_display(),
|
||||
# 'status': notification.get_status_display(),
|
||||
# 'time_ago': time_ago,
|
||||
# 'url': reverse('notification_detail', kwargs={'notification_id': notification.id})
|
||||
# })
|
||||
|
||||
return JsonResponse({
|
||||
'count': unread_notifications.count(),
|
||||
'recent_notifications': recent_data
|
||||
})
|
||||
# return JsonResponse({
|
||||
# 'count': unread_notifications.count(),
|
||||
# 'recent_notifications': recent_data
|
||||
# })
|
||||
|
||||
|
||||
@login_required
|
||||
def notification_stream(request):
|
||||
"""SSE endpoint for real-time notifications"""
|
||||
from django.http import StreamingHttpResponse
|
||||
import json
|
||||
import time
|
||||
from .signals import SSE_NOTIFICATION_CACHE
|
||||
# @login_required
|
||||
# def notification_stream(request):
|
||||
# """SSE endpoint for real-time notifications"""
|
||||
# from django.http import StreamingHttpResponse
|
||||
# import json
|
||||
# import time
|
||||
# from .signals import SSE_NOTIFICATION_CACHE
|
||||
|
||||
def event_stream():
|
||||
"""Generator function for SSE events"""
|
||||
user_id = request.user.id
|
||||
last_notification_id = 0
|
||||
# def event_stream():
|
||||
# """Generator function for SSE events"""
|
||||
# user_id = request.user.id
|
||||
# last_notification_id = 0
|
||||
|
||||
# Get initial last notification ID
|
||||
last_notification = Notification.objects.filter(
|
||||
recipient=request.user
|
||||
).order_by('-id').first()
|
||||
if last_notification:
|
||||
last_notification_id = last_notification.id
|
||||
# # Get initial last notification ID
|
||||
# last_notification = Notification.objects.filter(
|
||||
# recipient=request.user
|
||||
# ).order_by('-id').first()
|
||||
# if last_notification:
|
||||
# last_notification_id = last_notification.id
|
||||
|
||||
# Send any cached notifications first
|
||||
cached_notifications = SSE_NOTIFICATION_CACHE.get(user_id, [])
|
||||
for cached_notification in cached_notifications:
|
||||
if cached_notification['id'] > last_notification_id:
|
||||
yield f"event: new_notification\n"
|
||||
yield f"data: {json.dumps(cached_notification)}\n\n"
|
||||
last_notification_id = cached_notification['id']
|
||||
# # Send any cached notifications first
|
||||
# cached_notifications = SSE_NOTIFICATION_CACHE.get(user_id, [])
|
||||
# for cached_notification in cached_notifications:
|
||||
# if cached_notification['id'] > last_notification_id:
|
||||
# yield f"event: new_notification\n"
|
||||
# yield f"data: {json.dumps(cached_notification)}\n\n"
|
||||
# last_notification_id = cached_notification['id']
|
||||
|
||||
while True:
|
||||
try:
|
||||
# Check for new notifications from cache first
|
||||
cached_notifications = SSE_NOTIFICATION_CACHE.get(user_id, [])
|
||||
new_cached = [n for n in cached_notifications if n['id'] > last_notification_id]
|
||||
# while True:
|
||||
# try:
|
||||
# # Check for new notifications from cache first
|
||||
# cached_notifications = SSE_NOTIFICATION_CACHE.get(user_id, [])
|
||||
# new_cached = [n for n in cached_notifications if n['id'] > last_notification_id]
|
||||
|
||||
for notification_data in new_cached:
|
||||
yield f"event: new_notification\n"
|
||||
yield f"data: {json.dumps(notification_data)}\n\n"
|
||||
last_notification_id = notification_data['id']
|
||||
# for notification_data in new_cached:
|
||||
# yield f"event: new_notification\n"
|
||||
# yield f"data: {json.dumps(notification_data)}\n\n"
|
||||
# last_notification_id = notification_data['id']
|
||||
|
||||
# Also check database for any missed notifications
|
||||
new_notifications = Notification.objects.filter(
|
||||
recipient=request.user,
|
||||
id__gt=last_notification_id
|
||||
).order_by('id')
|
||||
# # Also check database for any missed notifications
|
||||
# new_notifications = Notification.objects.filter(
|
||||
# recipient=request.user,
|
||||
# id__gt=last_notification_id
|
||||
# ).order_by('id')
|
||||
|
||||
if new_notifications.exists():
|
||||
for notification in new_notifications:
|
||||
# Prepare notification data
|
||||
time_ago = ''
|
||||
if notification.created_at:
|
||||
now = timezone.now()
|
||||
diff = now - notification.created_at
|
||||
# if new_notifications.exists():
|
||||
# for notification in new_notifications:
|
||||
# # Prepare notification data
|
||||
# time_ago = ''
|
||||
# if notification.created_at:
|
||||
# now = timezone.now()
|
||||
# diff = now - notification.created_at
|
||||
|
||||
if diff.days > 0:
|
||||
time_ago = f'{diff.days}d ago'
|
||||
elif diff.seconds > 3600:
|
||||
hours = diff.seconds // 3600
|
||||
time_ago = f'{hours}h ago'
|
||||
elif diff.seconds > 60:
|
||||
minutes = diff.seconds // 60
|
||||
time_ago = f'{minutes}m ago'
|
||||
else:
|
||||
time_ago = 'Just now'
|
||||
# if diff.days > 0:
|
||||
# time_ago = f'{diff.days}d ago'
|
||||
# elif diff.seconds > 3600:
|
||||
# hours = diff.seconds // 3600
|
||||
# time_ago = f'{hours}h ago'
|
||||
# elif diff.seconds > 60:
|
||||
# minutes = diff.seconds // 60
|
||||
# time_ago = f'{minutes}m ago'
|
||||
# else:
|
||||
# time_ago = 'Just now'
|
||||
|
||||
notification_data = {
|
||||
'id': notification.id,
|
||||
'message': notification.message[:100] + ('...' if len(notification.message) > 100 else ''),
|
||||
'type': notification.get_notification_type_display(),
|
||||
'status': notification.get_status_display(),
|
||||
'time_ago': time_ago,
|
||||
'url': reverse('notification_detail', kwargs={'notification_id': notification.id})
|
||||
}
|
||||
# notification_data = {
|
||||
# 'id': notification.id,
|
||||
# 'message': notification.message[:100] + ('...' if len(notification.message) > 100 else ''),
|
||||
# 'type': notification.get_notification_type_display(),
|
||||
# 'status': notification.get_status_display(),
|
||||
# 'time_ago': time_ago,
|
||||
# 'url': reverse('notification_detail', kwargs={'notification_id': notification.id})
|
||||
# }
|
||||
|
||||
# Send SSE event
|
||||
yield f"event: new_notification\n"
|
||||
yield f"data: {json.dumps(notification_data)}\n\n"
|
||||
# # Send SSE event
|
||||
# yield f"event: new_notification\n"
|
||||
# yield f"data: {json.dumps(notification_data)}\n\n"
|
||||
|
||||
last_notification_id = notification.id
|
||||
# last_notification_id = notification.id
|
||||
|
||||
# Update count after sending new notifications
|
||||
unread_count = Notification.objects.filter(
|
||||
recipient=request.user,
|
||||
status=Notification.Status.PENDING
|
||||
).count()
|
||||
# # Update count after sending new notifications
|
||||
# unread_count = Notification.objects.filter(
|
||||
# recipient=request.user,
|
||||
# status=Notification.Status.PENDING
|
||||
# ).count()
|
||||
|
||||
count_data = {'count': unread_count}
|
||||
yield f"event: count_update\n"
|
||||
yield f"data: {json.dumps(count_data)}\n\n"
|
||||
# count_data = {'count': unread_count}
|
||||
# yield f"event: count_update\n"
|
||||
# yield f"data: {json.dumps(count_data)}\n\n"
|
||||
|
||||
# Send heartbeat every 30 seconds
|
||||
yield f"event: heartbeat\n"
|
||||
yield f"data: {json.dumps({'timestamp': int(time.time())})}\n\n"
|
||||
# # Send heartbeat every 30 seconds
|
||||
# yield f"event: heartbeat\n"
|
||||
# yield f"data: {json.dumps({'timestamp': int(time.time())})}\n\n"
|
||||
|
||||
# Wait before next check
|
||||
time.sleep(5) # Check every 5 seconds
|
||||
# # Wait before next check
|
||||
# time.sleep(5) # Check every 5 seconds
|
||||
|
||||
except Exception as e:
|
||||
# Send error event and continue
|
||||
error_data = {'error': str(e)}
|
||||
yield f"event: error\n"
|
||||
yield f"data: {json.dumps(error_data)}\n\n"
|
||||
time.sleep(10) # Wait longer on error
|
||||
# except Exception as e:
|
||||
# # Send error event and continue
|
||||
# error_data = {'error': str(e)}
|
||||
# yield f"event: error\n"
|
||||
# yield f"data: {json.dumps(error_data)}\n\n"
|
||||
# time.sleep(10) # Wait longer on error
|
||||
|
||||
response = StreamingHttpResponse(
|
||||
event_stream(),
|
||||
content_type='text/event-stream'
|
||||
)
|
||||
# response = StreamingHttpResponse(
|
||||
# event_stream(),
|
||||
# content_type='text/event-stream'
|
||||
# )
|
||||
|
||||
# Set SSE headers
|
||||
response['Cache-Control'] = 'no-cache'
|
||||
response['X-Accel-Buffering'] = 'no' # Disable buffering for nginx
|
||||
response['Connection'] = 'keep-alive'
|
||||
# # Set SSE headers
|
||||
# response['Cache-Control'] = 'no-cache'
|
||||
# response['X-Accel-Buffering'] = 'no' # Disable buffering for nginx
|
||||
# response['Connection'] = 'keep-alive'
|
||||
|
||||
context = {
|
||||
'agency': agency,
|
||||
'page_obj': page_obj,
|
||||
'stage_filter': stage_filter,
|
||||
'total_candidates': candidates.count(),
|
||||
}
|
||||
return render(request, 'recruitment/agency_candidates.html', context)
|
||||
# context = {
|
||||
# 'agency': agency,
|
||||
# 'page_obj': page_obj,
|
||||
# 'stage_filter': stage_filter,
|
||||
# 'total_candidates': candidates.count(),
|
||||
# }
|
||||
# return render(request, 'recruitment/agency_candidates.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
@ -2976,7 +2977,7 @@ def agency_assignment_create(request,slug=None):
|
||||
try:
|
||||
from django.forms import HiddenInput
|
||||
form.initial['agency'] = agency
|
||||
form.fields['agency'].widget = HiddenInput()
|
||||
# form.fields['agency'].widget = HiddenInput()
|
||||
except HiringAgency.DoesNotExist:
|
||||
pass
|
||||
|
||||
@ -3670,3 +3671,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
|
||||
})
|
||||
|
||||
@ -21,8 +21,15 @@ from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.views.generic import ListView, CreateView, UpdateView, DeleteView, DetailView
|
||||
# JobForm removed - using JobPostingForm instead
|
||||
from django.urls import reverse_lazy
|
||||
from django.db.models import Q, Count, Avg
|
||||
from django.db.models import FloatField
|
||||
from django.db.models import F, IntegerField, Count, Avg, Sum, Q, ExpressionWrapper, fields
|
||||
from django.db.models.functions import Cast, Coalesce, TruncDate
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.shortcuts import render
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
import json
|
||||
|
||||
|
||||
from datastar_py.django import (
|
||||
DatastarResponse,
|
||||
@ -215,12 +222,18 @@ class CandidateDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView):
|
||||
slug_url_kwarg = 'slug'
|
||||
|
||||
|
||||
# def job_detail(request, slug):
|
||||
# job = get_object_or_404(models.JobPosting, slug=slug, status='Published')
|
||||
# form = forms.CandidateForm()
|
||||
|
||||
# return render(request, 'jobs/job_detail.html', {'job': job, 'form': form})
|
||||
def retry_scoring_view(request,slug):
|
||||
from django_q.tasks import async_task
|
||||
|
||||
candidate = get_object_or_404(models.Candidate, slug=slug)
|
||||
|
||||
async_task(
|
||||
'recruitment.tasks.handle_reume_parsing_and_scoring',
|
||||
candidate.pk,
|
||||
hook='recruitment.hooks.callback_ai_parsing',
|
||||
sync=True,
|
||||
)
|
||||
return redirect('candidate_detail', slug=candidate.slug)
|
||||
|
||||
|
||||
|
||||
@ -339,13 +352,6 @@ class TrainingDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView):
|
||||
success_url = reverse_lazy('training_list')
|
||||
success_message = 'Training material deleted successfully.'
|
||||
|
||||
from django.db.models import F, IntegerField, Count, Avg, Sum, Q, ExpressionWrapper, fields
|
||||
from django.db.models.functions import Cast, Coalesce, TruncDate
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.shortcuts import render
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
import json
|
||||
|
||||
# IMPORTANT: Ensure 'models' correctly refers to your Django models file
|
||||
# Example: from . import models
|
||||
@ -494,11 +500,12 @@ def dashboard_view(request):
|
||||
# A. Pipeline Funnel (Scoped)
|
||||
stage_counts = candidate_queryset.values('stage').annotate(count=Count('stage'))
|
||||
stage_map = {item['stage']: item['count'] for item in stage_counts}
|
||||
candidate_stage = ['Applied', 'Exam', 'Interview', 'Offer', 'HIRED']
|
||||
candidate_stage = ['Applied', 'Exam', 'Interview', 'Offer', 'Hired']
|
||||
candidates_count = [
|
||||
stage_map.get('Applied', 0), stage_map.get('Exam', 0), stage_map.get('Interview', 0),
|
||||
stage_map.get('Offer', 0), filled_positions
|
||||
stage_map.get('Offer', 0), stage_map.get('Hired',0)
|
||||
]
|
||||
|
||||
|
||||
# --- 7. GAUGE CHART CALCULATION (Time-to-Hire) ---
|
||||
|
||||
@ -507,6 +514,15 @@ def dashboard_view(request):
|
||||
rotation_degrees = rotation_percent * 180
|
||||
rotation_degrees_final = round(min(rotation_degrees, 180), 1) # Ensure max 180 degrees
|
||||
|
||||
#
|
||||
hiring_source_counts = candidate_queryset.values('hiring_source').annotate(count=Count('stage'))
|
||||
source_map= {item['hiring_source']: item['count'] for item in hiring_source_counts}
|
||||
candidates_count_in_each_source = [
|
||||
source_map.get('Public', 0), source_map.get('Internal', 0), source_map.get('Agency', 0),
|
||||
|
||||
]
|
||||
all_hiring_sources=["Public", "Internal", "Agency"]
|
||||
|
||||
|
||||
# --- 8. CONTEXT RETURN ---
|
||||
|
||||
@ -555,6 +571,10 @@ def dashboard_view(request):
|
||||
'jobs': all_jobs_queryset,
|
||||
'current_job_id': selected_job_pk,
|
||||
'current_job': current_job,
|
||||
|
||||
|
||||
'candidates_count_in_each_source': json.dumps(candidates_count_in_each_source),
|
||||
'all_hiring_sources': json.dumps(all_hiring_sources),
|
||||
}
|
||||
|
||||
return render(request, 'recruitment/dashboard.html', context)
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
<meta name="description" content="{% trans 'King Abdullah Academic University Hospital - Agency Portal' %}">
|
||||
<title>{% block title %}{% trans 'KAAUH Agency Portal' %}{% endblock %}</title>
|
||||
|
||||
{% comment %} Load correct Bootstrap CSS file for RTL/LTR {% endcomment %}
|
||||
{# Load correct Bootstrap CSS file for RTL/LTR #}
|
||||
{% if LANGUAGE_CODE == 'ar' %}
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.rtl.min.css" rel="stylesheet">
|
||||
{% else %}
|
||||
@ -24,91 +24,94 @@
|
||||
</head>
|
||||
<body class="d-flex flex-column min-vh-100" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
|
||||
|
||||
{% comment %} <div class="top-bar d-none d-md-block">
|
||||
<div class="top-bar d-none d-md-block" style="background-color: #f8f9fa;">
|
||||
<div class="container-fluid">
|
||||
<div class="d-flex justify-content-between align-items-center gap-2 max-width-1600">
|
||||
<div class="logo-container d-flex gap-2">
|
||||
</div>
|
||||
<div class="clogo-container d-flex gap-2">
|
||||
</div>
|
||||
<div class="logo-container d-flex gap-2 align-items-center">
|
||||
<div class="d-flex justify-content-between align-items-center gap-2" style="max-width: 1600px; margin: 0 auto; padding: 0.5rem 0;">
|
||||
<div class="logo-group d-flex gap-3 align-items-center">
|
||||
<img src="{% static 'image/vision.svg' %}" alt="{% trans 'Saudi Vision 2030' %}" loading="lazy" style="height: 35px; object-fit: contain;">
|
||||
|
||||
<div class="kaauh-logo-container d-flex flex-column flex-md-row align-items-center gap-2 me-0">
|
||||
<div class="hospital-text text-center text-md-start me-0">
|
||||
<div class="ar text-xs">جامعة الأميرة نورة بنت عبدالرحمن الأكاديمية</div>
|
||||
<div class="ar text-xs">ومستشفى الملك عبدالله بن عبدالرحمن التخصصي</div>
|
||||
<div class="en text-xs">Princess Nourah bint Abdulrahman University</div>
|
||||
<div class="en text-xs">King Abdullah bin Abdulaziz University Hospital</div>
|
||||
</div>
|
||||
<div class="hospital-info d-flex gap-2 align-items-center">
|
||||
<div class="hospital-text text-center text-md-end">
|
||||
<div class="small fw-semibold" style="color: #004a53;">
|
||||
{% if LANGUAGE_CODE == 'ar' %}
|
||||
جامعة الأميرة نورة بنت عبدالرحمن الأكاديمية
|
||||
<br>
|
||||
ومستشفى الملك عبدالله بن عبدالعزيز التخصصي
|
||||
{% else %}
|
||||
Princess Nourah bint Abdulrahman University
|
||||
<br>
|
||||
King Abdullah bin Abdulaziz University Hospital
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<img src="{% static 'image/kaauh.png' %}" alt="KAAUH Logo" style="max-height: 40px; max-width: 40px;">
|
||||
<img src="{% static 'image/kaauh.png' %}" alt="KAAUH Logo" style="max-height: 40px; max-width: 40px;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div> {% endcomment %}
|
||||
</div>
|
||||
|
||||
{# Using inline style for nav background color - replace with a dedicated CSS class (e.g., .bg-kaauh-nav) if defined in main.css #}
|
||||
<div style="background-color: #00636e;">
|
||||
<nav class="navbar navbar-expand-lg navbar-dark sticky-top">
|
||||
<div class="container-fluid" style="max-width: 1600px;">
|
||||
|
||||
<a class="navbar-brand text-white" href="{% url 'agency_portal_dashboard' %}" aria-label="Agency Dashboard">
|
||||
<img src="{% static 'image/kaauh_green1.png' %}" alt="{% trans 'kaauh logo green bg' %}" style="width: 40px; height: 40px;">
|
||||
<span class="ms-3 d-none d-md-inline fw-semibold">{% trans "Agency Portal" %}</span>
|
||||
</a>
|
||||
|
||||
<!-- Agency Portal Header -->
|
||||
<nav class="navbar navbar-expand-lg navbar-dark sticky-top">
|
||||
<div class="container-fluid max-width-1600">
|
||||
<!-- Agency Portal Brand -->
|
||||
<a class="navbar-brand text-white" href="{% url 'agency_portal_dashboard' %}" aria-label="Agency Dashboard">
|
||||
<img src="{% static 'image/kaauh_green1.png' %}" alt="{% trans 'kaauh logo green bg' %}" style="width: 60px; height: 60px;">
|
||||
<span class="ms-3 d-none d-lg-inline">{% trans "Agency Portal" %}</span>
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#agencyNavbar"
|
||||
aria-controls="agencyNavbar" aria-expanded="false" aria-label="{% trans 'Toggle navigation' %}">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
||||
<!-- Mobile Toggler -->
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#agencyNavbar"
|
||||
aria-controls="agencyNavbar" aria-expanded="false" aria-label="{% trans 'Toggle navigation' %}">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="agencyNavbar">
|
||||
<div class="navbar-nav ms-auto">
|
||||
|
||||
{# NAVIGATION LINKS (Add your portal links here if needed) #}
|
||||
|
||||
<!-- Agency Controls -->
|
||||
<div class="collapse navbar-collapse" id="agencyNavbar">
|
||||
<div class="navbar-nav ms-auto">
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle text-white" href="#" role="button" data-bs-toggle="dropdown"
|
||||
data-bs-offset="0, 8" aria-expanded="false" aria-label="{% trans 'Toggle language menu' %}">
|
||||
<i class="fas fa-globe me-1"></i>
|
||||
<span class="d-none d-lg-inline">{{ LANGUAGE_CODE|upper }}</span>
|
||||
</a>
|
||||
<ul class="dropdown-menu {% if LANGUAGE_CODE == 'ar' %}dropdown-menu-start{% else %}dropdown-menu-end{% endif %}" data-bs-popper="static">
|
||||
<li>
|
||||
<form action="/i18n/setlang/" method="post" class="d-inline">{% csrf_token %}
|
||||
<input name="next" type="hidden" value="{{ request.get_full_path }}">
|
||||
<button name="language" value="en" class="dropdown-item {% if LANGUAGE_CODE == 'en' %}active bg-light-subtle{% endif %}" type="submit">
|
||||
<span class="me-2">🇺🇸</span> English
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
<li>
|
||||
<form action="/i18n/setlang/" method="post" class="d-inline">{% csrf_token %}
|
||||
<input name="next" type="hidden" value="{{ request.get_full_path }}">
|
||||
<button name="language" value="ar" class="dropdown-item {% if LANGUAGE_CODE == 'ar' %}active bg-light-subtle{% endif %}" type="submit">
|
||||
<span class="me-2">🇸🇦</span> العربية (Arabic)
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<!-- Language Switcher -->
|
||||
<li class="nav-item dropdown">
|
||||
<a class="language-toggle-btn dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown"
|
||||
data-bs-offset="0, 8" aria-expanded="false" aria-label="{% trans 'Toggle language menu' %}">
|
||||
<i class="fas fa-globe"></i>
|
||||
<span class="d-none d-lg-inline">{{ LANGUAGE_CODE|upper }}</span>
|
||||
</a>
|
||||
<ul class="dropdown-menu {% if LANGUAGE_CODE == 'ar' %}dropdown-menu-start{% else %}dropdown-menu-end{% endif %}" data-bs-popper="static">
|
||||
<li>
|
||||
<form action="{% url 'set_language' %}" method="post" class="d-inline">{% csrf_token %}
|
||||
<input name="next" type="hidden" value="{{ request.get_full_path }}">
|
||||
<button name="language" value="en" class="dropdown-item {% if LANGUAGE_CODE == 'en' %}active bg-light-subtle{% endif %}" type="submit">
|
||||
<span class="me-2">🇺🇸</span> English
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
<li>
|
||||
<form action="{% url 'set_language' %}" method="post" class="d-inline">{% csrf_token %}
|
||||
<input name="next" type="hidden" value="{{ request.get_full_path }}">
|
||||
<button name="language" value="ar" class="dropdown-item {% if LANGUAGE_CODE == 'ar' %}active bg-light-subtle{% endif %}" type="submit">
|
||||
<span class="me-2">🇸🇦</span> العربية (Arabic)
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<!-- Logout -->
|
||||
<li class="nav-item ms-3">
|
||||
<form method="post" action="{% url 'agency_portal_logout' %}" class="d-inline">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-outline-light btn-sm">
|
||||
<i class="fas fa-sign-out-alt me-1"></i> {% trans "Logout" %}
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
<li class="nav-item ms-3">
|
||||
<form method="post" action="{% url 'agency_portal_logout' %}" class="d-inline">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-outline-light btn-sm">
|
||||
<i class="fas fa-sign-out-alt me-1"></i> {% trans "Logout" %}
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<main class="container-fluid flex-grow-1" style="max-width: 1600px; margin: 0 auto;">
|
||||
{# Messages Block (Correct) #}
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags }} alert-dismissible fade show mt-2" role="alert">
|
||||
@ -121,10 +124,11 @@
|
||||
{% endblock %}
|
||||
</main>
|
||||
|
||||
{# Footer (Correct) #}
|
||||
<footer class="mt-auto">
|
||||
<div class="footer-bottom py-3 small text-muted" style="background-color: #00363a;">
|
||||
<div class="container-fluid">
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap max-width-1600">
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap" style="max-width: 1600px; margin: 0 auto;">
|
||||
<p class="mb-0 text-white-50">
|
||||
© {% now "Y" %} {% trans "King Abdullah Academic University Hospital (KAAUH)." %}
|
||||
{% trans "All rights reserved." %}
|
||||
@ -142,6 +146,8 @@
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js"></script>
|
||||
<script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.6/bundles/datastar.js"></script>
|
||||
|
||||
{# JavaScript (Left unchanged as it was mostly correct) #}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Navbar collapse auto-close on link click (Mobile UX)
|
||||
@ -200,4 +206,4 @@
|
||||
|
||||
{% block customJS %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
@ -100,7 +100,7 @@
|
||||
|
||||
<ul class="navbar-nav ms-2 ms-lg-4">
|
||||
<!-- Notification Bell for Admin Users -->
|
||||
{% if request.user.is_authenticated and request.user.is_staff %}
|
||||
{% comment %} {% if request.user.is_authenticated and request.user.is_staff %}
|
||||
<li class="nav-item dropdown me-2">
|
||||
<a class="nav-link position-relative" href="#" role="button" id="notificationDropdown" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="fas fa-bell"></i>
|
||||
@ -121,7 +121,7 @@
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endif %} {% endcomment %}
|
||||
|
||||
<li class="nav-item dropdown">
|
||||
<button
|
||||
@ -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' %}">
|
||||
@ -390,7 +390,7 @@
|
||||
</script>
|
||||
|
||||
<!-- Notification JavaScript for Admin Users -->
|
||||
{% if request.user.is_authenticated and request.user.is_staff %}
|
||||
{% comment %} {% if request.user.is_authenticated and request.user.is_staff %}
|
||||
<script>
|
||||
// SSE Notification System
|
||||
let eventSource = null;
|
||||
@ -405,7 +405,7 @@
|
||||
}
|
||||
|
||||
// Create new EventSource connection
|
||||
eventSource = new EventSource('{% url "notification_stream" %}');
|
||||
eventSource = new EventSource('');
|
||||
|
||||
eventSource.onopen = function(event) {
|
||||
console.log('SSE connection opened');
|
||||
@ -647,12 +647,13 @@
|
||||
// Initialize SSE connection on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Only connect SSE for authenticated staff users
|
||||
if ('{{ request.user.is_authenticated|yesno:"true,false" }}' === 'true' && '{{ request.user.is_staff|yesno:"true,false" }}' === 'true') {
|
||||
/*if ('{{ request.user.is_authenticated|yesno:"true,false" }}' === 'true' && '{{ request.user.is_staff|yesno:"true,false" }}' === 'true') {
|
||||
connectSSE();
|
||||
|
||||
// Initial notification count update
|
||||
updateNotificationCount();
|
||||
}
|
||||
*/
|
||||
});
|
||||
|
||||
// Cleanup on page unload
|
||||
@ -701,10 +702,9 @@
|
||||
list.innerHTML = '<div class="px-3 py-2 text-muted text-center"><small>{% trans "Error loading messages" %}</small></div>';
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endif %}
|
||||
</script> {% endcomment %}
|
||||
{% comment %} {% endif %} {% endcomment %}
|
||||
|
||||
{% block customJS %}{% endblock %}
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
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');
|
||||
});
|
||||
@ -1,6 +1,6 @@
|
||||
{% load crispy_forms_tags %}
|
||||
<div class="modal fade mt-4" id="linkedinData" tabindex="-1" aria-labelledby="myModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-dialog modal-xl" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="myModalLabel">Edit linkedin Post content</h5>
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -161,6 +161,60 @@
|
||||
<div class="text-muted">{{ assignment.admin_notes }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div class="kaauh-card shadow-sm mb-4">
|
||||
<div class="card-body my-2">
|
||||
<h5 class="card-title mb-3 mx-2">
|
||||
<i class="fas fa-key me-2 text-warning"></i>
|
||||
{% trans "Access Credentials" %}
|
||||
</h5>
|
||||
|
||||
<div class="mb-3 mx-2">
|
||||
<label class="form-label text-muted small">{% trans "Login URL" %}</label>
|
||||
<div class="input-group">
|
||||
<input type="text" readonly value="{{ request.scheme }}://{{ request.get_host }}{% url 'agency_portal_login' %}"
|
||||
class="form-control font-monospace" id="loginUrl">
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="copyToClipboard('loginUrl')">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 mx-2">
|
||||
<label class="form-label text-muted small">{% trans "Access Token" %}</label>
|
||||
<div class="input-group">
|
||||
<input type="text" readonly value="{{ access_link.unique_token }}"
|
||||
class="form-control font-monospace" id="accessToken">
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="copyToClipboard('accessToken')">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 mx-2">
|
||||
<label class="form-label text-muted small">{% trans "Password" %}</label>
|
||||
<div class="input-group">
|
||||
<input type="text" readonly value="{{ access_link.access_password }}"
|
||||
class="form-control font-monospace" id="accessPassword">
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="copyToClipboard('accessPassword')">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info mx-2">
|
||||
<i class="fas fa-info-circle me-2"></i>
|
||||
{% trans "Share these credentials securely with the agency. They can use this information to log in and submit candidates." %}
|
||||
</div>
|
||||
|
||||
<a href="{% url 'agency_access_link_detail' access_link.slug %}"
|
||||
class="btn btn-outline-info btn-sm mx-2">
|
||||
<i class="fas fa-eye me-1"></i> {% trans "View Access Links Details" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Candidates Card -->
|
||||
@ -277,68 +331,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Access Link Card -->
|
||||
<div class="kaauh-card p-4 mb-4">
|
||||
<h5 class="mb-4" style="color: var(--kaauh-teal-dark);">
|
||||
<i class="fas fa-link me-2"></i>
|
||||
{% trans "Access Link" %}
|
||||
</h5>
|
||||
|
||||
{% if access_link %}
|
||||
<div class="mb-3">
|
||||
<label class="text-muted small">{% trans "Status" %}</label>
|
||||
<div>
|
||||
{% if access_link.is_active %}
|
||||
<span class="badge bg-success">{% trans "Active" %}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">{% trans "Inactive" %}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="text-muted small">{% trans "Token" %}</label>
|
||||
<div class="font-monospace small bg-light p-2 rounded">
|
||||
{{ access_link.unique_token }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="text-muted small">{% trans "Expires" %}</label>
|
||||
<div class="{% if access_link.is_expired %}text-danger{% else %}text-muted{% endif %}">
|
||||
{{ access_link.expires_at|date:"Y-m-d H:i" }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="text-muted small">{% trans "Access Count" %}</label>
|
||||
<div>{{ access_link.access_count }} {% trans "times accessed" %}</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
<a href="{% url 'agency_access_link_detail' access_link.slug %}"
|
||||
class="btn btn-outline-info btn-sm">
|
||||
<i class="fas fa-eye me-1"></i> {% trans "View Details" %}
|
||||
</a>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||
onclick="copyToClipboard('{{ access_link.unique_token }}')">
|
||||
<i class="fas fa-copy me-1"></i> {% trans "Copy Token" %}
|
||||
</button>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-3">
|
||||
<i class="fas fa-link fa-2x text-muted mb-3"></i>
|
||||
<h6 class="text-muted">{% trans "No Access Link" %}</h6>
|
||||
<p class="text-muted small">
|
||||
{% trans "Create an access link to allow the agency to submit candidates." %}
|
||||
</p>
|
||||
<a href="{% url 'agency_access_link_create' %}" class="btn btn-main-action btn-sm">
|
||||
<i class="fas fa-plus me-1"></i> {% trans "Create Access Link" %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Actions Card -->
|
||||
<div class="kaauh-card p-4">
|
||||
<h5 class="mb-4" style="color: var(--kaauh-teal-dark);">
|
||||
@ -473,6 +466,39 @@ function copyToClipboard(text) {
|
||||
});
|
||||
}
|
||||
|
||||
function copyToClipboard(elementId) {
|
||||
const element = document.getElementById(elementId);
|
||||
element.select();
|
||||
document.execCommand('copy');
|
||||
|
||||
// Show feedback
|
||||
const button = element.nextElementSibling;
|
||||
const originalHTML = button.innerHTML;
|
||||
button.innerHTML = '<i class="fas fa-check"></i>';
|
||||
button.classList.add('btn-success');
|
||||
button.classList.remove('btn-outline-secondary');
|
||||
|
||||
setTimeout(() => {
|
||||
button.innerHTML = originalHTML;
|
||||
button.classList.remove('btn-success');
|
||||
button.classList.add('btn-outline-secondary');
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function confirmDeactivate() {
|
||||
if (confirm('{% trans "Are you sure you want to deactivate this access link? Agencies will no longer be able to use it." %}')) {
|
||||
// Submit form to deactivate
|
||||
window.location.href = '{% url "agency_access_link_deactivate" access_link.slug %}';
|
||||
}
|
||||
}
|
||||
|
||||
function confirmReactivate() {
|
||||
if (confirm('{% trans "Are you sure you want to reactivate this access link?" %}')) {
|
||||
// Submit form to reactivate
|
||||
window.location.href = '{% url "agency_access_link_reactivate" access_link.slug %}';
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Set minimum datetime for new deadline
|
||||
const deadlineInput = document.getElementById('new_deadline');
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load static i18n %}
|
||||
{% load static i18n widget_tweaks %}
|
||||
|
||||
{% block title %}{{ title }} - ATS{% endblock %}
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
/* KAAT-S UI Variables */
|
||||
:root {
|
||||
--kaauh-teal: #00636e;
|
||||
--kaauh-teal-light: #e0f7f9; /* Added for contrast */
|
||||
--kaauh-teal-dark: #004a53;
|
||||
--kaauh-border: #eaeff3;
|
||||
--kaauh-primary-text: #343a40;
|
||||
@ -16,19 +17,27 @@
|
||||
--kaauh-danger: #dc3545;
|
||||
--kaauh-warning: #ffc107;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #f8f9fa; /* Light background for better contrast */
|
||||
}
|
||||
|
||||
.kaauh-card {
|
||||
padding: 2.5rem; /* Increased padding */
|
||||
border: 1px solid var(--kaauh-border);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
/* Main Action Button (Teal Fill) */
|
||||
.btn-main-action {
|
||||
background-color: var(--kaauh-teal);
|
||||
border-color: var(--kaauh-teal);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
padding: 0.6rem 1.25rem;
|
||||
border-radius: 0.5rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.btn-main-action:hover {
|
||||
@ -36,7 +45,21 @@
|
||||
border-color: var(--kaauh-teal-dark);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
/* Secondary Action Button (Teal Outline) */
|
||||
.btn-outline-primary-teal {
|
||||
color: var(--kaauh-teal);
|
||||
border-color: var(--kaauh-teal);
|
||||
font-weight: 600;
|
||||
padding: 0.6rem 1.25rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
.btn-outline-primary-teal:hover {
|
||||
background-color: var(--kaauh-teal);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Form Consistency */
|
||||
.form-control:focus, .form-select:focus {
|
||||
border-color: var(--kaauh-teal);
|
||||
box-shadow: 0 0 0 0.2rem rgba(0, 99, 110, 0.25);
|
||||
@ -46,6 +69,30 @@
|
||||
font-weight: 600;
|
||||
color: var(--kaauh-primary-text);
|
||||
}
|
||||
|
||||
/* Applying Bootstrap classes to Django fields if not done in the form definition */
|
||||
.kaauh-field-control > input,
|
||||
.kaauh-field-control > textarea,
|
||||
.kaauh-field-control > select {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
color: var(--kaauh-primary-text);
|
||||
background-color: #fff;
|
||||
background-clip: padding-box;
|
||||
border: 1px solid #ced4da;
|
||||
appearance: none;
|
||||
border-radius: 0.5rem;
|
||||
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
|
||||
}
|
||||
/* Specific overrides for different types */
|
||||
.kaauh-field-control > select {
|
||||
padding-right: 2.5rem; /* Space for the caret */
|
||||
}
|
||||
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@ -53,7 +100,6 @@
|
||||
<div class="container-fluid py-4">
|
||||
<div class="row">
|
||||
<div class="col-lg-8 mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-1" style="color: var(--kaauh-teal-dark); font-weight: 700;">
|
||||
@ -64,36 +110,40 @@
|
||||
{% trans "Assign a job to an external hiring agency" %}
|
||||
</p>
|
||||
</div>
|
||||
<a href="{% url 'agency_assignment_list' %}" class="btn btn-outline-secondary">
|
||||
<a href="{% url 'agency_assignment_list' %}" class="btn btn-outline-primary-teal">
|
||||
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to Assignments" %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Form Card -->
|
||||
<div class="kaauh-card">
|
||||
<form method="post" novalidate>
|
||||
{% csrf_token %}
|
||||
|
||||
<!-- Agency and Job Selection -->
|
||||
{{ form.agency }}
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
{% comment %} <div class="col-md-6">
|
||||
|
||||
<div class="col-md-6">
|
||||
<label for="{{ form.agency.id_for_label }}" class="form-label">
|
||||
{{ form.agency.label }} <span class="text-danger">*</span>
|
||||
</label>
|
||||
{{ form.agency }}
|
||||
{# Wrapper Div for styling consistency (Assumes agency is a SELECT field) #}
|
||||
<div class="kaauh-field-control">
|
||||
{{ form.agency|attr:'class:form-select' }}
|
||||
</div>
|
||||
{% if form.agency.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{% for error in form.agency.errors %}{{ error }}{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div> {% endcomment %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label for="{{ form.job.id_for_label }}" class="form-label">
|
||||
{{ form.job.label }} <span class="text-danger">*</span>
|
||||
</label>
|
||||
{{ form.job }}
|
||||
{# Wrapper Div for styling consistency (Assumes job is a SELECT field) #}
|
||||
<div class="kaauh-field-control">
|
||||
{{ form.job|attr:'class:form-select' }}
|
||||
</div>
|
||||
{% if form.job.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{% for error in form.job.errors %}{{ error }}{% endfor %}
|
||||
@ -102,13 +152,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Assignment Details -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-6">
|
||||
<label for="{{ form.max_candidates.id_for_label }}" class="form-label">
|
||||
{{ form.max_candidates.label }} <span class="text-danger">*</span>
|
||||
</label>
|
||||
{{ form.max_candidates }}
|
||||
{# Wrapper Div for styling consistency (Assumes max_candidates is an INPUT field) #}
|
||||
<div class="kaauh-field-control">
|
||||
{{ form.max_candidates|attr:'class:form-control' }}
|
||||
</div>
|
||||
{% if form.max_candidates.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{% for error in form.max_candidates.errors %}{{ error }}{% endfor %}
|
||||
@ -122,7 +174,10 @@
|
||||
<label for="{{ form.deadline_date.id_for_label }}" class="form-label">
|
||||
{{ form.deadline_date.label }} <span class="text-danger">*</span>
|
||||
</label>
|
||||
{{ form.deadline_date }}
|
||||
{# Wrapper Div for styling consistency (Assumes deadline_date is an INPUT field) #}
|
||||
<div class="kaauh-field-control">
|
||||
{{ form.deadline_date|attr:'class:form-control' }}
|
||||
</div>
|
||||
{% if form.deadline_date.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{% for error in form.deadline_date.errors %}{{ error }}{% endfor %}
|
||||
@ -134,46 +189,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status and Settings -->
|
||||
{% comment %} <div class="row g-3 mb-4">
|
||||
<div class="col-md-6">
|
||||
<label for="{{ form.is_active.id_for_label }}" class="form-label">
|
||||
{{ form.is_active.label }}
|
||||
</label>
|
||||
<div class="form-check form-switch">
|
||||
{{ form.is_active }}
|
||||
<label class="form-check-label" for="{{ form.is_active.id_for_label }}">
|
||||
{% trans "Enable this assignment" %}
|
||||
</label>
|
||||
</div>
|
||||
{% if form.is_active.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{% for error in form.is_active.errors %}{{ error }}{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="{{ form.status.id_for_label }}" class="form-label">
|
||||
{{ form.status.label }}
|
||||
</label>
|
||||
{{ form.status }}
|
||||
{% if form.status.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{% for error in form.status.errors %}{{ error }}{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<small class="form-text text-muted">
|
||||
{% trans "Current status of this assignment" %}
|
||||
</small>
|
||||
</div>
|
||||
</div> {% endcomment %}
|
||||
|
||||
<!-- Admin Notes -->
|
||||
<div class="mb-4">
|
||||
<label for="{{ form.admin_notes.id_for_label }}" class="form-label">
|
||||
{{ form.admin_notes.label }}
|
||||
</label>
|
||||
{{ form.admin_notes }}
|
||||
{# Wrapper Div for styling consistency (Assumes admin_notes is a TEXTAREA field) #}
|
||||
<div class="kaauh-field-control">
|
||||
{{ form.admin_notes|attr:'class:form-control' }}
|
||||
</div>
|
||||
{% if form.admin_notes.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{% for error in form.admin_notes.errors %}{{ error }}{% endfor %}
|
||||
@ -184,9 +208,8 @@
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="d-flex justify-content-between align-items-center pt-3 border-top">
|
||||
<a href="{% url 'agency_assignment_list' %}" class="btn btn-outline-secondary">
|
||||
<a href="{% url 'agency_assignment_list' %}" class="btn btn-outline-primary-teal">
|
||||
<i class="fas fa-times me-1"></i> {% trans "Cancel" %}
|
||||
</a>
|
||||
<button type="submit" class="btn btn-main-action">
|
||||
@ -195,28 +218,6 @@
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Help Information -->
|
||||
{% comment %} <div class="kaauh-card mt-4">
|
||||
<h5 class="mb-3" style="color: var(--kaauh-teal-dark);">
|
||||
<i class="fas fa-info-circle me-2"></i>
|
||||
{% trans "Assignment Information" %}
|
||||
</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6 class="fw-bold text-primary">{% trans "Active Status" %}</h6>
|
||||
<p class="text-muted small">
|
||||
{% trans "Only active assignments allow agencies to submit candidates. Expired or cancelled assignments cannot receive new submissions." %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6 class="fw-bold text-primary">{% trans "Access Links" %}</h6>
|
||||
<p class="text-muted small">
|
||||
{% trans "After creating an assignment, you can generate access links for agencies to submit candidates through their portal." %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div> {% endcomment %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -225,6 +226,11 @@
|
||||
{% block customJS %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// --- Consistency Check: Ensure Django widgets have the Bootstrap classes ---
|
||||
// If your form fields are NOT already adding classes via widget attrs in the Django form,
|
||||
// you MUST add the following utility filter to your project to make this template work:
|
||||
// `|attr:'class:form-control'`
|
||||
|
||||
// Auto-populate agency field when job is selected
|
||||
const jobSelect = document.getElementById('{{ form.job.id_for_label }}');
|
||||
const agencySelect = document.getElementById('{{ form.agency.id_for_label }}');
|
||||
@ -248,4 +254,4 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
@ -312,7 +312,7 @@
|
||||
<div class="info-content">
|
||||
<div class="info-label">{% trans "Website" %}</div>
|
||||
<div class="info-value">
|
||||
<a href="{{ agency.website }}" target="_blank" class="text-decoration-none">
|
||||
<a href="{{ agency.website }}" target="_blank" class="text-decoration-none text-secondary">
|
||||
{{ agency.website }}
|
||||
<i class="fas fa-external-link-alt ms-1 small"></i>
|
||||
</a>
|
||||
@ -390,7 +390,7 @@
|
||||
<i class="fas fa-users me-2"></i>
|
||||
{% trans "Recent Candidates" %}
|
||||
</h5>
|
||||
<a href="{% url 'agency_candidates' agency.slug %}" class="btn btn-outline-primary btn-sm">
|
||||
<a href="{% url 'agency_candidates' agency.slug %}" class="btn btn-main-action btn-sm">
|
||||
{% trans "View All Candidates" %}
|
||||
<i class="fas fa-arrow-right ms-1"></i>
|
||||
</a>
|
||||
@ -482,7 +482,7 @@
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-grid gap-2">
|
||||
<a href="{% url 'agency_update' agency.slug %}" class="btn btn-outline-primary">
|
||||
<a href="{% url 'agency_update' agency.slug %}" class="btn btn-main-action">
|
||||
<i class="fas fa-edit me-2"></i> {% trans "Edit Agency" %}
|
||||
</a>
|
||||
<a href="{% url 'agency_candidates' agency.slug %}" class="btn btn-outline-info">
|
||||
|
||||
@ -64,7 +64,7 @@
|
||||
|
||||
/* Stats Badge */
|
||||
.stats-badge {
|
||||
background-color: var(--kaauh-info);
|
||||
background-color: var(--kaauh-teal);
|
||||
color: white;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
@ -168,7 +168,7 @@
|
||||
{% if agency.website %}
|
||||
<p class="card-text mb-3">
|
||||
<i class="fas fa-link text-muted me-2"></i>
|
||||
<a href="{{ agency.website }}" target="_blank" class="text-decoration-none">
|
||||
<a href="{{ agency.website }}" target="_blank" class="text-decoration-none text-secondary">
|
||||
{{ agency.website|truncatechars:30 }}
|
||||
<i class="fas fa-external-link-alt ms-1 small"></i>
|
||||
</a>
|
||||
@ -179,7 +179,7 @@
|
||||
<div class="d-flex justify-content-between align-items-center mt-auto">
|
||||
<div>
|
||||
<a href="{% url 'agency_detail' agency.slug %}"
|
||||
class="btn btn-outline-primary btn-sm me-2">
|
||||
class="btn btn-main-action btn-sm me-2">
|
||||
<i class="fas fa-eye me-1"></i> {% trans "View" %}
|
||||
</a>
|
||||
<a href="{% url 'agency_update' agency.slug %}"
|
||||
|
||||
@ -2,11 +2,44 @@
|
||||
{% load static i18n %}
|
||||
|
||||
{% block title %}{% trans "Agency Dashboard" %} - ATS{% endblock %}
|
||||
{% block customCSS %}
|
||||
<style>
|
||||
:root {
|
||||
--kaauh-teal: #00636e;
|
||||
--kaauh-teal-dark: #004a53;
|
||||
--kaauh-border: #eaeff3;
|
||||
--kaauh-primary-text: #343a40;
|
||||
--kaauh-success: #28a745;
|
||||
--kaauh-info: #17a2b8;
|
||||
--kaauh-danger: #dc3545;
|
||||
--kaauh-warning: #ffc107;
|
||||
}
|
||||
|
||||
.kaauh-card {
|
||||
border: 1px solid var(--kaauh-border);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.btn-main-action {
|
||||
background-color: var(--kaauh-teal);
|
||||
border-color: var(--kaauh-teal);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.btn-main-action:hover {
|
||||
background-color: var(--kaauh-teal-dark);
|
||||
border-color: var(--kaauh-teal-dark);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
||||
}
|
||||
</style>
|
||||
{% endblock%}
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<div class="px-2 py-2">
|
||||
<h1 class="h3 mb-1" style="color: var(--kaauh-teal-dark); font-weight: 700;">
|
||||
<i class="fas fa-tachometer-alt me-2"></i>
|
||||
{% trans "Agency Dashboard" %}
|
||||
@ -32,9 +65,9 @@
|
||||
|
||||
<!-- Overview Statistics -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="col-md-3 mb-2">
|
||||
<div class="kaauh-card shadow-sm h-100">
|
||||
<div class="card-body text-center">
|
||||
<div class="card-body text-center px-2 py-2">
|
||||
<div class="text-primary mb-2">
|
||||
<i class="fas fa-briefcase fa-2x"></i>
|
||||
</div>
|
||||
@ -43,8 +76,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="kaauh-card shadow-sm h-100">
|
||||
<div class="col-md-3 mb-2">
|
||||
<div class="kaauh-card shadow-sm h-100 px-2 py-2">
|
||||
<div class="card-body text-center">
|
||||
<div class="text-success mb-2">
|
||||
<i class="fas fa-check-circle fa-2x"></i>
|
||||
@ -54,8 +87,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="kaauh-card shadow-sm h-100">
|
||||
<div class="col-md-3 mb-2">
|
||||
<div class="kaauh-card shadow-sm h-100 px-2 py-2">
|
||||
<div class="card-body text-center">
|
||||
<div class="text-info mb-2">
|
||||
<i class="fas fa-users fa-2x"></i>
|
||||
@ -65,8 +98,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="kaauh-card shadow-sm h-100">
|
||||
<div class="col-md-3 mb-2">
|
||||
<div class="kaauh-card shadow-sm h-100 px-2 py-2">
|
||||
<div class="card-body text-center">
|
||||
<div class="text-warning mb-2">
|
||||
<i class="fas fa-envelope fa-2x"></i>
|
||||
@ -79,7 +112,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Job Assignments List -->
|
||||
<div class="kaauh-card shadow-sm">
|
||||
<div class="kaauh-card shadow-sm px-3 py-3">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h5 class="card-title mb-0">
|
||||
@ -171,7 +204,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<a href="{% url 'agency_portal_assignment_detail' stats.assignment.slug %}"
|
||||
class="btn btn-sm btn-outline-primary">
|
||||
class="btn btn-sm btn-main-action">
|
||||
<i class="fas fa-eye me-1"></i> {% trans "View Details" %}
|
||||
</a>
|
||||
{% if stats.unread_messages > 0 %}
|
||||
|
||||
@ -132,14 +132,7 @@
|
||||
<!-- Login Body -->
|
||||
<div class="login-body">
|
||||
<!-- Messages -->
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
|
||||
<!-- Login Form -->
|
||||
<form method="post" novalidate>
|
||||
|
||||
@ -654,37 +654,35 @@
|
||||
<h5 class="text-muted mb-3"><i class="fas fa-clock me-2"></i>{% trans "Time to Hire: " %}{{candidate.time_to_hire|default:100}} days</h5>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div class="resume-parsed-section">
|
||||
{% if candidate.is_resume_parsed %}
|
||||
{% include 'recruitment/candidate_resume_template.html' %}
|
||||
{% else %}
|
||||
<a href="{% url 'candidate_detail' candidate.slug %}" class="text-decoration-none">
|
||||
<div style="display: flex; justify-content: center; align-items: center; height: 100%;">
|
||||
|
||||
<div class="ai-loading-container">
|
||||
{# Robot Icon (Requires Font Awesome or similar library) #}
|
||||
<i class="fas fa-robot ai-robot-icon"></i>
|
||||
|
||||
{# The Spinner #}
|
||||
<svg class="kaats-spinner" viewBox="0 0 50 50">
|
||||
<circle cx="25" cy="25" r="20"></circle>
|
||||
<circle class="path" cx="25" cy="25" r="20" fill="none" stroke-width="4"
|
||||
style="animation: kaats-spinner-dash 1.5s ease-in-out infinite;"></circle>
|
||||
</svg>
|
||||
|
||||
<span>AI Scoring...</span>
|
||||
{% if candidate.scoring_timeout %}
|
||||
<div style="display: flex; justify-content: center; align-items: center; height: 100%;" class="mb-2">
|
||||
<div class="ai-loading-container">
|
||||
<i class="fas fa-robot ai-robot-icon"></i>
|
||||
<span>Resume is been Scoring...</span>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="display: flex; justify-content: center; align-items: center; height: 100%;">
|
||||
<button type="submit" class="btn btn-sm btn-main-action" hx-get="{% url 'candidate_retry_scoring' candidate.slug %}" hx-select=".resume-parsed-section" hx-target=".resume-parsed-section" hx-swap="outerHTML" hx-on:click="this.disabled=true;this.innerHTML=`Scoring Resume , Please Wait.. <i class='fa-solid fa-spinner fa-spin'></i>`">
|
||||
{% trans "Retry AI Scoring" %}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# STAGE UPDATE MODAL INCLUDED FOR STAFF USERS #}
|
||||
|
||||
|
||||
{% if user.is_staff %}
|
||||
|
||||
@ -179,7 +179,7 @@
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{% url 'export_candidates_csv' job.slug 'exam' %}"
|
||||
class="btn btn-outline-secondary btn-sm"
|
||||
class="btn btn-outline-secondary"
|
||||
title="{% trans 'Export exam candidates to CSV' %}">
|
||||
<i class="fas fa-download me-1"></i> {% trans "Export CSV" %}
|
||||
</a>
|
||||
@ -210,7 +210,7 @@
|
||||
|
||||
{# Select Input Group #}
|
||||
<div>
|
||||
<label for="update_status" class="form-label small mb-1 fw-bold">{% trans "Move Selected To:" %}</label>
|
||||
|
||||
<select name="mark_as" id="update_status" class="form-select form-select-sm" style="min-width: 150px;">
|
||||
<option selected>
|
||||
----------
|
||||
@ -226,7 +226,7 @@
|
||||
|
||||
{# Button #}
|
||||
<button type="submit" class="btn btn-main-action btn-sm">
|
||||
<i class="fas fa-arrow-right me-1"></i> {% trans "Update Status" %}
|
||||
<i class="fas fa-arrow-right me-1"></i> {% trans "Change Stage" %}
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
@ -197,13 +197,13 @@
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button"
|
||||
class="btn btn-main-action btn-sm"
|
||||
class="btn btn-main-action"
|
||||
onclick="syncHiredCandidates()"
|
||||
title="{% trans 'Sync hired candidates to external sources' %}">
|
||||
<i class="fas fa-sync me-1"></i> {% trans "Sync to Sources" %}
|
||||
</button>
|
||||
<a href="{% url 'export_candidates_csv' job.slug 'hired' %}"
|
||||
class="btn btn-outline-secondary btn-sm"
|
||||
class="btn btn-outline-secondary"
|
||||
title="{% trans 'Export hired candidates to CSV' %}">
|
||||
<i class="fas fa-download me-1"></i> {% trans "Export CSV" %}
|
||||
</a>
|
||||
|
||||
@ -182,7 +182,7 @@
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{% url 'export_candidates_csv' job.slug 'interview' %}"
|
||||
class="btn btn-outline-secondary btn-sm"
|
||||
class="btn btn-outline-secondary"
|
||||
title="{% trans 'Export interview candidates to CSV' %}">
|
||||
<i class="fas fa-download me-1"></i> {% trans "Export CSV" %}
|
||||
</a>
|
||||
@ -206,6 +206,7 @@
|
||||
{% csrf_token %}
|
||||
|
||||
{# Select Input Group - No label needed for this one, so we just flex the select and button #}
|
||||
|
||||
<select name="mark_as" id="update_status" class="form-select form-select-sm" style="width: 120px;">
|
||||
<option selected>
|
||||
----------
|
||||
@ -218,7 +219,7 @@
|
||||
</option>
|
||||
</select>
|
||||
<button type="submit" class="btn btn-main-action btn-sm">
|
||||
<i class="fas fa-arrow-right me-1"></i> {% trans "Move" %}
|
||||
<i class="fas fa-arrow-right me-1"></i> {% trans "Change Stage" %}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@ -231,16 +232,19 @@
|
||||
<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">
|
||||
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<form id="candidate-form" action="{% url 'candidate_update_status' job.slug %}" method="get">
|
||||
{% csrf_token %}
|
||||
<table class="table candidate-table align-middle">
|
||||
@ -369,6 +373,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"
|
||||
@ -411,7 +423,6 @@
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@ -432,47 +443,97 @@
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="jobAssignmentModal" tabindex="-1" aria-labelledby="jobAssignmentLabel" aria-hidden="true">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-dialog modal-lg" role="document">
|
||||
<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 %}
|
||||
<<<<<<< HEAD
|
||||
|
||||
<div class="modal-body table-responsive">
|
||||
=======
|
||||
|
||||
<div class="modal-body">
|
||||
>>>>>>> c6fcb276135dc7e87bb0d065a93ff89091ff0207
|
||||
{{ job.internal_job_id }} {{ job.title}}
|
||||
|
||||
|
||||
<hr>
|
||||
<<<<<<< HEAD
|
||||
|
||||
|
||||
<table class="table tab table-bordered mt-3">
|
||||
<thead>
|
||||
<th class="col">👥 {% trans "Participants" %}</th>
|
||||
<th class="col">🧑💼 {% trans "Users" %}</th>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
{{ form.participants.errors }}
|
||||
{{ form.participants }}
|
||||
</td>
|
||||
<td> {{ form.users.errors }}
|
||||
{{ form.users }}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
</table>
|
||||
=======
|
||||
|
||||
<h3>👥 {% trans "Participants" %}</h3>
|
||||
{{ form.participants.errors }}
|
||||
{{ form.participants }}
|
||||
|
||||
{{ form.participants }}
|
||||
|
||||
<hr>
|
||||
|
||||
|
||||
<h3>🧑💼 {% trans "Users" %}</h3>
|
||||
{{ form.users.errors }}
|
||||
{{ form.users }}
|
||||
>>>>>>> c6fcb276135dc7e87bb0d065a93ff89091ff0207
|
||||
</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 +640,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
|
||||
|
||||
@ -261,20 +261,20 @@
|
||||
{% include "includes/_list_view_switcher.html" with list_id="candidate-list" %}
|
||||
|
||||
{# Table View (Default) #}
|
||||
<div class="table-view active">
|
||||
<div class="table-view">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" style="width: 12%;">{% trans "Name" %}</th>
|
||||
<th scope="col" style="width: 12%;">{% trans "Email" %}</th>
|
||||
<th scope="col" style="width: 8%;">{% trans "Phone" %}</th>
|
||||
<th scope="col" style="width: 12%;">{% trans "Job" %}</th>
|
||||
<th scope="col" style="width: 5%;">{% trans "Major" %}</th>
|
||||
<th scope="col" style="width: 8%;">{% trans "Stage" %}</th>
|
||||
<th scope="col" style="width: 10%;">{% trans "Hiring Source" %}</th>
|
||||
<th scope="col" style="width: 13%;">{% trans "created At" %}</th>
|
||||
<th scope="col" style="width: 5%;" class="text-end">{% trans "Actions" %}</th>
|
||||
<th scope="col" >{% trans "Name" %}</th>
|
||||
<th scope="col">{% trans "Email" %}</th>
|
||||
{% comment %} <th scope="col" style="width: 8%;">{% trans "Phone" %}</th> {% endcomment %}
|
||||
<th scope="col">{% trans "Job" %}</th>
|
||||
<th scope="col" >{% trans "Major" %}</th>
|
||||
<th scope="col" >{% trans "Stage" %}</th>
|
||||
<th scope="col">{% trans "Hiring Source" %}</th>
|
||||
<th scope="col" >{% trans "created At" %}</th>
|
||||
<th scope="col" class="text-end">{% trans "Actions" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@ -282,7 +282,7 @@
|
||||
<tr>
|
||||
<td class="fw-medium"><a href="{% url 'candidate_detail' candidate.slug %}" class="text-decoration-none link-secondary">{{ candidate.name }}<a></td>
|
||||
<td>{{ candidate.email }}</td>
|
||||
<td>{{ candidate.phone }}</td>
|
||||
{% comment %} <td>{{ candidate.phone }}</td> {% endcomment %}
|
||||
<td> <span class="badge bg-primary"><a href="{% url 'job_detail' candidate.job.slug %}" class="text-decoration-none text-white">{{ candidate.job.title }}</a></span></td>
|
||||
<td>
|
||||
{% if candidate.is_resume_parsed %}
|
||||
@ -292,16 +292,15 @@
|
||||
</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<a href="{% url 'candidate_list' %}" class="text-decoration-none">
|
||||
<div>
|
||||
<a href="{% url 'candidate_list' %}" class="text-decoration-none d-flex align-items-center gap-2">
|
||||
<svg class="kaats-spinner" viewBox="0 0 50 50" style="width: 25px; height: 25px;">
|
||||
<circle cx="25" cy="25" r="20"></circle>
|
||||
<circle class="path" cx="25" cy="25" r="20" fill="none" stroke-width="5"
|
||||
style="animation: kaats-spinner-dash 1.5s ease-in-out infinite;"></circle>
|
||||
</svg>
|
||||
<span class="text-teal-primary text-nowrap">{% trans "AI Scoring..." %}</span>
|
||||
</div>
|
||||
</a>
|
||||
{# CRITICAL: Remove the DIV and the text-nowrap class #}
|
||||
<span class="text-teal-primary">{% trans "AI Scoring..." %}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
|
||||
@ -181,7 +181,7 @@
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{% url 'export_candidates_csv' job.slug 'offer' %}"
|
||||
class="btn btn-outline-secondary btn-sm"
|
||||
class="btn btn-outline-secondary"
|
||||
title="{% trans 'Export offer candidates to CSV' %}">
|
||||
<i class="fas fa-download me-1"></i> {% trans "Export CSV" %}
|
||||
</a>
|
||||
@ -219,7 +219,7 @@
|
||||
|
||||
{# Button #}
|
||||
<button type="submit" class="btn btn-main-action btn-sm">
|
||||
<i class="fas fa-arrow-right me-1"></i> {% trans "Move" %}
|
||||
<i class="fas fa-arrow-right me-1"></i> {% trans "Change Stage" %}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
|
||||
@ -230,7 +230,7 @@
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{% url 'export_candidates_csv' job.slug 'screening' %}"
|
||||
class="btn btn-outline-secondary btn-sm"
|
||||
class="btn btn-outline-secondary"
|
||||
title="{% trans 'Export screening candidates to CSV' %}">
|
||||
<i class="fas fa-download me-1"></i> {% trans "Export CSV" %}
|
||||
</a>
|
||||
@ -324,7 +324,7 @@
|
||||
|
||||
{# Select Input Group #}
|
||||
<div>
|
||||
<label for="update_status" class="form-label small mb-1 fw-bold">{% trans "Move Selected To:" %}</label>
|
||||
|
||||
<select name="mark_as" id="update_status" class="form-select form-select-sm" style="min-width: 150px;">
|
||||
<option selected>
|
||||
----------
|
||||
@ -338,7 +338,7 @@
|
||||
|
||||
{# Button #}
|
||||
<button type="submit" class="btn btn-main-action btn-sm">
|
||||
<i class="fas fa-arrow-right me-1"></i> {% trans "Update Status" %}
|
||||
<i class="fas fa-arrow-right me-1"></i> {% trans "Change Stage" %}
|
||||
</button>
|
||||
|
||||
</div>
|
||||
@ -361,7 +361,7 @@
|
||||
{% endif %}
|
||||
</th>
|
||||
<th scope="col" style="width: 8%;">
|
||||
<i class="fas fa-user me-1"></i> {% trans "Candidate Name" %}
|
||||
<i class="fas fa-user me-1"></i> {% trans "Name" %}
|
||||
</th>
|
||||
<th scope="col" style="width: 10%;">
|
||||
<i class="fas fa-phone me-1"></i> {% trans "Contact Info" %}
|
||||
|
||||
@ -223,6 +223,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="card shadow-sm no-hover mb-4">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-chart-pie me-2 text-primary"></i>
|
||||
{% trans "Candidates From Each Sources" %}
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div style="height: 300px;">
|
||||
<canvas id="candidatesourceschart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@ -442,6 +456,86 @@
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// Chart for Candidate Categories and Match Scores
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const ctx = document.getElementById('candidatesourceschart');
|
||||
if (!ctx) {
|
||||
console.warn('Candidates sources chart element not found.');
|
||||
return;
|
||||
}
|
||||
const chartCtx = ctx.getContext('2d');
|
||||
|
||||
// Safely get job_category_data from Django context
|
||||
// Using window.jobChartData to avoid template parsing issues
|
||||
|
||||
|
||||
if (categories.length > 0) { // Only render if there's data
|
||||
const chart = new Chart(chartCtx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: categories,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Number of Candidates',
|
||||
data: candidates_count_in_each_source,
|
||||
backgroundColor: [
|
||||
'rgba(0, 99, 110, 0.7)', // --kaauh-teal
|
||||
'rgba(23, 162, 184, 0.7)', // Teal shade
|
||||
'rgba(0, 150, 136, 0.7)', // Teal green
|
||||
'rgba(0, 188, 212, 0.7)', // Cyan
|
||||
'rgba(38, 166, 154, 0.7)', // Turquoise
|
||||
'rgba(77, 182, 172, 0.7)', // Medium teal
|
||||
// Add more colors if you expect more categories
|
||||
],
|
||||
borderColor: [
|
||||
'rgba(0, 99, 110, 1)',
|
||||
'rgba(23, 162, 184, 1)',
|
||||
'rgba(0, 150, 136, 1)',
|
||||
'rgba(0, 188, 212, 1)',
|
||||
'rgba(38, 166, 154, 1)',
|
||||
'rgba(77, 182, 172, 1)',
|
||||
// Add more colors if you expect more categories
|
||||
],
|
||||
borderWidth: 1,
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false, // Important for fixed height container
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'right', // Position legend for doughnut chart
|
||||
},
|
||||
title: {
|
||||
display: false, // Chart title is handled by the card header
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
let label = context.label || '';
|
||||
if (label) {
|
||||
label += ': ';
|
||||
}
|
||||
label += context.parsed + ' candidate(s)';
|
||||
return label;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Display a message if no data is available
|
||||
chartCtx.canvas.parentNode.innerHTML = '<p class="text-center text-muted mt-4">No candidate category data available for this job.</p>';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
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