fix remote interview creation issue
This commit is contained in:
parent
bfea43df8f
commit
bb552cbd3f
6
.env
6
.env
@ -1,3 +1,3 @@
|
||||
DB_NAME=haikal_db
|
||||
DB_USER=faheed
|
||||
DB_PASSWORD=Faheed@215
|
||||
DB_NAME=norahuniversity
|
||||
DB_USER=norahuniversity
|
||||
DB_PASSWORD=norahuniversity
|
||||
@ -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/"
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
19
recruitment/management/commands/init_settings.py
Normal file
19
recruitment/management/commands/init_settings.py
Normal 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}')
|
||||
)
|
||||
30
recruitment/migrations/0002_settings.py
Normal file
30
recruitment/migrations/0002_settings.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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 ''}"
|
||||
@ -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)
|
||||
|
||||
@ -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)}")
|
||||
|
||||
@ -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>/",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -43,7 +43,6 @@
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
background-color: #f0f0f5;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
|
||||
@ -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.
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@ -18,7 +18,6 @@
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #f8f9fa; /* Subtle light background */
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
123
templates/recruitment/settings_confirm_delete.html
Normal file
123
templates/recruitment/settings_confirm_delete.html
Normal 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 %}
|
||||
82
templates/recruitment/settings_detail.html
Normal file
82
templates/recruitment/settings_detail.html
Normal 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 %}
|
||||
218
templates/recruitment/settings_form.html
Normal file
218
templates/recruitment/settings_form.html
Normal 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 %}
|
||||
186
templates/recruitment/settings_list.html
Normal file
186
templates/recruitment/settings_list.html
Normal 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
169
test_job_reminders.py
Normal 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()
|
||||
Loading…
x
Reference in New Issue
Block a user