fix remote interview creation issue

This commit is contained in:
ismail 2025-12-04 15:02:43 +03:00
parent bfea43df8f
commit bb552cbd3f
28 changed files with 2069 additions and 477 deletions

6
.env
View File

@ -1,3 +1,3 @@
DB_NAME=haikal_db
DB_USER=faheed
DB_PASSWORD=Faheed@215
DB_NAME=norahuniversity
DB_USER=norahuniversity
DB_PASSWORD=norahuniversity

View File

@ -273,6 +273,8 @@ SOCIALACCOUNT_PROVIDERS = {
}
}
# Dynamic Zoom Configuration - will be loaded from database
# These are fallback values - actual values will be loaded from database at runtime
ZOOM_ACCOUNT_ID = "HoGikHXsQB2GNDC5Rvyw9A"
ZOOM_CLIENT_ID = "brC39920R8C8azfudUaQgA"
ZOOM_CLIENT_SECRET = "rvfhjlbID4ychXPOvZ2lYsoAC0B0Ny2L"
@ -292,6 +294,8 @@ CELERY_TASK_SERIALIZER = "json"
CELERY_RESULT_SERIALIZER = "json"
CELERY_TIMEZONE = "UTC"
# Dynamic LinkedIn Configuration - will be loaded from database
# These are fallback values - actual values will be loaded from database at runtime
LINKEDIN_CLIENT_ID = "867jwsiyem1504"
LINKEDIN_CLIENT_SECRET = "WPL_AP1.QNH5lYnfRSQpp0Qp.GO8Srw=="
LINKEDIN_REDIRECT_URI = "http://127.0.0.1:8000/jobs/linkedin/callback/"

View File

@ -6,7 +6,7 @@ from .models import (
JobPosting, Application, TrainingMaterial,
FormTemplate, FormStage, FormField, FormSubmission, FieldResponse,
SharedFormTemplate, Source, HiringAgency, IntegrationLog,BulkInterviewTemplate,JobPostingImage,Note,
AgencyAccessLink, AgencyJobAssignment,Interview,ScheduledInterview,Person
AgencyAccessLink, AgencyJobAssignment,Interview,ScheduledInterview, Settings,Person
)
from django.contrib.auth import get_user_model
@ -249,6 +249,30 @@ admin.site.register(ScheduledInterview)
# AgencyMessage admin removed - model has been deleted
@admin.register(Settings)
class SettingsAdmin(admin.ModelAdmin):
list_display = ['key', 'value_preview', 'created_at', 'updated_at']
list_filter = ['created_at']
search_fields = ['key', 'value']
readonly_fields = ['created_at', 'updated_at']
fieldsets = (
('Setting Information', {
'fields': ('key', 'value')
}),
('Timestamps', {
'fields': ('created_at', 'updated_at')
}),
)
save_on_top = True
def value_preview(self, obj):
"""Show a preview of the value (truncated for long values)"""
if len(obj.value) > 50:
return obj.value[:50] + '...'
return obj.value
value_preview.short_description = 'Value'
admin.site.register(JobPostingImage)
admin.site.register(Person)
# admin.site.register(User)

View File

@ -29,6 +29,7 @@ from .models import (
Person,
Document,
CustomUser,
Settings,
Interview
)
@ -2946,6 +2947,7 @@ class ScheduledInterviewUpdateStatusForm(forms.Form):
model = ScheduledInterview
fields = ['status']
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@ -2958,3 +2960,78 @@ class ScheduledInterviewUpdateStatusForm(forms.Form):
# Apply the filtered list back to the field
self.fields['status'].choices = filtered_choices
class SettingsForm(forms.ModelForm):
"""Form for creating and editing settings"""
class Meta:
model = Settings
fields = ['key', 'value']
widgets = {
'key': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Enter setting key',
'required': True
}),
'value': forms.Textarea(attrs={
'class': 'form-control',
'rows': 3,
'placeholder': 'Enter setting value',
'required': True
}),
}
labels = {
'key': _('Setting Key'),
'value': _('Setting Value'),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_method = 'post'
self.helper.form_class = 'g-3'
self.helper.layout = Layout(
Field('key', css_class='form-control'),
Field('value', css_class='form-control'),
Div(
Submit('submit', _('Save Setting'), css_class='btn btn-main-action'),
css_class='col-12 mt-4',
),
)
def clean_key(self):
"""Ensure key is unique and properly formatted"""
key = self.cleaned_data.get('key')
if key:
# Convert to uppercase for consistency
key = key.upper().strip()
# Check for duplicates excluding current instance if editing
instance = self.instance
if not instance.pk: # Creating new instance
if Settings.objects.filter(key=key).exists():
raise forms.ValidationError("A setting with this key already exists.")
else: # Editing existing instance
if Settings.objects.filter(key=key).exclude(pk=instance.pk).exists():
raise forms.ValidationError("A setting with this key already exists.")
# Validate key format (alphanumeric and underscores only)
import re
if not re.match(r'^[A-Z][A-Z0-9_]*$', key):
raise forms.ValidationError(
"Setting key must start with a letter and contain only uppercase letters, numbers, and underscores."
)
return key
def clean_value(self):
"""Validate setting value"""
value = self.cleaned_data.get('value')
if value:
value = value.strip()
# You can add specific validation based on key type here
# For now, just ensure it's not empty
if not value:
raise forms.ValidationError("Setting value cannot be empty.")
return value

View File

@ -4,21 +4,22 @@ import uuid
import requests
import logging
import time
from django.conf import settings
from urllib.parse import quote, urlencode
from .utils import get_linkedin_config,get_setting
logger = logging.getLogger(__name__)
# Define constants
LINKEDIN_API_VERSION = '2.0.0'
LINKEDIN_VERSION = '202409'
LINKEDIN_API_VERSION = get_setting('LINKEDIN_API_VERSION', '2.0.0')
LINKEDIN_VERSION = get_setting('LINKEDIN_VERSION', '202301')
class LinkedInService:
def __init__(self):
self.client_id = settings.LINKEDIN_CLIENT_ID
self.client_secret = settings.LINKEDIN_CLIENT_SECRET
self.redirect_uri = settings.LINKEDIN_REDIRECT_URI
config = get_linkedin_config()
self.client_id = config['LINKEDIN_CLIENT_ID']
self.client_secret = config['LINKEDIN_CLIENT_SECRET']
self.redirect_uri = config['LINKEDIN_REDIRECT_URI']
self.access_token = None
# Configuration for image processing wait time
self.ASSET_STATUS_TIMEOUT = 15

View File

@ -0,0 +1,19 @@
from django.core.management.base import BaseCommand
from recruitment.utils import initialize_default_settings
class Command(BaseCommand):
help = 'Initialize Zoom and LinkedIn settings in the database from current hardcoded values'
def handle(self, *args, **options):
self.stdout.write('Initializing settings in database...')
try:
initialize_default_settings()
self.stdout.write(
self.style.SUCCESS('Successfully initialized settings in database')
)
except Exception as e:
self.stdout.write(
self.style.ERROR(f'Error initializing settings: {e}')
)

View File

@ -0,0 +1,30 @@
# Generated by Django 5.2.6 on 2025-12-03 17:52
import django_extensions.db.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Settings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('key', models.CharField(help_text='Unique key for the setting', max_length=100, unique=True, verbose_name='Setting Key')),
('value', models.TextField(help_text='Value for the setting', verbose_name='Setting Value')),
],
options={
'verbose_name': 'Setting',
'verbose_name_plural': 'Settings',
'ordering': ['key'],
},
),
]

View File

@ -2739,3 +2739,24 @@ class Document(Base):
return ""
class Settings(Base):
"""Model to store key-value pair settings"""
key = models.CharField(
max_length=100,
unique=True,
verbose_name=_("Setting Key"),
help_text=_("Unique key for the setting"),
)
value = models.TextField(
verbose_name=_("Setting Value"),
help_text=_("Value for the setting"),
)
class Meta:
verbose_name = _("Setting")
verbose_name_plural = _("Settings")
ordering = ["key"]
def __str__(self):
return f"{self.key}: {self.value[:50]}{'...' if len(self.value) > 50 else ''}"

View File

