Compare commits

...

7 Commits

Author SHA1 Message Date
f4bddfc391 .. 2025-10-30 19:57:30 +03:00
2cfab7c3ef spinner for parsing 2025-10-30 19:54:09 +03:00
c6fcb27613 update the email 2025-10-30 19:53:26 +03:00
08774489bc updates 2025-10-30 18:00:51 +03:00
3ff3c25734 comment the notification for now 2025-10-30 13:17:50 +03:00
2e62700146 Merge pull request 'frontend' (#26) from frontend into main
Reviewed-on: #26
2025-10-30 13:17:25 +03:00
b37af920ba small fix in the dashboard 2025-10-30 12:09:52 +03:00
53 changed files with 4064 additions and 731 deletions

View File

@ -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"

View File

@ -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}

View File

@ -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

View File

@ -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

View File

@ -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}"

View File

@ -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}

View File

@ -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'),
]

View File

@ -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
})

View File

@ -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)

View File

@ -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">
&copy; {% 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>

View File

@ -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>

View 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>

View 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');
});

View File

@ -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>

View File

@ -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 %}

View File

@ -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');

View File

@ -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 %}

View File

@ -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">

View File

@ -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 %}"

View File

@ -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 %}

View File

@ -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>

View File

@ -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}}&nbsp;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 %}

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -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" %}

View File

@ -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
View 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

File diff suppressed because one or more lines are too long

View 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
View 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
View 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
View 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
View 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
View 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
View 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)