@ -44,31 +44,74 @@ def format_job(sender, instance, created, **kwargs):
# hook='myapp.tasks.email_sent_callback' # Optional callback
)
else:
existing_schedule = Schedule.objects.filter(
func="recruitment.tasks.form_close",
args=f"[{instance.pk}]",
schedule_type=Schedule.ONCE,
# Enhanced reminder scheduling logic
if instance.status == "ACTIVE" and instance.application_deadline:
# Schedule 1-day reminder
one_day_schedule = Schedule.objects.filter(
name=f"one_day_reminder_{instance.pk}"
).first()
if instance.STATUS_CHOICES == "ACTIVE" and instance.application_deadline:
if not existing_schedule:
# Create a new schedule if one does not exist
one_day_before = instance.application_deadline - timedelta(days=1)
if not one_day_schedule:
schedule(
"recruitment.tasks.form_close",
"recruitment.tasks.send_one_day_reminder",
instance.pk,
schedule_type=Schedule.ONCE,
next_run=one_day_before,
repeats=-1,
name=f"one_day_reminder_{instance.pk}",
)
elif one_day_schedule.next_run != one_day_before:
one_day_schedule.next_run = one_day_before
one_day_schedule.save()
# Schedule 15-minute reminder
fifteen_min_schedule = Schedule.objects.filter(
name=f"fifteen_min_reminder_{instance.pk}"
).first()
fifteen_min_before = instance.application_deadline - timedelta(minutes=15)
if not fifteen_min_schedule:
schedule(
"recruitment.tasks.send_fifteen_minute_reminder",
instance.pk,
schedule_type=Schedule.ONCE,
next_run=fifteen_min_before,
repeats=-1,
name=f"fifteen_min_reminder_{instance.pk}",
)
elif fifteen_min_schedule.next_run != fifteen_min_before:
fifteen_min_schedule.next_run = fifteen_min_before
fifteen_min_schedule.save()
# Schedule job closing notification (enhanced form_close)
closing_schedule = Schedule.objects.filter(
name=f"job_closing_{instance.pk}"
).first()
if not closing_schedule:
schedule(
"recruitment.tasks.send_job_closed_notification",
instance.pk,
schedule_type=Schedule.ONCE,
next_run=instance.application_deadline,
repeats=-1, # Ensure the schedule is deleted after it runs
name=f"job_closing_{instance.pk}", # Add a name for easier lookup
repeats=-1,
name=f"job_closing_{instance.pk}",
)
elif existing_schedule.next_run != instance.application_deadline:
# Update an existing schedule's run time
existing_schedule.next_run = instance.application_deadline
existing_schedule.save()
elif existing_schedule:
# If the instance is no longer active, delete the scheduled task
existing_schedule.delete()
elif closing_schedule.next_run != instance.application_deadline:
closing_schedule.next_run = instance.application_deadline
closing_schedule.save()
else:
# Clean up all reminder schedules if job is no longer active
reminder_schedules = Schedule.objects.filter(
name__in=[f"one_day_reminder_{instance.pk}",
f"fifteen_min_reminder_{instance.pk}",
f"job_closing_{instance.pk}"]
)
if reminder_schedules.exists():
reminder_schedules.delete()
logger.info(f"Cleaned up reminder schedules for job {instance.pk}")
# @receiver(post_save, sender=JobPosting)

View File

@ -14,6 +14,8 @@ from . models import JobPosting
from django.utils import timezone
from . models import BulkInterviewTemplate,Interview,Message,ScheduledInterview
from django.contrib.auth import get_user_model
from .utils import get_setting
User = get_user_model()
# Add python-docx import for Word document processing
try:
@ -26,8 +28,11 @@ except ImportError:
logger = logging.getLogger(__name__)
OPENROUTER_API_KEY ='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'
OPENROUTER_MODEL = 'qwen/qwen-2.5-72b-instruct'
OPENROUTER_API_URL = get_setting('OPENROUTER_API_URL')
OPENROUTER_API_KEY = get_setting('OPENROUTER_API_KEY')
OPENROUTER_MODEL = get_setting('OPENROUTER_MODEL')
# OPENROUTER_API_KEY ='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'
# OPENROUTER_MODEL = 'qwen/qwen-2.5-72b-instruct'
# OPENROUTER_MODEL = 'qwen/qwen-2.5-7b-instruct'
# OPENROUTER_MODEL = 'openai/gpt-oss-20b'
@ -193,7 +198,7 @@ def format_job_description(pk):
def ai_handler(prompt):
print("model call")
response = requests.post(
url="https://openrouter.ai/api/v1/chat/completions",
url=OPENROUTER_API_URL,
headers={
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
"Content-Type": "application/json",
@ -668,54 +673,25 @@ def handle_resume_parsing_and_scoring(pk: int):
from django.utils import timezone
def create_interview_and_meeting(
application_id,
job_id,
schedule_id,
slot_date,
slot_time,
duration
):
def create_interview_and_meeting(schedule_id):
"""
Synchronous task for a single interview slot, dispatched by django-q.
"""
try:
application = Application.objects.get(pk=application_id)
job = JobPosting.objects.get(pk=job_id)
schedule = BulkInterviewTemplate.objects.get(pk=schedule_id)
schedule = ScheduledInterview.objects.get(pk=schedule_id)
interview = schedule.interview
interview_datetime = timezone.make_aware(datetime.combine(slot_date, slot_time))
meeting_topic = schedule.topic
result = create_zoom_meeting(meeting_topic, interview_datetime, duration)
result = create_zoom_meeting(interview.topic, interview.start_time, interview.duration)
if result["status"] == "success":
interview = Interview.objects.create(
topic=meeting_topic,
start_time=interview_datetime,
duration=duration,
meeting_id=result["meeting_details"]["meeting_id"],
details_url=result["meeting_details"]["join_url"],
zoom_gateway_response=result["zoom_gateway_response"],
host_email=result["meeting_details"]["host_email"],
password=result["meeting_details"]["password"],
location_type="Remote"
)
schedule = ScheduledInterview.objects.create(
application=application,
job=job,
schedule=schedule,
interview_date=slot_date,
interview_time=slot_time,
interview=interview
)
schedule.interview = interview
schedule.status = "scheduled"
schedule.save()
interview.meeting_id = result["meeting_details"]["meeting_id"]
interview.details_url = result["meeting_details"]["join_url"]
interview.zoom_gateway_response = result["zoom_gateway_response"]
interview.host_email = result["meeting_details"]["host_email"]
interview.password = result["meeting_details"]["password"]
interview.save()
logger.info(f"Successfully scheduled interview for {Application.name}")
return True # Task succeeded
return True
else:
# Handle Zoom API failure (e.g., log it or notify administrator)
logger.error(f"Zoom API failed for {Application.name}: {result['message']}")
@ -1122,3 +1098,202 @@ def generate_and_save_cv_zip(job_posting_id):
job.save()
return f"Successfully created zip for Job ID {job.slug} {job_posting_id}"
def send_one_day_reminder(job_id):
"""
Send email reminder 1 day before job application deadline.
"""
try:
job = JobPosting.objects.get(pk=job_id)
# Only send if job is still active
if job.status != 'ACTIVE':
logger.info(f"Job {job_id} is no longer active, skipping 1-day reminder")
return
# Get application count
application_count = Application.objects.filter(job=job).count()
# Determine recipients
recipients = []
if job.assigned_to:
recipients.append(job.assigned_to.email)
# Add admin users as fallback or additional recipients
admin_users = User.objects.filter(is_staff=True)
if not recipients: # If no assigned user, send to all admins
recipients = [admin.email for admin in admin_users]
if not recipients:
logger.warning(f"No recipients found for job {job_id} 1-day reminder")
return
# Create email content
subject = f"Reminder: Job '{job.title}' closes tomorrow"
html_message = f"""
<html>
<body>
<h2>Job Closing Reminder</h2>
<p><strong>Job Title:</strong> {job.title}</p>
<p><strong>Application Deadline:</strong> {job.application_deadline.strftime('%B %d, %Y')}</p>
<p><strong>Current Applications:</strong> {application_count}</p>
<p><strong>Status:</strong> {job.get_status_display()}</p>
<p>This job posting will close <strong>tomorrow</strong>. Please review any pending applications before the deadline.</p>
<p><a href="/recruitment/jobs/{job.pk}/" style="background-color: #007cba; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;">View Job Details</a></p>
<hr>
<p><small>This is an automated reminder from the KAAUH Recruitment System.</small></p>
</body>
</html>
"""
# Send email to each recipient
for recipient_email in recipients:
_task_send_individual_email(subject, html_message, recipient_email, None, None, None)
logger.info(f"Sent 1-day reminder for job {job_id} to {len(recipients)} recipients")
except JobPosting.DoesNotExist:
logger.error(f"Job {job_id} not found for 1-day reminder")
except Exception as e:
logger.error(f"Error sending 1-day reminder for job {job_id}: {str(e)}")
def send_fifteen_minute_reminder(job_id):
"""
Send final email reminder 15 minutes before job application deadline.
"""
try:
job = JobPosting.objects.get(pk=job_id)
# Only send if job is still active
if job.status != 'ACTIVE':
logger.info(f"Job {job_id} is no longer active, skipping 15-minute reminder")
return
# Get application count
application_count = Application.objects.filter(job=job).count()
# Determine recipients
recipients = []
if job.assigned_to:
recipients.append(job.assigned_to.email)
# Add admin users as fallback or additional recipients
admin_users = User.objects.filter(is_staff=True)
if not recipients: # If no assigned user, send to all admins
recipients = [admin.email for admin in admin_users]
if not recipients:
logger.warning(f"No recipients found for job {job_id} 15-minute reminder")
return
# Create email content
subject = f"FINAL REMINDER: Job '{job.title}' closes in 15 minutes"
html_message = f"""
<html>
<body>
<h2 style="color: #d63384;"> FINAL REMINDER</h2>
<p><strong>Job Title:</strong> {job.title}</p>
<p><strong>Application Deadline:</strong> {job.application_deadline.strftime('%B %d, %Y at %I:%M %p')}</p>
<p><strong>Current Applications:</strong> {application_count}</p>
<p><strong>Status:</strong> {job.get_status_display()}</p>
<p style="color: #d63384; font-weight: bold;">This job posting will close in <strong>15 minutes</strong>. This is your final reminder to review any pending applications.</p>
<p><a href="/recruitment/jobs/{job.pk}/" style="background-color: #dc3545; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;">View Job Details Now</a></p>
<hr>
<p><small>This is an automated final reminder from the KAAUH Recruitment System.</small></p>
</body>
</html>
"""
# Send email to each recipient
for recipient_email in recipients:
_task_send_individual_email(subject, html_message, recipient_email, None, None, None)
logger.info(f"Sent 15-minute reminder for job {job_id} to {len(recipients)} recipients")
except JobPosting.DoesNotExist:
logger.error(f"Job {job_id} not found for 15-minute reminder")
except Exception as e:
logger.error(f"Error sending 15-minute reminder for job {job_id}: {str(e)}")
def send_job_closed_notification(job_id):
"""
Send notification when job has closed and update job status.
"""
try:
job = JobPosting.objects.get(pk=job_id)
# Only proceed if job is currently active
if job.status != 'ACTIVE':
logger.info(f"Job {job_id} is already not active, skipping closed notification")
return
# Get final application count
application_count = Application.objects.filter(job=job).count()
# Update job status to closed
job.status = 'CLOSED'
job.save(update_fields=['status'])
# Also close the form template
if job.template_form:
job.template_form.is_active = False
job.template_form.save(update_fields=['is_active'])
# Determine recipients
recipients = []
if job.assigned_to:
recipients.append(job.assigned_to.email)
# Add admin users as fallback or additional recipients
admin_users = User.objects.filter(is_staff=True)
if not recipients: # If no assigned user, send to all admins
recipients = [admin.email for admin in admin_users]
if not recipients:
logger.warning(f"No recipients found for job {job_id} closed notification")
return
# Create email content
subject = f"Job '{job.title}' has closed - {application_count} applications received"
html_message = f"""
<html>
<body>
<h2>Job Closed Notification</h2>
<p><strong>Job Title:</strong> {job.title}</p>
<p><strong>Application Deadline:</strong> {job.application_deadline.strftime('%B %d, %Y at %I:%M %p')}</p>
<p><strong>Total Applications Received:</strong> <strong style="color: #28a745;">{application_count}</strong></p>
<p><strong>Status:</strong> {job.get_status_display()}</p>
<p>The job posting has been automatically closed and is no longer accepting applications.</p>
<p><a href="/recruitment/jobs/{job.pk}/" style="background-color: #6c757d; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;">View Job Details</a></p>
<p><a href="/recruitment/applications/?job={job.pk}" style="background-color: #007cba; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;">View Applications</a></p>
<hr>
<p><small>This is an automated notification from the KAAUH Recruitment System.</small></p>
</body>
</html>
"""
# Send email to each recipient
for recipient_email in recipients:
_task_send_individual_email(subject, html_message, recipient_email, None, None, None)
logger.info(f"Sent job closed notification for job {job_id} to {len(recipients)} recipients")
except JobPosting.DoesNotExist:
logger.error(f"Job {job_id} not found for closed notification")
except Exception as e:
logger.error(f"Error sending job closed notification for job {job_id}: {str(e)}")

View File

@ -264,11 +264,11 @@ urlpatterns = [
# path('api/templates/save/', views.save_form_template, name='save_form_template'),
# path('api/templates/<slug:template_slug>/', views.load_form_template, name='load_form_template'),
# path('api/templates/<slug:template_slug>/delete/', views.delete_form_template, name='delete_form_template'),
# path(
# "jobs/<slug:slug>/calendar/",
# views.interview_calendar_view,
# name="interview_calendar",
# ),
path(
"jobs/<slug:slug>/calendar/",
views.interview_calendar_view,
name="interview_calendar",
),
# path(
# "jobs/<slug:slug>/calendar/interview/<int:interview_id>/",
# views.interview_detail_view,
@ -283,8 +283,13 @@ urlpatterns = [
name="user_profile_image_update",
),
path("easy_logs/", views.easy_logs, name="easy_logs"),
path('settings/',views.settings,name="settings"),
path("settings/admin/", views.admin_settings, name="admin_settings"),
path("settings/", views.admin_settings, name="admin_settings"),
path("settings/list/", views.settings_list, name="settings_list"),
path("settings/create/", views.settings_create, name="settings_create"),
path("settings/<int:pk>/", views.settings_detail, name="settings_detail"),
path("settings/<int:pk>/update/", views.settings_update, name="settings_update"),
path("settings/<int:pk>/delete/", views.settings_delete, name="settings_delete"),
path("settings/<int:pk>/toggle/", views.settings_toggle_status, name="settings_toggle_status"),
path("staff/create", views.create_staff_user, name="create_staff_user"),
path(
"set_staff_password/<int:pk>/",

View File

@ -1,7 +1,6 @@
# import os
# import fitz # PyMuPDF
# import spacy
# import requests
"""
Utility functions for recruitment app
"""
from recruitment import models
from django.conf import settings
from datetime import datetime, timedelta, time, date
@ -9,41 +8,282 @@ from django.utils import timezone
from .models import ScheduledInterview
from django.template.loader import render_to_string
from django.core.mail import send_mail
import random
# nlp = spacy.load("en_core_web_sm")
# def extract_text_from_pdf(pdf_path):
# text = ""
# with fitz.open(pdf_path) as doc:
# for page in doc:
# text += page.get_text()
# return text
# def extract_summary_from_pdf(pdf_path):
# if not os.path.exists(pdf_path):
# return {'error': 'File not found'}
# text = extract_text_from_pdf(pdf_path)
# doc = nlp(text)
# summary = {
# 'name': doc.ents[0].text if doc.ents else '',
# 'skills': [chunk.text for chunk in doc.noun_chunks if len(chunk.text.split()) > 1],
# 'summary': text[:500]
# }
# return summary
import requests
from PyPDF2 import PdfReader
import os
import json
import logging
import requests
from PyPDF2 import PdfReader
from django.conf import settings
from .models import Settings, Application
logger = logging.getLogger(__name__)
OPENROUTER_API_KEY ='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'
OPENROUTER_MODEL = 'qwen/qwen-2.5-72b-instruct:free'
def get_setting(key, default=None):
"""
Get a setting value from the database, with fallback to environment variables and default
Args:
key (str): The setting key to retrieve
default: Default value if not found in database or environment
Returns:
The setting value from database, environment variable, or default
"""
try:
# First try to get from database
setting = Settings.objects.get(key=key)
return setting.value
except Settings.DoesNotExist:
# Fall back to environment variable
env_value = os.getenv(key)
if env_value is not None:
return env_value
# Finally return the default
return default
except Exception:
# In case of any database error, fall back to environment or default
env_value = os.getenv(key)
if env_value is not None:
return env_value
return default
def set_setting(key, value):
"""
Set a setting value in the database
Args:
key (str): The setting key
value: The setting value
Returns:
Settings: The created or updated setting object
"""
setting, created = Settings.objects.update_or_create(
key=key,
defaults={'value': str(value)}
)
return setting
def get_zoom_config():
"""
Get all Zoom configuration settings
Returns:
dict: Dictionary containing all Zoom settings
"""
return {
'ZOOM_ACCOUNT_ID': get_setting('ZOOM_ACCOUNT_ID'),
'ZOOM_CLIENT_ID': get_setting('ZOOM_CLIENT_ID'),
'ZOOM_CLIENT_SECRET': get_setting('ZOOM_CLIENT_SECRET'),
'ZOOM_WEBHOOK_API_KEY': get_setting('ZOOM_WEBHOOK_API_KEY'),
'SECRET_TOKEN': get_setting('SECRET_TOKEN'),
}
def get_linkedin_config():
"""
Get all LinkedIn configuration settings
Returns:
dict: Dictionary containing all LinkedIn settings
"""
return {
'LINKEDIN_CLIENT_ID': get_setting('LINKEDIN_CLIENT_ID'),
'LINKEDIN_CLIENT_SECRET': get_setting('LINKEDIN_CLIENT_SECRET'),
'LINKEDIN_REDIRECT_URI': get_setting('LINKEDIN_REDIRECT_URI'),
}
def get_applications_from_request(request):
"""
Extract application IDs from request and return Application objects
"""
application_ids = request.POST.getlist("candidate_ids")
if application_ids:
return Application.objects.filter(id__in=application_ids)
return Application.objects.none()
def schedule_interviews(schedule, applications):
"""
Schedule interviews for multiple applications based on a schedule template
"""
from .models import ScheduledInterview
from datetime import datetime, timedelta
scheduled_interviews = []
available_slots = get_available_time_slots(schedule)
for i, application in enumerate(applications):
if i < len(available_slots):
slot = available_slots[i]
interview = ScheduledInterview.objects.create(
application=application,
job=schedule.job,
interview_date=slot['date'],
interview_time=slot['time'],
status='scheduled'
)
scheduled_interviews.append(interview)
return scheduled_interviews
def get_available_time_slots(schedule):
"""
Calculate available time slots for interviews based on schedule
"""
from datetime import datetime, timedelta, time
import calendar
slots = []
current_date = schedule.start_date
while current_date <= schedule.end_date:
# Check if current date is a working day
weekday = current_date.weekday()
if str(weekday) in schedule.working_days:
# Calculate slots for this day
day_slots = _calculate_day_slots(schedule, current_date)
slots.extend(day_slots)
current_date += timedelta(days=1)
return slots
def _calculate_day_slots(schedule, date):
"""
Calculate available slots for a specific day
"""
from datetime import datetime, timedelta, time
slots = []
current_time = schedule.start_time
end_time = schedule.end_time
# Convert to datetime for easier calculation
current_datetime = datetime.combine(date, current_time)
end_datetime = datetime.combine(date, end_time)
# Calculate break times
break_start = None
break_end = None
if schedule.break_start_time and schedule.break_end_time:
break_start = datetime.combine(date, schedule.break_start_time)
break_end = datetime.combine(date, schedule.break_end_time)
while current_datetime + timedelta(minutes=schedule.interview_duration) <= end_datetime:
# Skip break time
if break_start and break_end:
if break_start <= current_datetime < break_end:
current_datetime = break_end
continue
slots.append({
'date': date,
'time': current_datetime.time()
})
# Move to next slot
current_datetime += timedelta(minutes=schedule.interview_duration + schedule.buffer_time)
return slots
def json_to_markdown_table(data):
"""
Convert JSON data to markdown table format
"""
if not data:
return ""
if isinstance(data, list):
if not data:
return ""
# Get headers from first item
first_item = data[0]
if isinstance(first_item, dict):
headers = list(first_item.keys())
rows = []
for item in data:
row = []
for header in headers:
value = item.get(header, '')
if isinstance(value, (dict, list)):
value = str(value)
row.append(str(value))
rows.append(row)
else:
# Simple list
headers = ['Value']
rows = [[str(item)] for item in data]
elif isinstance(data, dict):
headers = ['Key', 'Value']
rows = []
for key, value in data.items():
if isinstance(value, (dict, list)):
value = str(value)
rows.append([str(key), str(value)])
else:
# Single value
return str(data)
# Build markdown table
if not headers or not rows:
return str(data)
# Header row
table = "| " + " | ".join(headers) + " |\n"
# Separator row
table += "| " + " | ".join(["---"] * len(headers)) + " |\n"
# Data rows
for row in rows:
# Escape pipe characters in cells
escaped_row = [cell.replace("|", "\\|") for cell in row]
table += "| " + " | ".join(escaped_row) + " |\n"
return table
def initialize_default_settings():
"""
Initialize default settings in the database from current hardcoded values
This should be run once to migrate existing settings
"""
# Zoom settings
zoom_settings = {
'ZOOM_ACCOUNT_ID': getattr(settings, 'ZOOM_ACCOUNT_ID', ''),
'ZOOM_CLIENT_ID': getattr(settings, 'ZOOM_CLIENT_ID', ''),
'ZOOM_CLIENT_SECRET': getattr(settings, 'ZOOM_CLIENT_SECRET', ''),
'ZOOM_WEBHOOK_API_KEY': getattr(settings, 'ZOOM_WEBHOOK_API_KEY', ''),
'SECRET_TOKEN': getattr(settings, 'SECRET_TOKEN', ''),
}
# LinkedIn settings
linkedin_settings = {
'LINKEDIN_CLIENT_ID': getattr(settings, 'LINKEDIN_CLIENT_ID', ''),
'LINKEDIN_CLIENT_SECRET': getattr(settings, 'LINKEDIN_CLIENT_SECRET', ''),
'LINKEDIN_REDIRECT_URI': getattr(settings, 'LINKEDIN_REDIRECT_URI', ''),
}
# Create settings if they don't exist
all_settings = {**zoom_settings, **linkedin_settings}
for key, value in all_settings.items():
if value: # Only set if value exists
set_setting(key, value)
#####################################
if not OPENROUTER_API_KEY:
logger.warning("OPENROUTER_API_KEY not set. Resume scoring will be skipped.")
def extract_text_from_pdf(file_path):
print("text extraction")
@ -60,8 +300,12 @@ def extract_text_from_pdf(file_path):
def score_resume_with_openrouter(prompt):
print("model call")
OPENROUTER_API_URL = get_setting('OPENROUTER_API_URL')
OPENROUTER_API_KEY = get_setting('OPENROUTER_API_KEY')
OPENROUTER_MODEL = get_setting('OPENROUTER_MODEL')
response = requests.post(
url="https://openrouter.ai/api/v1/chat/completions",
url=OPENROUTER_API_URL,
headers={
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
"Content-Type": "application/json",
@ -75,7 +319,6 @@ def score_resume_with_openrouter(prompt):
# print(response.status_code)
# print(response.json())
res = {}
print(response.status_code)
if response.status_code == 200:
res = response.json()
content = res["choices"][0]['message']['content']
@ -123,21 +366,22 @@ def dashboard_callback(request, context):
def get_access_token():
"""Obtain an access token using server-to-server OAuth."""
client_id = settings.ZOOM_CLIENT_ID
client_secret = settings.ZOOM_CLIENT_SECRET
ZOOM_ACCOUNT_ID = get_setting("ZOOM_ACCOUNT_ID")
ZOOM_CLIENT_ID = get_setting("ZOOM_CLIENT_ID")
ZOOM_CLIENT_SECRET = get_setting("ZOOM_CLIENT_SECRET")
ZOOM_AUTH_URL = get_setting("ZOOM_AUTH_URL")
auth_url = "https://zoom.us/oauth/token"
headers = {
"Content-Type": "application/x-www-form-urlencoded",
}
data = {
"grant_type": "account_credentials",
"account_id": settings.ZOOM_ACCOUNT_ID,
"account_id": ZOOM_ACCOUNT_ID,
}
auth = (client_id, client_secret)
auth = (ZOOM_CLIENT_ID, ZOOM_CLIENT_SECRET)
response = requests.post(auth_url, headers=headers, data=data, auth=auth)
response = requests.post(ZOOM_AUTH_URL, headers=headers, data=data, auth=auth)
if response.status_code == 200:
return response.json().get("access_token")
@ -181,8 +425,9 @@ def create_zoom_meeting(topic, start_time, duration):
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json"
}
ZOOM_MEETING_URL = get_setting('ZOOM_MEETING_URL')
response = requests.post(
"https://api.zoom.us/v2/users/me/meetings",
ZOOM_MEETING_URL,
headers=headers,
json=meeting_details
)
@ -585,19 +830,15 @@ def get_applications_from_request(request):
def update_meeting(instance, updated_data):
result = update_zoom_meeting(instance.meeting_id, updated_data)
if result["status"] == "success":
# Fetch the latest details from Zoom after successful update
details_result = get_zoom_meeting_details(instance.meeting_id)
if details_result["status"] == "success":
zoom_details = details_result["meeting_details"]
# Update instance with fetched details
instance.topic = zoom_details.get("topic", instance.topic)
instance.duration = zoom_details.get("duration", instance.duration)
# instance.details_url = zoom_details.get("join_url", instance.details_url)
instance.details_url = zoom_details.get("join_url", instance.details_url)
instance.password = zoom_details.get("password", instance.password)
# Corrected status assignment: instance.status, not instance.password
instance.status = zoom_details.get("status")
instance.zoom_gateway_response = details_result.get("meeting_details") # Store full response

View File

@ -40,6 +40,7 @@ from .forms import (
RemoteInterviewForm,
OnsiteInterviewForm,
BulkInterviewTemplateForm,
SettingsForm,
InterviewCancelForm
)
from .utils import generate_random_password
@ -107,15 +108,16 @@ from django.views.generic import (
DeleteView,
)
from .utils import (
create_zoom_meeting,
delete_zoom_meeting,
get_applications_from_request,
update_meeting,
update_zoom_meeting,
get_zoom_meeting_details,
schedule_interviews,
get_available_time_slots,
)
from .zoom_api import (
create_zoom_meeting,
delete_zoom_meeting,
update_zoom_meeting,
get_zoom_meeting_details,
)
from django.views.decorators.csrf import ensure_csrf_cookie
from django.views.decorators.http import require_POST
from .models import (
@ -141,7 +143,8 @@ from .models import (
Message,
Document,
Interview,
BulkInterviewTemplate
BulkInterviewTemplate,
Settings
)
@ -2103,7 +2106,8 @@ def applications_document_review_view(request, slug):
@require_POST
@staff_user_required
def reschedule_meeting_for_application(request, slug):
schedule_interview = get_object_or_404(ScheduledInterview, slug=slug)
from .utils import update_meeting
schedule = get_object_or_404(ScheduledInterview, slug=slug)
if request.method == "POST":
form = ScheduledInterviewForm(request.POST)
if form.is_valid():
@ -2115,7 +2119,7 @@ def reschedule_meeting_for_application(request, slug):
"start_time": start_time.isoformat() + "Z",
"duration": duration,
}
result = update_meeting(schedule_interview.interview, updated_data)
result = update_meeting(schedule.interview, updated_data)
if result["status"] == "success":
messages.success(request, result["message"])
@ -2124,7 +2128,7 @@ def reschedule_meeting_for_application(request, slug):
else:
print(form.errors)
messages.error(request, "Invalid data submitted.")
return redirect("interview_detail", slug=schedule_interview.slug)
return redirect("interview_detail", slug=schedule.slug)
# context = {"job": job, "application": application, "meeting": meeting, "form": form}
# return render(request, "meetings/reschedule_meeting.html", context)
@ -4746,6 +4750,7 @@ def message_list(request):
status_filter = request.GET.get("status", "")
message_type_filter = request.GET.get("type", "")
search_query = request.GET.get("q", "")
job_filter = request.GET.get("job_filter", "")
# Base queryset - get messages where user is either sender or recipient
message_list = (
@ -4754,16 +4759,20 @@ def message_list(request):
.order_by("-created_at")
)
jobs = JobPosting.objects.all()
# Apply filters
if status_filter:
if status_filter == "read":
message_list = message_list.filter(is_read=True)
elif status_filter == "unread":
message_list = message_list.filter(is_read=False)
if message_type_filter:
message_list = message_list.filter(message_type=message_type_filter)
if request.user.user_type == "staff" and job_filter:
job = get_object_or_404(JobPosting, pk=job_filter)
message_list = message_list.filter(job=job)
if search_query:
message_list = message_list.filter(
Q(subject__icontains=search_query) | Q(content__icontains=search_query)
@ -4785,6 +4794,8 @@ def message_list(request):
"status_filter": status_filter,
"type_filter": message_type_filter,
"search_query": search_query,
"job_filter": job_filter,
"jobs": jobs,
}
if request.user.user_type != "staff":
return render(request, "messages/application_message_list.html", context)
@ -4813,7 +4824,7 @@ def message_detail(request, message_id):
"message": message,
}
if request.user.user_type != "staff":
return render(request, "messages/candidate_message_detail.html", context)
return render(request, "messages/application_message_detail.html", context)
return render(request, "messages/message_detail.html", context)
@ -5400,15 +5411,15 @@ def interview_create_remote(request, application_slug):
if form.is_valid():
try:
with transaction.atomic():
# Create ScheduledInterview record
schedule = ScheduledInterview.objects.create(application=application,job=application.job,interview_date=form.cleaned_data["interview_date"],interview_time=form.cleaned_data["interview_time"])
async_task(
"recruitment.tasks.create_interview_and_meeting",
application.pk, application.job.pk, schedule.pk, schedule.interview_date,schedule.interview_time, form.cleaned_data['duration']
)
start_time = timezone.make_aware(datetime.combine(schedule.interview_date, schedule.interview_time))
interview = Interview.objects.create(topic=form.cleaned_data["topic"],location_type="Remote",start_time=start_time,duration=form.cleaned_data['duration'])
schedule.interview = interview
schedule.save()
async_task("recruitment.tasks.create_interview_and_meeting",schedule.pk)
messages.success(request, f"Remote interview scheduled for {application.name}")
return redirect('interview_detail', slug=schedule.slug)
return redirect('applications_interview_view', slug=application_slug)
except Exception as e:
messages.error(request, f"Error creating remote interview: {str(e)}")
@ -5443,7 +5454,7 @@ def interview_create_onsite(request, application_slug):
schedule = ScheduledInterview.objects.create(application=application,job=application.job,interview=interview,interview_date=form.cleaned_data["interview_date"],interview_time=form.cleaned_data["interview_time"])
messages.success(request, f"Onsite interview scheduled for {application.name}")
return redirect('interview_detail', slug=schedule.slug)
return redirect('applications_interview_view', slug=application_slug)
except Exception as e:
messages.error(request, f"Error creating onsite interview: {str(e)}")
@ -5723,7 +5734,7 @@ def compose_application_email(request, job_slug):
subject=subject,
content=message,
job=job,
message_type='email',
message_type='job_related',
is_email_sent=True,
email_address=application.person.email if application.person.email else application.email
)
@ -6084,13 +6095,16 @@ def interview_detail(request, slug):
"""View details of a specific interview"""
from .forms import ScheduledInterviewUpdateStatusForm
interview = get_object_or_404(ScheduledInterview, slug=slug)
schedule = get_object_or_404(ScheduledInterview, slug=slug)
interview = schedule.interview
reschedule_form = ScheduledInterviewForm()
reschedule_form.initial['topic'] = interview.interview.topic
meeting=interview.interview
if interview:
reschedule_form.initial['topic'] = interview.topic
reschedule_form.initial['start_time'] = interview.start_time
reschedule_form.initial['duration'] = interview.duration
context = {
'schedule': schedule,
'interview': interview,
'reschedule_form':reschedule_form,
'interview_status_form':ScheduledInterviewUpdateStatusForm(),
@ -6830,30 +6844,136 @@ def interview_add_note(request, slug):
form.initial['interview'] = interview
form.fields['interview'].widget = HiddenInput()
form.fields['application'].widget = HiddenInput()
form.fields['author'].widget = HiddenInput()
form.initial['author'] = request.user
form.fields['author'].widget = HiddenInput()
return render(request, 'recruitment/partials/note_form.html', {'form': form,'instance':interview,'notes':interview.notes.all()})
# @login_required
# @staff_user_required
# def archieve_application_bank_view(request):
# """View to list all applications in the application bank"""
# applications = Application.objects.filter(stage="Applied").se
# lect_related('person', 'job_posting').all().order_by('-created_at')
# Settings CRUD Views
@staff_user_required
def settings_list(request):
"""List all settings with search and pagination"""
search_query = request.GET.get('q', '')
settings = Settings.objects.all()
# paginator = Paginator(applications, 20) # Show 20 applications per page
# page_number = request.GET.get('page')
# page_obj = paginator.get_page(page_number)
if search_query:
settings = settings.filter(key__icontains=search_query)
# context = {
# 'page_obj': page_obj,
# 'applications': applications,
# }
# return render(request, 'jobs/archieve_applications_bank.html', context)
# Order by key alphabetically
settings = settings.order_by('key')
# Pagination
paginator = Paginator(settings, 20) # Show 20 settings per page
page_number = request.GET.get('page')
page_obj = paginator.get_page(page_number)
context = {
'page_obj': page_obj,
'search_query': search_query,
'total_settings': settings.count(),
}
return render(request, 'recruitment/settings_list.html', context)
@staff_user_required
def settings_create(request):
"""Create a new setting"""
if request.method == 'POST':
form = SettingsForm(request.POST)
if form.is_valid():
setting = form.save()
messages.success(request, f'Setting "{setting.key}" created successfully!')
return redirect('settings_list')
else:
messages.error(request, 'Please correct the errors below.')
else:
form = SettingsForm()
# In your views.py (or where the application views are defined)
context = {
'form': form,
'title': 'Create New Setting',
'button_text': 'Create Setting',
}
return render(request, 'recruitment/settings_form.html', context)
@staff_user_required
def settings_detail(request, pk):
"""View details of a specific setting"""
setting = get_object_or_404(Settings, pk=pk)
context = {
'setting': setting,
}
return render(request, 'recruitment/settings_detail.html', context)
@staff_user_required
def settings_update(request, pk):
"""Update an existing setting"""
setting = get_object_or_404(Settings, pk=pk)
if request.method == 'POST':
form = SettingsForm(request.POST, instance=setting)
if form.is_valid():
form.save()
messages.success(request, f'Setting "{setting.key}" updated successfully!')
return redirect('settings_detail', pk=setting.pk)
else:
messages.error(request, 'Please correct the errors below.')
else:
form = SettingsForm(instance=setting)
context = {
'form': form,
'setting': setting,
'title': f'Edit Setting: {setting.key}',
'button_text': 'Update Setting',
}
return render(request, 'recruitment/settings_form.html', context)
@staff_user_required
def settings_delete(request, pk):
"""Delete a setting"""
setting = get_object_or_404(Settings, pk=pk)
if request.method == 'POST':
setting_name = setting.key
setting.delete()
messages.success(request, f'Setting "{setting_name}" deleted successfully!')
return redirect('settings_list')
context = {
'setting': setting,
'title': 'Delete Setting',
'message': f'Are you sure you want to delete the setting "{setting_name}"?',
'cancel_url': reverse('settings_detail', kwargs={'pk': setting.pk}),
}
return render(request, 'recruitment/settings_confirm_delete.html', context)
@staff_user_required
def settings_toggle_status(request, pk):
"""Toggle active status of a setting"""
setting = get_object_or_404(Settings, pk=pk)
if request.method == 'POST':
setting.is_active = not setting.is_active
setting.save(update_fields=['is_active'])
status_text = 'activated' if setting.is_active else 'deactivated'
messages.success(request, f'Setting "{setting.key}" {status_text} successfully!')
return redirect('settings_detail', pk=setting.pk)
# For GET requests or HTMX, return JSON response
if request.headers.get('HX-Request'):
return JsonResponse({
'success': True,
'is_active': setting.is_active,
'message': f'Setting "{setting.key}" {status_text} successfully!'
})
return redirect('settings_detail', pk=setting.pk)

View File

@ -1,19 +1,22 @@
import requests
import jwt
import time
from .utils import get_zoom_config
ZOOM_API_KEY = 'your_zoom_api_key'
ZOOM_API_SECRET = 'your_zoom_api_secret'
def generate_zoom_jwt():
"""Generate JWT token using dynamic Zoom configuration"""
config = get_zoom_config()
payload = {
'iss': ZOOM_API_KEY,
'iss': config['ZOOM_ACCOUNT_ID'],
'exp': time.time() + 3600
}
token = jwt.encode(payload, ZOOM_API_SECRET, algorithm='HS256')
token = jwt.encode(payload, config['ZOOM_CLIENT_SECRET'], algorithm='HS256')
return token
def create_zoom_meeting(topic, start_time, duration, host_email):
"""Create a Zoom meeting using dynamic configuration"""
jwt_token = generate_zoom_jwt()
headers = {
'Authorization': f'Bearer {jwt_token}',
@ -29,3 +32,36 @@ def create_zoom_meeting(topic, start_time, duration, host_email):
}
url = f"https://api.zoom.us/v2/users/{host_email}/meetings"
return requests.post(url, json=data, headers=headers)
def update_zoom_meeting(meeting_id, updated_data):
"""Update an existing Zoom meeting"""
jwt_token = generate_zoom_jwt()
headers = {
'Authorization': f'Bearer {jwt_token}',
'Content-Type': 'application/json'
}
url = f"https://api.zoom.us/v2/meetings/{meeting_id}"
return requests.patch(url, json=updated_data, headers=headers)
def delete_zoom_meeting(meeting_id):
"""Delete a Zoom meeting"""
jwt_token = generate_zoom_jwt()
headers = {
'Authorization': f'Bearer {jwt_token}',
'Content-Type': 'application/json'
}
url = f"https://api.zoom.us/v2/meetings/{meeting_id}"
return requests.delete(url, headers=headers)
def get_zoom_meeting_details(meeting_id):
"""Get details of a Zoom meeting"""
jwt_token = generate_zoom_jwt()
headers = {
'Authorization': f'Bearer {jwt_token}',
'Content-Type': 'application/json'
}
url = f"https://api.zoom.us/v2/meetings/{meeting_id}"
return requests.get(url, headers=headers)

View File

@ -43,7 +43,6 @@
body {
min-height: 100vh;
background-color: #f0f0f5;
padding-top: 0;
}

View File

@ -1,7 +1,7 @@
{% extends 'base.html' %}
{% load static i18n crispy_forms_tags %}
{% block title %}{{ interview.application.name }} - {% trans "Interview Details" %} - ATS{% endblock %}
{% block title %}{{ schedule.application.name }} - {% trans "Interview Details" %} - ATS{% endblock %}
{% block customCSS %}
<style>
@ -213,14 +213,14 @@
{% trans "Interview Details" %}
</h1>
<h2 class="h5 text-muted mb-0">
{{ interview.application.name }} - {{ interview.job.title }}
{{ schedule.application.name }} - {{ schedule.job.title }}
</h2>
</div>
<div class="d-flex gap-2">
<a href="{% url 'interview_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to Interviews" %}
</a>
<a href="{% url 'job_detail' interview.job.slug %}" class="btn btn-outline-secondary">
<a href="{% url 'job_detail' schedule.job.slug %}" class="btn btn-outline-secondary">
<i class="fas fa-briefcase me-1"></i> {% trans "View Job" %}
</a>
{% if interview.status != 'cancelled' %}
@ -241,18 +241,18 @@
<i class="fas fa-user me-2"></i> {% trans "Candidate Information" %}
</h5>
<div class="action-buttons">
{% if interview.application.resume %}
<a href="{{ interview.application.resume.url }}" class="btn btn-outline-primary btn-sm" target="_blank">
{% if schedule.application.resume %}
<a href="{{ schedule.application.resume.url }}" class="btn btn-outline-primary btn-sm" target="_blank">
<i class="fas fa-download me-1"></i> {% trans "Download Resume" %}
</a>
{% endif %}
<button type="button" class="btn btn-outline-primary btn-sm"
{% comment %} <button type="button" class="btn btn-outline-primary btn-sm"
data-bs-toggle="modal"
data-bs-target="#candidateModal"
hx-get="#"
hx-target="#candidateModalBody">
<i class="fas fa-eye me-1"></i> {% trans "AI Scoring" %}
</button>
</button> {% endcomment %}
</div>
</div>
@ -260,23 +260,25 @@
<div class="col-md-6">
<div class="info-panel">
<h6>{% trans "Personal Details" %}</h6>
<p class="mb-2"><strong>{% trans "Name:" %}</strong> {{ interview.application.name }}</p>
<p class="mb-2"><strong>{% trans "Email:" %}</strong> {{ interview.application.email }}</p>
<p class="mb-2"><strong>{% trans "Phone:" %}</strong> {{ interview.application.phone }}</p>
{% if interview.application.location %}
<p class="mb-0"><strong>{% trans "Location:" %}</strong> {{ interview.application.location }}</p>
<p class="mb-2"><strong>{% trans "Name:" %}</strong> {{ schedule.application.name }}</p>
<p class="mb-2"><strong>{% trans "Email:" %}</strong> {{ schedule.application.email }}</p>
<p class="mb-2"><strong>{% trans "Phone:" %}</strong> {{ schedule.application.phone }}</p>
{% if schedule.application.location %}
<p class="mb-0"><strong>{% trans "Location:" %}</strong> {{ schedule.application.location }}</p>
{% endif %}
</div>
</div>
<div class="col-md-6">
<div class="info-panel">
<h6>{% trans "Application Details" %}</h6>
<p class="mb-2"><strong>{% trans "Job:" %}</strong> {{ interview.job.title }}</p>
<p class="mb-2"><strong>{% trans "Department:" %}</strong> {{ interview.job.department }}</p>
<p class="mb-2"><strong>{% trans "Applied Date:" %}</strong> {{ interview.application.created_at|date:"d-m-Y" }}</p>
<p class="mb-2"><strong>{% trans "Job:" %}</strong> {{ schedule.job.title }}</p>
<p class="mb-2"><strong>{% trans "Department:" %}</strong> {{ schedule.job.department }}</p>
<p class="mb-2"><strong>{% trans "Applied Date:" %}</strong> {{ schedule.application.created_at|date:"d-m-Y" }}</p>
<p class="mb-0"><strong>{% trans "Current Stage:" %}</strong>
<span class="badge stage-badge bg-primary-theme">
{{ interview.application.stage }}
<span class="badge stage-badge stage-{{ schedule.application.stage|lower }}">
{{ schedule.application.stage }}
</span>
</p>
</div>
@ -291,22 +293,22 @@
</h5>
<div class="d-flex gap-2">
<span class="badge interview-type-badge
{% if interview.interview.location_type == 'Remote' %}bg-remote
{% if interview.location_type == 'Remote' %}bg-remote
{% else %}bg-onsite
{% endif %}">
{% if interview.interview.location_type == 'Remote' %}
{% if interview.location_type == 'Remote' %}
<i class="fas fa-video me-1"></i> {% trans "Remote" %}
{% else %}
<i class="fas fa-building me-1"></i> {% trans "Onsite" %}
{% endif %}
</span>
<span class="badge status-badge
{% if interview.status == 'SCHEDULED' %}bg-scheduled
{% elif interview.status == 'CONFIRMED' %}bg-confirmed
{% elif interview.status == 'CANCELLED' %}bg-cancelled
{% elif interview.status == 'COMPLETED' %}bg-completed
{% if schedule.status == 'SCHEDULED' %}bg-scheduled
{% elif schedule.status == 'CONFIRMED' %}bg-confirmed
{% elif schedule.status == 'CANCELLED' %}bg-cancelled
{% elif schedule.status == 'COMPLETED' %}bg-completed
{% endif %}">
{{ interview.status }}
{{ schedule.status }}
</span>
</div>
</div>
@ -316,45 +318,45 @@
<div class="meeting-details">
<div class="detail-item">
<span class="detail-label">{% trans "Date:" %}</span>
<span class="detail-value">{{ interview.interview_date|date:"d-m-Y" }}</span>
<span class="detail-value">{{ schedule.interview_date|date:"d-m-Y" }}</span>
</div>
<div class="detail-item">
<span class="detail-label">{% trans "Time:" %}</span>
<span class="detail-value">{{ interview.interview_time|date:"h:i A" }}</span>
<span class="detail-value">{{ schedule.interview_time|date:"h:i A" }}</span>
</div>
<div class="detail-item">
<span class="detail-label">{% trans "Duration:" %}</span>
<span class="detail-value">{{ interview.interview.duration }} {% trans "minutes" %}</span>
<span class="detail-value">{{ interview.duration }} {% trans "minutes" %}</span>
</div>
<div class="detail-item">
<span class="detail-label">{% trans "Status:" %}</span>
<span class="detail-value">
<span class="badge bg-primary-theme">
{{ interview.status }}</span>
{{ schedule.status }}</span>
</span>
</div>
</div>
</div>
<div class="col-md-6">
{% if interview.interview.location_type == 'Remote' %}
{% if interview.location_type == 'Remote' %}
<div class="meeting-details">
<h6 class="mb-3">{% trans "Remote Meeting Details" %}</h6>
<div class="detail-item">
<span class="detail-label">{% trans "Platform:" %}</span>
<span class="detail-value">Zoom</span>
</div>
{% if interview.interview %}
{% if schedule.interview %}
<div class="detail-item">
<span class="detail-label">{% trans "Meeting ID:" %}</span>
<span class="detail-value">{{ interview.interview.meeting_id }}</span>
<span class="detail-value">{{ interview.meeting_id }}</span>
</div>
<div class="detail-item">
<span class="detail-label">{% trans "Password:" %}</span>
<span class="detail-value">{{ interview.interview.password }}</span>
<span class="detail-value">{{ interview.password }}</span>
</div>
{% if interview.interview.details_url %}
{% if interview.details_url %}
<div class="mt-3">
<a href="{{ interview.interview.zoommeetingdetails.details_url }}"
<a href="{{ interview.zoommeetingdetails.details_url }}"
target="_blank"
class="btn btn-main-action btn-sm w-100">
<i class="fas fa-video me-1"></i> {% trans "Join Meeting" %}
@ -366,14 +368,14 @@
{% else %}
<div class="meeting-details">
<h6 class="mb-3">{% trans "Onsite Location Details" %}</h6>
{% if interview.interview %}
{% if schedule.interview %}
<div class="detail-item">
<span class="detail-label">{% trans "Address:" %}</span>
<span class="detail-value">{{ interview.interview.physical_address }}</span>
<span class="detail-value">{{ interview.physical_address }}</span>
</div>
<div class="detail-item">
<span class="detail-label">{% trans "Room:" %}</span>
<span class="detail-value">{{ interview.interview.room_number }}</span>
<span class="detail-value">{{ interview.room_number }}</span>
</div>
{% endif %}
</div>
@ -392,13 +394,13 @@
<div class="d-flex justify-content-between align-items-start">
<div>
<h6 class="mb-1">{% trans "Interview Scheduled" %}</h6>
<p class="mb-0 text-muted">{% trans "Interview was scheduled for" %} {{ interview.interview_date|date:"F j, Y" }} {{ interview.interview_time|date:"h:i A" }}</p>
<p class="mb-0 text-muted">{% trans "Interview was scheduled for" %} {{ schedule.interview_date|date:"d-m-Y" }} {{ schedule.interview_time|date:"h:i A" }}</p>
</div>
<small class="text-muted">{{ interview.interview.created_at|date:"F j,Y h:i A" }}</small>
<small class="text-muted">{{ interview.created_at|date:"d-m-Y h:i A" }}</small>
</div>
</div>
</div>
{% if interview.status == 'confirmed' %}
{% if interview.status == 'CONFIRMED' %}
<div class="timeline-item">
<div class="timeline-content">
<div class="d-flex justify-content-between align-items-start">
@ -411,7 +413,7 @@
</div>
</div>
{% endif %}
{% if interview.status == 'completed' %}
{% if interview.status == 'COMPLETED' %}
<div class="timeline-item">
<div class="timeline-content">
<div class="d-flex justify-content-between align-items-start">
@ -424,7 +426,7 @@
</div>
</div>
{% endif %}
{% if interview.status == 'cancelled' %}
{% if interview.status == 'CANCELLED' %}
<div class="timeline-item">
<div class="timeline-content">
<div class="d-flex justify-content-between align-items-start">
@ -432,7 +434,7 @@
<h6 class="mb-1">{% trans "Interview Cancelled" %}</h6>
<p class="mb-0 text-muted">{% trans "Interview was cancelled on: " %}{{interview.interview.cancelled_at|date:"F j, Y"}}</p>
</div>
<small class="text-muted">{% trans "Recently" %}</small>
</div>
</div>
</div>
@ -442,14 +444,16 @@
</div>
<div class="col-lg-4">
<!-- Participants Panel -->
{% comment %} <div class="kaauh-card shadow-sm p-4 mb-4">
<h5 class="mb-3" style="color: var(--kaauh-teal-dark); font-weight: 600;">
<i class="fas fa-users me-2"></i> {% trans "Participants" %}
</h5>
{% if interview.participants.exists %}
<!-- Internal Participants -->
{% if schedule.participants.exists %}
<h6 class="mb-2 text-muted">{% trans "Internal Participants" %}</h6>
{% for participant in interview.participants.all %}
{% for participant in schedule.participants.all %}
<div class="participant-item">
<div class="participant-avatar">
{{ participant.first_name.0 }}{{ participant.last_name.0 }}
@ -462,9 +466,10 @@
{% endfor %}
{% endif %}
{% if interview.system_users.exists %}
<!-- External Participants -->
{% if schedule.system_users.exists %}
<h6 class="mb-2 mt-3 text-muted">{% trans "External Participants" %}</h6>
{% for user in interview.system_users.all %}
{% for user in schedule.system_users.all %}
<div class="participant-item">
<div class="participant-avatar">
{{ user.first_name.0 }}{{ user.last_name.0 }}
@ -477,7 +482,7 @@
{% endfor %}
{% endif %}
{% if not interview.participants.exists and not interview.system_users.exists %}
{% if not schedule.participants.exists and not schedule.system_users.exists %}
<div class="text-center py-3 text-muted">
<i class="fas fa-users fa-2x mb-2"></i>
<p class="mb-0">{% trans "No participants added yet" %}</p>
@ -490,6 +495,7 @@
<i class="fas fa-user-plus me-1"></i> {% trans "Add Participants" %}
</button>
</div> {% endcomment %}
</div> {% endcomment %}
<div class="kaauh-card shadow-sm p-4">
<h5 class="mb-3" style="color: var(--kaauh-teal-dark); font-weight: 600;">
@ -497,9 +503,7 @@
</h5>
<div class="action-buttons">
{% if interview.status != 'cancelled' and interview.status != 'COMPLETED' %}
{# 1. Interview is Active (SCHEDULED/CONFIRMED) #}
{% if schedule.status != 'CANCELLED' and schedule.status != 'COMPLETED' %}
<button type="button" class="btn btn-main-action btn-sm"
data-bs-toggle="modal"
data-bs-target="#rescheduleModal">
@ -509,35 +513,7 @@
<button type="button" class="btn btn-outline-danger btn-sm"
data-bs-toggle="modal"
data-bs-target="#cancelModal">
<i class="fas fa-times me-1"></i> {% trans "Cancel Interview" %}
</button>
{% elif interview.status == 'cancelled' %}
{# 2. Interview is CANCELLED (Promote Reschedule) #}
<div class="alert alert-danger w-100 py-2 small" role="alert">
<i class="fas fa-info-circle me-1"></i>
{% trans "Interview is CANCELLED." %}
</div>
<div class="alert alert-info py-2 small" role="alert" style="border-left: 5px solid #00636e; background-color: #f0f8ff;">
{# Use an icon for visual appeal #}
<i class="fas fa-info-circle me-2"></i>
<strong>{% trans "Reason:" %}</strong>
{{ interview.interview.cancelled_reason }}
</div>
{% elif interview.status == 'COMPLETED' %}
{# 3. Interview is COMPLETED (Promote Result Update) #}
<div class="alert alert-success w-100 py-2 small" role="alert">
<i class="fas fa-check-circle me-1"></i>
{% trans "Interview is COMPLETED. Update the final result." %}
</div>
<button type="button" class="btn btn-success btn-sm"
data-bs-toggle="modal"
data-bs-target="#resultModal"
style="width: 100%;">
<i class="fas fa-clipboard-check me-1"></i> {% trans "Update Result" %}
<i class="fas fa-times me-1"></i> {% trans "Cancel" %}
</button>
{% endif %}
@ -548,6 +524,14 @@
data-bs-target="#emailModal">
<i class="fas fa-envelope me-1"></i> {% trans "Send Email" %}
</button>
{% if schedule.status == 'COMPLETED' %}
<button type="button" class="btn btn-outline-success btn-sm"
data-bs-toggle="modal"
data-bs-target="#resultModal">
<i class="fas fa-check-circle me-1"></i> {% trans "Update Result" %}
</button>
{% endif %}
</div>
</div>
</div>
@ -618,29 +602,29 @@
{% csrf_token %}
<div class="mb-3">
<label for="email_to" class="form-label">{% trans "To" %}</label>
<input type="email" class="form-control" id="email_to" value="{{ interview.application.email }}" readonly>
<input type="email" class="form-control" id="email_to" value="{{ schedule.application.email }}" readonly>
</div>
<div class="mb-3">
<label for="email_subject" class="form-label">{% trans "Subject" %}</label>
<input type="text" class="form-control" id="email_subject" name="subject"
value="{% trans 'Interview Details' %} - {{ interview.job.title }}">
value="{% trans 'Interview Details' %} - {{ schedule.job.title }}">
</div>
<div class="mb-3">
<label for="email_message" class="form-label">{% trans "Message" %}</label>
<textarea class="form-control" id="email_message" name="message" rows="6">
{% trans "Dear" %} {{ interview.application.name }},
{% trans "Dear" %} {{ schedule.application.name }},
{% trans "Your interview details are as follows:" %}
{% trans "Date:" %} {{ interview.interview_date|date:"d-m-Y" }}
{% trans "Time:" %} {{ interview.interview_time|date:"h:i A" }}
{% trans "Job:" %} {{ interview.job.title }}
{% trans "Date:" %} {{ schedule.interview_date|date:"d-m-Y" }}
{% trans "Time:" %} {{ schedule.interview_time|date:"h:i A" }}
{% trans "Job:" %} {{ schedule.job.title }}
{% if interview.interview.location_type == 'Remote' %}
{% trans "This is a remote interview. You will receive the meeting link separately." %}
{% else %}
{% trans "This is an onsite interview. Please arrive 10 minutes early." %}
{% endif %}
{% if interview.location_type == 'Remote' %}
{% trans "This is a remote schedule. You will receive the meeting link separately." %}
{% else %}
{% trans "This is an onsite schedule. Please arrive 10 minutes early." %}
{% endif %}
{% trans "Best regards," %}
{% trans "HR Team" %}
@ -665,11 +649,10 @@
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form method="post" action="{% url 'reschedule_meeting_for_application' interview.slug %}">
<form method="post" action="{% url 'reschedule_meeting_for_application' schedule.slug %}">
{% csrf_token %}
{{ reschedule_form|crispy }}
<button type="submit" class="btn btn-main-action btn-sm mt-3">
{{reschedule_form|crispy}}
<button type="submit" class="btn btn-main-action btn-sm">
<i class="fas fa-redo-alt me-1"></i> {% trans "Reschedule" %}
</button>
</form>
@ -688,7 +671,7 @@
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form method="post" action="{% url 'cancel_interview_for_application' interview.interview.slug %}">
<form method="post" action="{% url 'cancel_interview_for_application' schedule.slug %}">
{% csrf_token %}
{{ cancel_form|crispy }}
<div class="alert alert-warning">
@ -754,7 +737,7 @@
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form method="post" action="{% url 'update_interview_status' interview.slug %}">
<form method="post" action="{% url 'update_interview_status' schedule.slug %}">
{% csrf_token %}
{{ interview_status_form|crispy }}
<div class="d-flex gap-2 mt-3 justify-content-end">
@ -793,9 +776,6 @@ document.addEventListener('DOMContentLoaded', function () {
</div>
`;
}
// Note: For forms using Django/Crispy, you might need extra JS
// to explicitly reset form fields if the form is not reloading
// when the modal is closed.
});
}
});

View File

@ -18,7 +18,6 @@
}
body {
background-color: #f8f9fa; /* Subtle light background */
font-family: 'Inter', sans-serif;
}

View File

@ -25,7 +25,7 @@
<label for="{{ form.status.id_for_label }}" class="form-label small">
<i class="fas fa-info-circle me-1"></i> {% trans "Meeting Status" %}
</label>
{{ form.status|add_class:"form-select" }}
{{ form.status }}
{% for error in form.status.errors %}
<div class="text-danger small mt-1">{{ error }}</div>
{% endfor %}

View File

@ -1,5 +1,5 @@
{% extends "portal_base.html" %}
{% load static %}
{% load static i18n %}
{% block title %}{{ message.subject }}{% endblock %}
@ -9,40 +9,49 @@
<div class="col-12">
<!-- Message Header -->
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">
{{ message.subject }}
<div class="card-header bg-gradient border-0 pb-3">
<div class="d-flex justify-content-between align-items-start gap-3">
<div>
<h4 class="mb-2 fw-bold">{{ message.subject }}</h4>
{% if message.parent_message %}
<span class="badge bg-secondary ms-2">Reply</span>
<span class="badge bg-info">{% trans "Reply" %}</span>
{% endif %}
</h5>
<div class="btn-group" role="group">
<a href="{% url 'message_reply' message.id %}" class="btn btn-outline-info">
<i class="fas fa-reply"></i> Reply
</div>
<div class="btn-group flex-wrap" role="group">
<a href="{% url 'message_reply' message.id %}" class="btn btn-sm btn-main-action">
<i class="fas fa-reply me-1"></i> {% trans "Reply" %}
</a>
{% if message.recipient == request.user %}
<a href="{% url 'message_mark_unread' message.id %}"
class="btn btn-outline-warning"
hx-post="{% url 'message_mark_unread' message.id %}">
<i class="fas fa-envelope"></i> Mark Unread
</a>
{% endif %}
<a href="{% url 'message_delete' message.id %}"
class="btn btn-outline-danger"
hx-get="{% url 'message_delete' message.id %}"
hx-confirm="Are you sure you want to delete this message?">
<i class="fas fa-trash"></i> Delete
</a>
<a href="{% url 'message_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left"></i> Back to Messages
{% comment %} {% if message.recipient == request.user %}
<button type="button" class="btn btn-sm btn-outline-warning"
hx-post="{% url 'message_mark_unread' message.id %}"
hx-swap="outerHTML">
<i class="fas fa-envelope me-1"></i> {% trans "Mark Unread" %}
</button>
{% endif %} {% endcomment %}
{% comment %} <button type="button" class="btn btn-sm btn-outline-danger"
hx-post="{% url 'message_delete' message.id %}"
hx-confirm="{% trans 'Are you sure you want to permanently delete this message? This action cannot be undone.' %}"
hx-redirect="{% url 'message_list' %}"
onclick="this.disabled=true;">
<i class="fas fa-trash me-1"></i> {% trans "Delete" %}
</button> {% endcomment %}
<a href="{% url 'message_list' %}" class="btn btn-sm btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i> {% trans "Back" %}
</a>
</div>
</div>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-6">
<strong>From:</strong>
<span class="text-primary">{{ message.sender.get_full_name|default:message.sender.username }}</span>
<span class="text-primary">
{% if message.sender == request.user %}
{% trans "Me"%}
{% else %}
{{ message.sender.get_full_name|default:message.sender.username }}
{% endif %}
</span>
</div>
<div class="col-md-6">
<strong>To:</strong>
@ -57,9 +66,9 @@
</span>
</div>
<div class="col-md-6">
<strong>Status:</strong>
<strong>{% trans "Status"%}:</strong>
{% if message.is_read %}
<span class="badge bg-success">Read</span>
<span class="badge bg-success">{% trans "Read"%}</span>
{% if message.read_at %}
<small class="text-muted">({{ message.read_at|date:"M d, Y H:i" }})</small>
{% endif %}
@ -76,9 +85,7 @@
{% if message.job %}
<div class="col-md-6">
<strong>Related Job:</strong>
<a href="{% url 'job_detail' message.job.slug %}" class="text-primary">
{{ message.job.title }}
</a>
</div>
{% endif %}
</div>

View File

@ -1,5 +1,5 @@
{% extends "portal_base.html" %}
{% load static %}
{% load static i18n %}
{% block title %}Messages{% endblock %}
@ -103,8 +103,21 @@
<span class="badge bg-secondary ms-2">Reply</span>
{% endif %}
</td>
<td>{{ message.sender.get_full_name|default:message.sender.username }}</td>
<td>{{ message.recipient.get_full_name|default:message.recipient.username }}</td>
<td>
{% if message.sender == request.user %}
{% trans "Me"%}
{% else %}
{{ message.sender.get_full_name|default:message.sender.username }}
{% endif %}
</td>
<td>
{% if message.recipient == request.user %}
{% trans "Me"%}
{% else %}
{{ message.recipient.get_full_name|default:message.recipient.username }}
{% endif %}
</td>
<td>
<td>
<span>
{{ message.get_message_type_display }}
@ -127,7 +140,6 @@
{% if not message.is_read and message.recipient == request.user %}
<a href="{% url 'message_mark_read' message.id %}"
class="btn btn-sm btn-outline-success"
hx-post="{% url 'message_mark_read' message.id %}"
title="Mark as Read">
<i class="fas fa-check"></i>
</a>
@ -136,13 +148,13 @@
class="btn btn-sm btn-outline-primary" title="Reply">
<i class="fas fa-reply"></i>
</a>
<a href="{% url 'message_delete' message.id %}"
{% comment %} <a href="{% url 'message_delete' message.id %}"
class="btn btn-sm btn-outline-danger"
hx-get="{% url 'message_delete' message.id %}"
hx-confirm="Are you sure you want to delete this message?"
title="Delete">
<i class="fas fa-trash"></i>
</a>
</a> {% endcomment %}
</div>
</td>
</tr>

View File

@ -28,13 +28,13 @@
<i class="fas fa-envelope me-1"></i> {% trans "Mark Unread" %}
</button>
{% endif %}
<button type="button" class="btn btn-sm btn-outline-danger"
{% comment %} <button type="button" class="btn btn-sm btn-outline-danger"
hx-post="{% url 'message_delete' message.id %}"
hx-confirm="{% trans 'Are you sure you want to permanently delete this message? This action cannot be undone.' %}"
hx-redirect="{% url 'message_list' %}"
onclick="this.disabled=true;">
<i class="fas fa-trash me-1"></i> {% trans "Delete" %}
</button>
</button> {% endcomment %}
<a href="{% url 'message_list' %}" class="btn btn-sm btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i> {% trans "Back" %}
</a>

View File

@ -17,7 +17,7 @@
<div class="card mb-4 border-primary-theme-subtle">
<div class="card-body">
<form method="get" class="row g-3">
<div class="col-md-3">
<div class="col-md-2">
<label for="status" class="form-label">{% trans "Status" %}</label>
<select name="status" id="status" class="form-select">
<option value="">{% trans "All Status" %}</option>
@ -25,16 +25,26 @@
<option value="unread" {% if status_filter == 'unread' %}selected{% endif %}>{% trans "Unread" %}</option>
</select>
</div>
<div class="col-md-3">
<div class="col-md-2">
<label for="type" class="form-label">{% trans "Type" %}</label>
<select name="type" id="type" class="form-select">
<option value="">{% trans "All Types" %}</option>
<option value="GENERAL" {% if type_filter == 'GENERAL' %}selected{% endif %}>{% trans "General" %}</option>
<option value="JOB_RELATED" {% if type_filter == 'JOB_RELATED' %}selected{% endif %}>{% trans "Job Related" %}</option>
<option value="INTERVIEW" {% if type_filter == 'INTERVIEW' %}selected{% endif %}>{% trans "Interview" %}</option>
<option value="OFFER" {% if type_filter == 'OFFER' %}selected{% endif %}>{% trans "Offer" %}</option>
<option value="direct" {% if type_filter == 'DIRECT' %}selected{% endif %}>{% trans "Direct" %}</option>
<option value="job_related" {% if type_filter == 'JOB_RELATED' %}selected{% endif %}>{% trans "Job Related" %}</option>
<option value="system" {% if type_filter == 'SYSTEM' %}selected{% endif %}>{% trans "System" %}</option>
</select>
</div>
{% if user.user_type == "staff" %}
<div class="col-md-3">
<label for="job" class="form-label">{% trans "Job" %}</label>
<select name="job_filter" id="job" class="form-select">
<option value="">{% trans "All Types" %}</option>
{% for job in jobs %}
<option value="{{ job.pk }}" {% if job_filter == job.pk %}selected{% endif %}>{{job.title}}</option>
{% endfor %}
</select>
</div>
{% endif %}
<div class="col-md-4">
<label for="q" class="form-label">{% trans "Search" %}</label>
<div class="input-group">
@ -102,8 +112,20 @@
<span class="badge bg-secondary-theme ms-2 text-decoration-none">{% trans "Reply" %}</span>
{% endif %}
</td>
<td>{{ message.sender.get_full_name|default:message.sender.username }}</td>
<td>{{ message.recipient.get_full_name|default:message.recipient.username }}</td>
<td>
{% if message.sender == request.user %}
{% trans "Me"%}
{% else %}
{{ message.sender.get_full_name|default:message.sender.username }}
{% endif %}
</td>
<td>
{% if message.recipient == request.user %}
{% trans "Me"%}
{% else %}
{{ message.recipient.get_full_name|default:message.recipient.username }}
{% endif %}
</td>
<td>
<span>
{{ message.get_message_type_display }}
@ -126,7 +148,6 @@
{% if not message.is_read and message.recipient == request.user %}
<a href="{% url 'message_mark_read' message.id %}"
class="btn btn-sm btn-outline-primary"
hx-post="{% url 'message_mark_read' message.id %}"
title="{% trans 'Mark as Read' %}">
<i class="fas fa-check"></i>
</a>
@ -135,13 +156,13 @@
class="btn btn-sm btn-outline-primary" title="{% trans 'Reply' %}">
<i class="fas fa-reply"></i>
</a>
<a href="{% url 'message_delete' message.id %}"
{% comment %} <a href="{% url 'message_delete' message.id %}"
class="btn btn-sm btn-outline-danger"
hx-post="{% url 'message_delete' message.id %}"
hx-confirm="{% trans 'Are you sure you want to delete this message?' %}"
title="{% trans 'Delete' %}">
<i class="fas fa-trash"></i>
</a>
</a> {% endcomment %}
</div>
</td>
</tr>

View File

@ -38,7 +38,7 @@
/* Consistent, reduced padding for compact look */
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--kaauh-border);
background-color: #f8f9fa;
display: flex;
justify-content: space-between;
align-items: center;

View File

@ -0,0 +1,123 @@
{% extends "base.html" %}
{% load widget_tweaks %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="row">
<div class="col-12">
<!-- Breadcrumb Navigation -->
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a href="{% url 'settings_list' %}" class="text-decoration-none text-secondary">
<i class="fas fa-cog me-1"></i> Settings
</a>
</li>
<li class="breadcrumb-item">
<a href="{% url 'settings_detail' setting.pk %}" class="text-decoration-none text-secondary">
{{ setting.key }}
</a>
</li>
<li class="breadcrumb-item active" aria-current="page"
style="color: #F43B5E; font-weight: 600;">Delete</li>
</ol>
</nav>
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0 text-primary-theme">
<i class="fas fa-exclamation-triangle me-2"></i>
{{ title }}
</h1>
<a href="{{ cancel_url }}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i> Cancel
</a>
</div>
<!-- Warning Card -->
<div class="card shadow-sm border-danger">
<div class="card-body">
{% 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" aria-label="Close"></button>
</div>
{% endfor %}
{% endif %}
<!-- Warning Alert -->
<div class="alert alert-warning d-flex align-items-center" role="alert">
<i class="fas fa-exclamation-triangle me-3 fa-lg"></i>
<div>
<strong>Warning:</strong> This action cannot be undone. Deleting this setting will permanently remove it from the system.
</div>
</div>
<!-- Setting Details -->
<div class="row">
<div class="col-12">
<h5 class="mb-3">Setting Details:</h5>
<div class="table-responsive">
<table class="table table-hover">
<tbody>
<tr>
<th style="width: 150px;" class="text-primary-theme">Key:</th>
<td><code class="text-primary-theme">{{ setting.key }}</code></td>
</tr>
<tr>
<th class="text-primary-theme">Value:</th>
<td>{{ setting.value|default:"-" }}</td>
</tr>
<tr>
<th class="text-primary-theme">Description:</th>
<td>{{ setting.description|default:"No description" }}</td>
</tr>
<tr>
<th class="text-primary-theme">Type:</th>
<td>
<span class="badge bg-primary-theme text-white">
{{ setting.get_type_display }}
</span>
</td>
</tr>
<tr>
<th class="text-primary-theme">Status:</th>
<td>
{% if setting.is_active %}
<span class="badge bg-primary-theme text-white">Active</span>
{% else %}
<span class="badge bg-secondary text-white">Inactive</span>
{% endif %}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="row mt-4">
<div class="col-12">
<form method="post" onsubmit="return confirm('Are you absolutely sure you want to delete this setting? This action cannot be undone.');">
{% csrf_token %}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-danger">
<i class="fas fa-trash me-1"></i> Yes, Delete Setting
</button>
<a href="{{ cancel_url }}" class="btn btn-outline-secondary">
<i class="fas fa-times me-1"></i> Cancel
</a>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,82 @@
{% extends "base.html" %}
{% load widget_tweaks %}
{% block title %}Setting Details{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="row">
<div class="col-12">
<!-- Breadcrumb Navigation -->
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a href="{% url 'settings_list' %}" class="text-decoration-none text-secondary">
<i class="fas fa-cog me-1"></i> Settings
</a>
</li>
<li class="breadcrumb-item active" aria-current="page"
style="color: #F43B5E; font-weight: 600;">{{ setting.key }}</li>
</ol>
</nav>
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0 text-primary-theme">
<i class="fas fa-cog me-2"></i>
Setting Details
</h1>
<div class="d-flex gap-2">
<a href="{% url 'settings_update' pk=setting.pk %}" class="btn btn-main-action">
<i class="fas fa-edit me-1"></i> Edit Setting
</a>
<a href="{% url 'settings_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i> Back to List
</a>
</div>
</div>
<!-- Setting Details Card -->
<div class="card shadow-sm">
<div class="card-body">
{% 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" aria-label="Close"></button>
</div>
{% endfor %}
{% endif %}
<div class="row">
<div class="col-md-6">
<h6 class="text-primary-theme"><strong>Key:</strong></h6>
<p><code class="text-primary-theme">{{ setting.key }}</code></p>
</div>
<div class="col-md-6">
<h6 class="text-primary-theme"><strong>Value:</strong></h6>
<p>{{ setting.value|default:"-" }}</p>
</div>
</div>
<div class="row mt-3">
<div class="col-md-4">
<h6 class="text-primary-theme"><strong>Created:</strong></h6>
<p>{{ setting.created_at|date:"Y-m-d H:i" }}</p>
</div>
<div class="col-md-4">
<h6 class="text-primary-theme"><strong>Last Updated:</strong></h6>
<p>{{ setting.updated_at|date:"Y-m-d H:i" }}</p>
</div>
<div class="col-md-4">
<h6 class="text-primary-theme"><strong>Updated By:</strong></h6>
<p>{{ setting.updated_by.get_full_name|default:"System" }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,218 @@
{% extends "base.html" %}
{% load crispy_forms_tags %}
{% block title %}{{ title }}{% endblock %}
{% block customCSS %}
<style>
/* UI Variables for the KAAUH Theme */
:root {
--kaauh-teal: #00636e;
--kaauh-teal-dark: #004a53;
--kaauh-border: #eaeff3;
--kaauh-gray-light: #f8f9fa;
}
/* Form Container Styling */
.form-container {
max-width: 800px;
margin: 0 auto;
}
/* Card Styling */
.card {
border: 1px solid var(--kaauh-border);
border-radius: 0.75rem;
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
}
/* Main Action Button Style */
.btn-main-action {
background-color: var(--kaauh-teal);
border-color: var(--kaauh-teal);
color: white;
font-weight: 600;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 1.5rem;
}
.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);
}
/* Secondary Button Style */
.btn-outline-secondary {
color: var(--kaauh-teal-dark);
border-color: var(--kaauh-teal);
}
.btn-outline-secondary:hover {
background-color: var(--kaauh-teal-dark);
color: white;
border-color: var(--kaauh-teal-dark);
}
/* Form Field Styling */
.form-control:focus {
border-color: var(--kaauh-teal);
box-shadow: 0 0 0 0.2rem rgba(0, 99, 110, 0.25);
}
.form-select:focus {
border-color: var(--kaauh-teal);
box-shadow: 0 0 0 0.2rem rgba(0, 99, 110, 0.25);
}
/* Breadcrumb Styling */
.breadcrumb {
background-color: transparent;
padding: 0;
margin-bottom: 1rem;
}
.breadcrumb-item + .breadcrumb-item::before {
content: ">";
color: var(--kaauh-teal);
}
/* Alert Styling */
.alert {
border-radius: 0.5rem;
border: none;
}
/* Loading State */
.btn.loading {
position: relative;
pointer-events: none;
opacity: 0.8;
}
.btn.loading::after {
content: "";
position: absolute;
width: 16px;
height: 16px;
margin: auto;
border: 2px solid transparent;
border-top-color: #ffffff;
border-radius: 50%;
animation: spin 1s linear infinite;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="form-container">
<!-- Breadcrumb Navigation -->
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a href="{% url 'settings_list' %}" class="text-decoration-none text-secondary">
<i class="fas fa-cog me-1"></i> Settings
</a>
</li>
{% if setting %}
<li class="breadcrumb-item">
<a href="{% url 'settings_detail' setting.pk %}" class="text-decoration-none text-secondary">
{{ setting.key }}
</a>
</li>
<li class="breadcrumb-item active" aria-current="page"
style="color: #F43B5E; font-weight: 600;">Update</li>
{% else %}
<li class="breadcrumb-item active" aria-current="page"
style="color: #F43B5E; font-weight: 600;">Create</li>
{% endif %}
</ol>
</nav>
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 style="color: var(--kaauh-teal-dark); font-weight: 700;">
<i class="fas fa-cog me-2"></i> {{ title }}
</h4>
<div class="d-flex gap-2">
{% if setting %}
<a href="{% url 'settings_detail' setting.pk %}" class="btn btn-outline-secondary">
<i class="fas fa-eye me-1"></i> View Details
</a>
<a href="{% url 'settings_delete' setting.pk %}" class="btn btn-danger">
<i class="fas fa-trash me-1"></i> Delete
</a>
{% endif %}
<a href="{% url 'settings_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i> Back to List
</a>
</div>
</div>
<!-- Form Card -->
<div class="card shadow-sm">
<div class="card-body p-4">
{% if form.non_field_errors %}
<div class="alert alert-danger" role="alert">
<h5 class="alert-heading">
<i class="fas fa-exclamation-triangle me-2"></i>Error
</h5>
{% for error in form.non_field_errors %}
<p class="mb-0">{{ error }}</p>
{% endfor %}
</div>
{% endif %}
{% 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" aria-label="Close"></button>
</div>
{% endfor %}
{% endif %}
<form method="post" novalidate id="settings-form">
{% csrf_token %}
{{form|crispy}}
<div class="d-flex gap-2">
<button form="settings-form" type="submit" class="btn btn-main-action">
<i class="fas fa-save me-1"></i> {{ button_text }}
</button>
<a href="{% url 'settings_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-times me-1"></i> Cancel
</a>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Auto-adjust textarea height
const textarea = document.querySelector('textarea[name="description"]');
if (textarea) {
textarea.addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = this.scrollHeight + 'px';
});
}
});
</script>
{% endblock %}

View File

@ -0,0 +1,186 @@
{% extends "base.html" %}
{% load widget_tweaks %}
{% block title %}Settings{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0 text-primary-theme">
<i class="fas fa-cog me-2"></i>
Settings Management
</h1>
<a href="{% url 'settings_create' %}" class="btn btn-main-action">
<i class="fas fa-plus me-2"></i>
Create New Setting
</a>
</div>
<!-- Search and Filters -->
<div class="card mb-4">
<div class="card-body">
<form method="get" class="row g-3">
<div class="col-md-8">
<div class="input-group">
<span class="input-group-text">
<i class="fas fa-search"></i>
</span>
<input type="text" class="form-control" name="q"
placeholder="Search settings..." value="{{ search_query }}">
</div>
</div>
<div class="col-md-4">
<button type="submit" class="btn btn-outline-primary">
<i class="fas fa-search"></i> Search
</button>
{% if search_query %}
<a href="{% url 'settings_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-times"></i> Clear
</a>
{% endif %}
</div>
</form>
</div>
</div>
<!-- Results Summary -->
{% if search_query %}
<div class="alert alert-info">
Found {{ page_obj.paginator.count }} setting{{ page_obj.paginator.count|pluralize }} matching "{{ search_query }}"
</div>
{% endif %}
<!-- Settings Table -->
<div class="card">
<div class="card-body">
{% 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" aria-label="Close"></button>
</div>
{% endfor %}
{% endif %}
{% if page_obj %}
<div class="table-responsive">
<table class="table table-hover">
<thead class="table-light">
<tr>
<th style="width: 45%;">Key</th>
<th style="width: 45%;">Value</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for setting in page_obj %}
<tr>
<td>
<code class="text-primary-theme">{{ setting.key }}</code>
</td>
<td>{{ setting.value|truncatechars:50 }}</td>
<td>
<div class="btn-group" role="group">
<a href="{% url 'settings_detail' pk=setting.pk %}"
class="btn btn-sm btn-outline-primary" title="View Details">
<i class="fas fa-eye"></i>
</a>
<a href="{% url 'settings_update' pk=setting.pk %}"
class="btn btn-sm btn-outline-secondary" title="Edit Setting">
<i class="fas fa-edit"></i>
</a>
<form method="post" action="{% url 'settings_delete' pk=setting.pk %}"
onsubmit="return confirm('Are you sure you want to delete this setting?');"
style="display: inline;">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-outline-danger" title="Delete Setting">
<i class="fas fa-trash"></i>
</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if page_obj.has_other_pages %}
<nav aria-label="Settings pagination">
<ul class="pagination justify-content-center">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page=1{% if search_query %}&q={{ search_query }}{% endif %}">
<i class="fas fa-angle-double-left"></i>
</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if search_query %}&q={{ search_query }}{% endif %}">
<i class="fas fa-angle-left"></i>
</a>
</li>
{% endif %}
{% for num in page_obj.paginator.page_range %}
{% if page_obj.number == num %}
<li class="page-item active">
<span class="page-link">{{ num }}</span>
</li>
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
<li class="page-item">
<a class="page-link" href="?page={{ num }}{% if search_query %}&q={{ search_query }}{% endif %}">{{ num }}</a>
</li>
{% endif %}
{% endfor %}
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% if search_query %}&q={{ search_query }}{% endif %}">
<i class="fas fa-angle-right"></i>
</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}{% if search_query %}&q={{ search_query }}{% endif %}">
<i class="fas fa-angle-double-right"></i>
</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
<div class="text-center py-5">
<i class="fas fa-cog fa-3x text-muted mb-3"></i>
<h5 class="text-muted">No settings found</h5>
<p class="text-muted">
{% if search_query %}
No settings match your search criteria "{{ search_query }}".
{% else %}
Get started by creating your first setting.
{% endif %}
</p>
<a href="{% url 'settings_create' %}" class="btn btn-main-action">
<i class="fas fa-plus"></i> Create Setting
</a>
</div>
{% endif %}
<div class="text-center mt-3">
<small class="text-muted">
{% if page_obj %}
Showing {{ page_obj.start_index }}-{{ page_obj.end_index }} of {{ page_obj.paginator.count }} settings
{% if search_query %}
(filtered by: "{{ search_query }}")
{% endif %}
{% endif %}
</small>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

169
test_job_reminders.py Normal file
View File

@ -0,0 +1,169 @@
#!/usr/bin/env python
"""
Test script for job closing reminder system
"""
import os
import sys
import django
from datetime import datetime, timedelta
from django.utils import timezone
# Setup Django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'NorahUniversity.settings')
django.setup()
from recruitment.models import JobPosting, Application
from django_q.models import Schedule
from django.contrib.auth import get_user_model
User = get_user_model()
def test_reminder_scheduling():
"""Test that reminders are properly scheduled when a job is created/updated"""
print("🧪 Testing Job Closing Reminder System")
print("=" * 50)
# Get or create a test user
test_user, created = User.objects.get_or_create(
username='test_recruiter',
defaults={
'email': 'test@example.com',
'is_staff': True
}
)
# Create a test job with deadline in 2 days
deadline = timezone.now() + timedelta(days=2)
print(f"📅 Creating test job with deadline: {deadline}")
job = JobPosting.objects.create(
title='Test Software Engineer Position',
description='Test job for reminder system',
qualifications='Python, Django, PostgreSQL',
status='ACTIVE',
application_deadline=deadline,
assigned_to=test_user,
created_by='Test User'
)
print(f"✅ Created job: {job.title} (ID: {job.pk})")
# Check if schedules were created
schedules = Schedule.objects.filter(
name__in=[
f"one_day_reminder_{job.pk}",
f"fifteen_min_reminder_{job.pk}",
f"job_closing_{job.pk}"
]
)
print(f"\n📋 Scheduled Tasks ({schedules.count()} found):")
for schedule in schedules:
print(f" - {schedule.name}: {schedule.next_run}")
# Test deadline update
print(f"\n🔄 Testing deadline update...")
new_deadline = deadline + timedelta(hours=6)
job.application_deadline = new_deadline
job.save()
# Check if schedules were updated
updated_schedules = Schedule.objects.filter(
name__in=[
f"one_day_reminder_{job.pk}",
f"fifteen_min_reminder_{job.pk}",
f"job_closing_{job.pk}"
]
)
print(f"📋 Updated Scheduled Tasks ({updated_schedules.count()} found):")
for schedule in updated_schedules:
print(f" - {schedule.name}: {schedule.next_run}")
# Test job deactivation
print(f"\n🛑 Testing job deactivation...")
job.status = 'CLOSED'
job.save()
remaining_schedules = Schedule.objects.filter(
name__in=[
f"one_day_reminder_{job.pk}",
f"fifteen_min_reminder_{job.pk}",
f"job_closing_{job.pk}"
]
)
print(f"📋 Remaining Scheduled Tasks after deactivation: {remaining_schedules.count()}")
# Cleanup
print(f"\n🧹 Cleaning up test data...")
job.delete()
print("✅ Test completed successfully!")
def test_email_functions():
"""Test the email functions directly"""
print("\n🧪 Testing Email Functions")
print("=" * 50)
# Create a test job
test_user, _ = User.objects.get_or_create(
username='test_recruiter_email',
defaults={'email': 'test_email@example.com', 'is_staff': True}
)
job = JobPosting.objects.create(
title='Test Email Job',
description='Test job for email functions',
status='ACTIVE',
application_deadline=timezone.now() + timedelta(hours=1),
assigned_to=test_user,
created_by='Test User'
)
print(f"📧 Testing email functions for job: {job.pk}")
# Import and test email functions
from recruitment.tasks import (
send_one_day_reminder,
send_fifteen_minute_reminder,
send_job_closed_notification
)
try:
print("📤 Testing 1-day reminder...")
send_one_day_reminder(job.pk)
print("✅ 1-day reminder sent successfully")
except Exception as e:
print(f"❌ 1-day reminder failed: {e}")
try:
print("📤 Testing 15-minute reminder...")
send_fifteen_minute_reminder(job.pk)
print("✅ 15-minute reminder sent successfully")
except Exception as e:
print(f"❌ 15-minute reminder failed: {e}")
try:
print("📤 Testing job closed notification...")
send_job_closed_notification(job.pk)
print("✅ Job closed notification sent successfully")
except Exception as e:
print(f"❌ Job closed notification failed: {e}")
# Cleanup
job.delete()
print("🧹 Test data cleaned up")
if __name__ == '__main__':
try:
test_reminder_scheduling()
test_email_functions()
print("\n🎉 All tests completed!")
except Exception as e:
print(f"\n❌ Test failed with error: {e}")
import traceback
traceback.print_exc()