Compare commits

...

6 Commits

23 changed files with 1033 additions and 749 deletions

View File

@ -199,7 +199,7 @@ ACCOUNT_SIGNUP_FIELDS = ["email*", "password1*", "password2*"]
ACCOUNT_UNIQUE_EMAIL = True
ACCOUNT_EMAIL_VERIFICATION = 'none'
ACCOUNT_USER_MODEL_USERNAME_FIELD = None
ACCOUNT_EMAIL_VERIFICATION = "mandatory"
ACCOUNT_EMAIL_VERIFICATION = "optional"
ACCOUNT_LOGIN_ON_EMAIL_CONFIRMATION = True

View File

@ -5,7 +5,7 @@ from django.utils import timezone
from .models import (
JobPosting, Application, TrainingMaterial,
FormTemplate, FormStage, FormField, FormSubmission, FieldResponse,
SharedFormTemplate, Source, HiringAgency, IntegrationLog,BulkInterviewTemplate,JobPostingImage,InterviewNote,
SharedFormTemplate, Source, HiringAgency, IntegrationLog,BulkInterviewTemplate,JobPostingImage,Note,
AgencyAccessLink, AgencyJobAssignment,Interview,ScheduledInterview
)
from django.contrib.auth import get_user_model
@ -250,4 +250,4 @@ admin.site.register(ScheduledInterview)
admin.site.register(JobPostingImage)
admin.site.register(User)
# admin.site.register(User)

View File

@ -18,7 +18,7 @@ from .models import (
BulkInterviewTemplate,
BreakTime,
JobPostingImage,
InterviewNote,
Note,
ScheduledInterview,
Source,
HiringAgency,
@ -720,94 +720,100 @@ class FormTemplateForm(forms.ModelForm):
# BreakTimeFormSet = formset_factory(BreakTimeForm, extra=1, can_delete=True)
# class BulkInterviewTemplateForm(forms.ModelForm):
# applications = forms.ModelMultipleChoiceField(
# queryset=Application.objects.none(),
# widget=forms.CheckboxSelectMultiple,
# required=True,
# )
# working_days = forms.MultipleChoiceField(
# choices=[
# (0, "Monday"),
# (1, "Tuesday"),
# (2, "Wednesday"),
# (3, "Thursday"),
# (4, "Friday"),
# (5, "Saturday"),
# (6, "Sunday"),
# ],
# widget=forms.CheckboxSelectMultiple,
# required=True,
# )
class BulkInterviewTemplateForm(forms.ModelForm):
applications = forms.ModelMultipleChoiceField(
queryset=Application.objects.none(),
widget=forms.CheckboxSelectMultiple,
required=True,
)
working_days = forms.MultipleChoiceField(
choices=[
(0, "Monday"),
(1, "Tuesday"),
(2, "Wednesday"),
(3, "Thursday"),
(4, "Friday"),
(5, "Saturday"),
(6, "Sunday"),
],
widget=forms.CheckboxSelectMultiple,
required=True,
)
# class Meta:
# model = BulkInterviewTemplate
# fields = [
# 'schedule_interview_type',
# "applications",
# "start_date",
# "end_date",
# "working_days",
# "start_time",
# "end_time",
# "interview_duration",
# "buffer_time",
# "break_start_time",
# "break_end_time",
# ]
# widgets = {
# "start_date": forms.DateInput(
# attrs={"type": "date", "class": "form-control"}
# ),
# "end_date": forms.DateInput(
# attrs={"type": "date", "class": "form-control"}
# ),
# "start_time": forms.TimeInput(
# attrs={"type": "time", "class": "form-control"}
# ),
# "end_time": forms.TimeInput(
# attrs={"type": "time", "class": "form-control"}
# ),
# "interview_duration": forms.NumberInput(attrs={"class": "form-control"}),
# "buffer_time": forms.NumberInput(attrs={"class": "form-control"}),
# "break_start_time": forms.TimeInput(
# attrs={"type": "time", "class": "form-control"}
# ),
# "break_end_time": forms.TimeInput(
# attrs={"type": "time", "class": "form-control"}
# ),
# "schedule_interview_type":forms.RadioSelect()
# }
class Meta:
model = BulkInterviewTemplate
fields = [
'schedule_interview_type',
'topic',
'physical_address',
"applications",
"start_date",
"end_date",
"working_days",
"start_time",
"end_time",
"interview_duration",
"buffer_time",
"break_start_time",
"break_end_time",
]
widgets = {
"topic": forms.TextInput(attrs={"class": "form-control"}),
"start_date": forms.DateInput(
attrs={"type": "date", "class": "form-control"}
),
"end_date": forms.DateInput(
attrs={"type": "date", "class": "form-control"}
),
"start_time": forms.TimeInput(
attrs={"type": "time", "class": "form-control"}
),
"end_time": forms.TimeInput(
attrs={"type": "time", "class": "form-control"}
),
"interview_duration": forms.NumberInput(attrs={"class": "form-control"}),
"buffer_time": forms.NumberInput(attrs={"class": "form-control"}),
"break_start_time": forms.TimeInput(
attrs={"type": "time", "class": "form-control"}
),
"break_end_time": forms.TimeInput(
attrs={"type": "time", "class": "form-control"}
),
"schedule_interview_type":forms.RadioSelect(),
"physical_address": forms.Textarea(
attrs={"class": "form-control", "rows": 3, "placeholder": "Enter physical address if 'In-Person' is selected"}
),
}
# def __init__(self, slug, *args, **kwargs):
# super().__init__(*args, **kwargs)
# self.fields["applications"].queryset = Application.objects.filter(
# job__slug=slug, stage="Interview"
# )
def __init__(self, slug, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["applications"].queryset = Application.objects.filter(
job__slug=slug, stage="Interview"
)
# def clean_working_days(self):
# working_days = self.cleaned_data.get("working_days")
# return [int(day) for day in working_days]
def clean_working_days(self):
working_days = self.cleaned_data.get("working_days")
return [int(day) for day in working_days]
# class InterviewNoteForm(forms.ModelForm):
# """Form for creating and editing meeting comments"""
class NoteForm(forms.ModelForm):
"""Form for creating and editing meeting comments"""
# class Meta:
# model = InterviewNote
# fields = ["content"]
# widgets = {
# "content": CKEditor5Widget(
# attrs={
# "class": "form-control",
# "placeholder": _("Enter your comment or note"),
# },
# config_name="extends",
# ),
# }
# labels = {
# "content": _("Comment"),
# }
class Meta:
model = Note
fields = "__all__"
widgets = {
"content": CKEditor5Widget(
attrs={
"class": "form-control",
"placeholder": _("Enter your comment or note"),
},
config_name="extends",
),
}
labels = {
"content": _("Note"),
}
# def __init__(self, *args, **kwargs):
# super().__init__(*args, **kwargs)
@ -891,6 +897,15 @@ class JobPostingStatusForm(forms.ModelForm):
widgets = {
"status": forms.Select(attrs={"class": "form-select"}),
}
def clean_status(self):
status = self.cleaned_data.get("status")
if status == "ACTIVE":
if self.instance and self.instance.pk:
print(self.instance.assigned_to)
if not self.instance.assigned_to:
raise ValidationError("Please assign the job posting before setting it to Active.")
return status
class LinkedPostContentForm(forms.ModelForm):
@ -2090,23 +2105,19 @@ class CandidateEmailForm(forms.Form):
from django.forms import HiddenInput
class MessageForm(forms.ModelForm):
"""Form for creating and editing messages between users"""
class Meta:
model = Message
fields = ["recipient", "job", "subject", "content", "message_type"]
fields = ["job","recipient", "subject", "content", "message_type"]
widgets = {
"recipient": forms.Select(
attrs={"class": "form-select", "placeholder": "Select recipient","required": True,}
),
"job": forms.Select(
attrs={"class": "form-select", "placeholder": "Select job",
"hx-get": "/en/messages/create/",
"hx-target": "#id_recipient",
"hx-select": "#id_recipient",
"hx-swap": "outerHTML",}
attrs={"class": "form-select", "placeholder": "Select job"}
),
"subject": forms.TextInput(
attrs={
@ -2180,7 +2191,7 @@ class MessageForm(forms.ModelForm):
self.fields["job"].queryset = JobPosting.objects.filter(
id__in=job_ids
).order_by("-created_at")
print("Agency user job queryset:", self.fields["job"].queryset)
elif self.user.user_type == "candidate":
# Candidates can only see jobs they applied for
@ -2210,6 +2221,8 @@ class MessageForm(forms.ModelForm):
self.fields["recipient"].queryset = User.objects.filter(
user_type="staff"
).order_by("username")
def clean(self):
"""Validate message form data"""

View File

@ -1,4 +1,4 @@
# Generated by Django 5.2.7 on 2025-11-27 15:36
# Generated by Django 5.2.6 on 2025-12-02 10:27
import django.contrib.auth.models
import django.contrib.auth.validators
@ -31,6 +31,18 @@ class Migration(migrations.Migration):
('end_time', models.TimeField(verbose_name='End Time')),
],
),
migrations.CreateModel(
name='EmailContent',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('subject', models.CharField(max_length=255, verbose_name='Subject')),
('message', django_ckeditor_5.fields.CKEditor5Field(verbose_name='Message Body')),
],
options={
'verbose_name': 'Email Content',
'verbose_name_plural': 'Email Contents',
},
),
migrations.CreateModel(
name='FormStage',
fields=[
@ -57,7 +69,6 @@ class Migration(migrations.Migration):
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('location_type', models.CharField(choices=[('Remote', 'Remote (e.g., Zoom, Google Meet)'), ('Onsite', 'In-Person (Physical Location)')], db_index=True, max_length=10, verbose_name='Location Type')),
('topic', models.CharField(blank=True, help_text="e.g., 'Zoom Topic: Software Interview' or 'Main Conference Room'", max_length=255, verbose_name='Meeting/Location Topic')),
('details_url', models.URLField(blank=True, max_length=2048, null=True, verbose_name='Meeting/Location URL')),
('timezone', models.CharField(default='UTC', max_length=50, verbose_name='Timezone')),
('start_time', models.DateTimeField(db_index=True, verbose_name='Start Time')),
('duration', models.PositiveIntegerField(verbose_name='Duration (minutes)')),
@ -65,6 +76,7 @@ class Migration(migrations.Migration):
('meeting_id', models.CharField(blank=True, max_length=50, null=True, unique=True, verbose_name='External Meeting ID')),
('password', models.CharField(blank=True, max_length=20, null=True)),
('zoom_gateway_response', models.JSONField(blank=True, null=True)),
('details_url', models.JSONField(blank=True, null=True)),
('participant_video', models.BooleanField(default=True)),
('join_before_host', models.BooleanField(default=False)),
('host_email', models.CharField(blank=True, max_length=255, null=True)),
@ -278,24 +290,6 @@ class Migration(migrations.Migration):
'verbose_name_plural': 'Applications',
},
),
migrations.CreateModel(
name='InterviewNote',
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')),
('note_type', models.CharField(choices=[('Feedback', 'Candidate Feedback'), ('Logistics', 'Logistical Note'), ('General', 'General Comment')], default='Feedback', max_length=50, verbose_name='Note Type')),
('content', django_ckeditor_5.fields.CKEditor5Field(verbose_name='Content/Feedback')),
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interview_notes', to=settings.AUTH_USER_MODEL, verbose_name='Author')),
('interview', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notes', to='recruitment.interview', verbose_name='Scheduled Interview')),
],
options={
'verbose_name': 'Interview Note',
'verbose_name_plural': 'Interview Notes',
'ordering': ['created_at'],
},
),
migrations.CreateModel(
name='JobPosting',
fields=[
@ -363,12 +357,15 @@ class Migration(migrations.Migration):
('start_date', models.DateField(db_index=True, verbose_name='Start Date')),
('end_date', models.DateField(db_index=True, verbose_name='End Date')),
('working_days', models.JSONField(verbose_name='Working Days')),
('topic', models.CharField(max_length=255, verbose_name='Interview Topic')),
('start_time', models.TimeField(verbose_name='Start Time')),
('end_time', models.TimeField(verbose_name='End Time')),
('break_start_time', models.TimeField(blank=True, null=True, verbose_name='Break Start Time')),
('break_end_time', models.TimeField(blank=True, null=True, verbose_name='Break End Time')),
('interview_duration', models.PositiveIntegerField(verbose_name='Interview Duration (minutes)')),
('buffer_time', models.PositiveIntegerField(default=0, verbose_name='Buffer Time (minutes)')),
('schedule_interview_type', models.CharField(choices=[('Remote', 'Remote (e.g., Zoom)'), ('Onsite', 'In-Person (Physical Location)')], default='Onsite', max_length=10, verbose_name='Interview Type')),
('physical_address', models.CharField(blank=True, max_length=255, null=True)),
('applications', models.ManyToManyField(blank=True, related_name='interview_schedules', to='recruitment.application')),
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
('interview', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='schedule_templates', to='recruitment.interview', verbose_name='Location Template (Zoom/Onsite)')),
@ -438,6 +435,25 @@ class Migration(migrations.Migration):
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='Note',
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')),
('note_type', models.CharField(choices=[('Feedback', 'Candidate Feedback'), ('Logistics', 'Logistical Note'), ('General', 'General Comment')], default='Feedback', max_length=50, verbose_name='Note Type')),
('content', django_ckeditor_5.fields.CKEditor5Field(verbose_name='Content/Feedback')),
('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notes', to='recruitment.application', verbose_name='Application')),
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interview_notes', to=settings.AUTH_USER_MODEL, verbose_name='Author')),
('interview', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notes', to='recruitment.interview', verbose_name='Scheduled Interview')),
],
options={
'verbose_name': 'Interview Note',
'verbose_name_plural': 'Interview Notes',
'ordering': ['created_at'],
},
),
migrations.CreateModel(
name='Notification',
fields=[
@ -478,7 +494,7 @@ class Migration(migrations.Migration):
('profile_image', models.ImageField(blank=True, null=True, upload_to='profile_pic/', validators=[recruitment.validators.validate_image_size], verbose_name='Profile Image')),
('linkedin_profile', models.URLField(blank=True, null=True, verbose_name='LinkedIn Profile URL')),
('agency', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='recruitment.hiringagency', verbose_name='Hiring Agency')),
('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='person_profile', to=settings.AUTH_USER_MODEL, verbose_name='User Account')),
('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='person_profile', to=settings.AUTH_USER_MODEL, verbose_name='User Account')),
],
options={
'verbose_name': 'Person',

View File

@ -1,20 +0,0 @@
# Generated by Django 5.2.7 on 2025-11-28 10:24
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='person',
name='user',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='person_profile', to=settings.AUTH_USER_MODEL, verbose_name='User Account'),
),
]

View File

@ -0,0 +1,56 @@
# Generated by Django 5.2.7 on 2025-12-02 10:28
import django.db.models.deletion
import django_ckeditor_5.fields
import django_extensions.db.fields
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0005_merge_20251202_1308'),
]
operations = [
migrations.CreateModel(
name='EmailContent',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('subject', models.CharField(max_length=255, verbose_name='Subject')),
('message', django_ckeditor_5.fields.CKEditor5Field(verbose_name='Message Body')),
],
options={
'verbose_name': 'Email Content',
'verbose_name_plural': 'Email Contents',
},
),
migrations.AlterField(
model_name='interview',
name='details_url',
field=models.JSONField(blank=True, null=True),
),
migrations.CreateModel(
name='Note',
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')),
('note_type', models.CharField(choices=[('Feedback', 'Candidate Feedback'), ('Logistics', 'Logistical Note'), ('General', 'General Comment')], default='Feedback', max_length=50, verbose_name='Note Type')),
('content', django_ckeditor_5.fields.CKEditor5Field(verbose_name='Content/Feedback')),
('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notes', to='recruitment.application', verbose_name='Application')),
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interview_notes', to=settings.AUTH_USER_MODEL, verbose_name='Author')),
('interview', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notes', to='recruitment.interview', verbose_name='Scheduled Interview')),
],
options={
'verbose_name': 'Interview Note',
'verbose_name_plural': 'Interview Notes',
'ordering': ['created_at'],
},
),
migrations.DeleteModel(
name='InterviewNote',
),
]

View File

@ -1397,6 +1397,7 @@ class BulkInterviewTemplate(Base):
working_days = models.JSONField(
verbose_name=_("Working Days")
)
topic = models.CharField(max_length=255, verbose_name=_("Interview Topic"))
start_time = models.TimeField(verbose_name=_("Start Time"))
end_time = models.TimeField(verbose_name=_("End Time"))
@ -1414,6 +1415,14 @@ class BulkInterviewTemplate(Base):
buffer_time = models.PositiveIntegerField(
verbose_name=_("Buffer Time (minutes)"), default=0
)
schedule_interview_type = models.CharField(
max_length=10,
choices=[('Remote', 'Remote (e.g., Zoom)'), ('Onsite', 'In-Person (Physical Location)')],
default='Onsite',
verbose_name=_("Interview Type"),
)
physical_address = models.CharField(max_length=255, blank=True, null=True)
created_by = models.ForeignKey(
User, on_delete=models.CASCADE, db_index=True
)
@ -1509,7 +1518,7 @@ class ScheduledInterview(Base):
return self.interview_location
# --- 3. Interview Notes Model (Fixed) ---
class InterviewNote(Base):
class Note(Base):
"""Model for storing notes, feedback, or comments related to a specific ScheduledInterview."""
class NoteType(models.TextChoices):
@ -1517,13 +1526,24 @@ class InterviewNote(Base):
LOGISTICS = 'Logistics', _('Logistical Note')
GENERAL = 'General', _('General Comment')
1
application = models.ForeignKey(
Application,
on_delete=models.CASCADE,
related_name="notes",
verbose_name=_("Application"),
db_index=True,
null=True,
blank=True
)
interview = models.ForeignKey(
Interview,
on_delete=models.CASCADE,
related_name="notes",
verbose_name=_("Scheduled Interview"),
db_index=True
db_index=True,
null=True,
blank=True
)
author = models.ForeignKey(
@ -2692,3 +2712,5 @@ class Document(Base):
if self.file:
return self.file.name.split(".")[-1].upper()
return ""

View File

@ -12,7 +12,7 @@ from . linkedin_service import LinkedInService
from django.shortcuts import get_object_or_404
from . models import JobPosting
from django.utils import timezone
from . models import ScheduledInterview,Interview,Message
from . models import BulkInterviewTemplate,Interview,Message,ScheduledInterview
from django.contrib.auth import get_user_model
User = get_user_model()
# Add python-docx import for Word document processing
@ -27,9 +27,9 @@ except ImportError:
logger = logging.getLogger(__name__)
OPENROUTER_API_KEY ='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'
# OPENROUTER_MODEL = 'qwen/qwen-2.5-72b-instruct:free'
OPENROUTER_MODEL = 'qwen/qwen-2.5-72b-instruct'
OPENROUTER_MODEL = 'openai/gpt-oss-20b'
# OPENROUTER_MODEL = 'qwen/qwen-2.5-7b-instruct'
# OPENROUTER_MODEL = 'openai/gpt-oss-20b'
# OPENROUTER_MODEL = 'mistralai/mistral-small-3.2-24b-instruct:free'
@ -623,7 +623,8 @@ def handle_resume_parsing_and_scoring(pk: int):
}}
If a top-level key or its required fields are missing, set the field to null, an empty list, or an empty object as appropriate.
Be Clear and Direct Avoid overly indirect politeness which can add confusion.
Be strict,objective and concise and critical in your responses, and don't give inflated scores to weak candidates.
Output only valid JSONno markdown, no extra text.
"""
@ -668,7 +669,7 @@ def handle_resume_parsing_and_scoring(pk: int):
from django.utils import timezone
def create_interview_and_meeting(
candidate_id,
application_id,
job_id,
schedule_id,
slot_date,
@ -679,24 +680,13 @@ def create_interview_and_meeting(
Synchronous task for a single interview slot, dispatched by django-q.
"""
try:
application = Application.objects.get(pk=candidate_id)
application = Application.objects.get(pk=application_id)
job = JobPosting.objects.get(pk=job_id)
schedule = ScheduledInterview.objects.get(pk=schedule_id)
schedule = BulkInterviewTemplate.objects.get(pk=schedule_id)
interview_datetime = timezone.make_aware(datetime.combine(slot_date, slot_time))
meeting_topic = f"Interview for {job.title} - {application.name}"
meeting_topic = schedule.topic
# 1. External API Call (Slow)
# "status": "success",
# "message": "Meeting created successfully.",
# "meeting_details": {
# "join_url": meeting_data['join_url'],
# "meeting_id": meeting_data['id'],
# "password": meeting_data['password'],
# "host_email": meeting_data['host_email']
# },
# "zoom_gateway_response": meeting_data
# }
result = create_zoom_meeting(meeting_topic, interview_datetime, duration)
if result["status"] == "success":
@ -711,33 +701,19 @@ def create_interview_and_meeting(
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()
# 2. Database Writes (Slow)
# zoom_meeting = ZoomMeetingDetails.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"
# )
# ScheduledInterview.objects.create(
# application=candidate,
# job=job,
# interview_location=zoom_meeting,
# schedule=schedule,
# interview_date=slot_date,
# interview_time=slot_time
# )
# Log success or use Django-Q result system for monitoring
logger.info(f"Successfully scheduled interview for {Application.name}")
return True # Task succeeded
else:

View File

@ -587,16 +587,16 @@ urlpatterns = [
# path('interviews/<slug:slug>/delete/', views.ScheduledInterviewDeleteView.as_view(), name='delete_scheduled_interview'),
#interview and meeting related urls
# path(
# "jobs/<slug:slug>/schedule-interviews/",
# views.schedule_interviews_view,
# name="schedule_interviews",
# ),
# path(
# "jobs/<slug:slug>/confirm-schedule-interviews/",
# views.confirm_schedule_interviews_view,
# name="confirm_schedule_interviews_view",
# ),
path(
"jobs/<slug:slug>/schedule-interviews/",
views.schedule_interviews_view,
name="schedule_interviews",
),
path(
"jobs/<slug:slug>/confirm-schedule-interviews/",
views.confirm_schedule_interviews_view,
name="confirm_schedule_interviews_view",
),
# path(
# "meetings/create-meeting/",
@ -682,5 +682,6 @@ urlpatterns = [
# Email invitation URLs
# path("interviews/meetings/<slug:slug>/send-application-invitation/", views.send_application_invitation, name="send_application_invitation"),
# path("interviews/meetings/<slug:slug>/send-participants-invitation/", views.send_participants_invitation, name="send_participants_invitation"),
path("note/<slug:slug>/application_add_note/", views.application_add_note, name="application_add_note"),
path("note/<slug:slug>/interview_add_note/", views.interview_add_note, name="interview_add_note"),
]

View File

@ -33,7 +33,8 @@ from .forms import (
PasswordResetForm,
StaffAssignmentForm,
RemoteInterviewForm,
OnsiteInterviewForm
OnsiteInterviewForm,
BulkInterviewTemplateForm
)
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
@ -132,7 +133,8 @@ from .models import (
Source,
Message,
Document,
Interview
Interview,
BulkInterviewTemplate
)
@ -517,6 +519,7 @@ def job_detail(request, slug):
job_status = status_form.cleaned_data["status"]
form_template = job.form_template
if job_status == "ACTIVE":
form_template.is_active = True
form_template.save(update_fields=["is_active"])
else:
@ -533,7 +536,9 @@ def job_detail(request, slug):
return redirect("job_detail", slug=slug)
else:
messages.error(request, "Failed to update status due to validation errors.")
error_messages = status_form.errors.get('status', [])
formatted_errors = "<br>".join(error_messages)
messages.error(request, f"{formatted_errors}")
# --- 2. Quality Metrics (JSON Aggregation) ---
@ -605,10 +610,13 @@ def job_detail(request, slug):
if avg_t_in_exam_duration
else 0
)
category_data = (
applications.filter(ai_analysis_data__analysis_data_en__category__isnull=False)
.values("ai_analysis_data__analysis_data_en__category")
applications.filter(
ai_analysis_data__analysis_data_en__category__isnull=False
).exclude(
ai_analysis_data__analysis_data_en__category__exact=None
).values("ai_analysis_data__analysis_data_en__category")
.annotate(
application_count=Count("id"),
category=Cast(
@ -617,6 +625,7 @@ def job_detail(request, slug):
)
.order_by("ai_analysis_data__analysis_data_en__category")
)
# Prepare data for Chart.js
categories = [item["category"] for item in category_data]
applications_count = [item["application_count"] for item in category_data]
@ -711,7 +720,7 @@ def request_cvs_download(request, slug):
if not job.applications.exists():
messages.warning(request, _("No applications found for this job. ZIP file generation skipped."))
return redirect('job_detail', slug=slug)
async_task('recruitment.tasks.generate_and_save_cv_zip', job.id)
# Provide user feedback and redirect
@ -1463,323 +1472,293 @@ def form_submission_details(request, template_id, slug):
)
# def _handle_get_request(request, slug, job):
# """
# Handles GET requests, setting up forms and restoring candidate selections
# from the session for persistence.
# """
# SESSION_KEY = f"schedule_candidate_ids_{slug}"
def _handle_get_request(request, slug, job):
"""
Handles GET requests, setting up forms and restoring candidate selections
from the session for persistence.
"""
SESSION_KEY = f"schedule_candidate_ids_{slug}"
# form = BulkInterviewTemplateForm(slug=slug)
# # break_formset = BreakTimeFormSet(prefix='breaktime')
form = BulkInterviewTemplateForm(slug=slug)
# break_formset = BreakTimeFormSet(prefix='breaktime')
# selected_ids = []
selected_ids = []
# # 1. Capture IDs from HTMX request and store in session (when first clicked)
# if "HX-Request" in request.headers:
# candidate_ids = request.GET.getlist("candidate_ids")
# 1. Capture IDs from HTMX request and store in session (when first clicked)
if "HX-Request" in request.headers:
candidate_ids = request.GET.getlist("candidate_ids")
# if candidate_ids:
# request.session[SESSION_KEY] = candidate_ids
# selected_ids = candidate_ids
if candidate_ids:
request.session[SESSION_KEY] = candidate_ids
selected_ids = candidate_ids
# # 2. Restore IDs from session (on refresh or navigation)
# if not selected_ids:
# selected_ids = request.session.get(SESSION_KEY, [])
# 2. Restore IDs from session (on refresh or navigation)
if not selected_ids:
selected_ids = request.session.get(SESSION_KEY, [])
# # 3. Use the list of IDs to initialize the form
# if selected_ids:
# candidates_to_load = Application.objects.filter(pk__in=selected_ids)
# print(candidates_to_load)
# form.initial["applications"] = candidates_to_load
# 3. Use the list of IDs to initialize the form
if selected_ids:
candidates_to_load = Application.objects.filter(pk__in=selected_ids)
form.initial["applications"] = candidates_to_load
# return render(
# request,
# "interviews/schedule_interviews.html",
# {"form": form, "job": job},
# )
#TODO:MAIN FUNCTIONS
# def _handle_preview_submission(request, slug, job):
# """
# Handles the initial POST request (Preview Schedule).
# Validates forms, calculates slots, saves data to session, and renders preview.
# """
# SESSION_DATA_KEY = "interview_schedule_data"
# form = BulkInterviewTemplateForm(slug, request.POST)
# # break_formset = BreakTimeFormSet(request.POST,prefix='breaktime')
# if form.is_valid():
# # Get the form data
# applications = form.cleaned_data["applications"]
# start_date = form.cleaned_data["start_date"]
# end_date = form.cleaned_data["end_date"]
# working_days = form.cleaned_data["working_days"]
# start_time = form.cleaned_data["start_time"]
# end_time = form.cleaned_data["end_time"]
# interview_duration = form.cleaned_data["interview_duration"]
# buffer_time = form.cleaned_data["buffer_time"]
# break_start_time = form.cleaned_data["break_start_time"]
# break_end_time = form.cleaned_data["break_end_time"]
# schedule_interview_type=form.cleaned_data["schedule_interview_type"]
# # Process break times
# # breaks = []
# # for break_form in break_formset:
# # print(break_form.cleaned_data)
# # if break_form.cleaned_data and not break_form.cleaned_data.get("DELETE"):
# # breaks.append(
# # {
# # "start_time": break_form.cleaned_data["start_time"].strftime("%H:%M:%S"),
# # "end_time": break_form.cleaned_data["end_time"].strftime("%H:%M:%S"),
# # }
# # )
# # Create a temporary schedule object (not saved to DB)
# temp_schedule = BulkInterviewTemplate(
# job=job,
# start_date=start_date,
# end_date=end_date,
# working_days=working_days,
# start_time=start_time,
# end_time=end_time,
# interview_duration=interview_duration,
# buffer_time=buffer_time or 5,
# break_start_time=break_start_time or None,
# break_end_time=break_end_time or None,
# )
# # Get available slots (temp_breaks logic moved into get_available_time_slots if needed)
# available_slots = get_available_time_slots(temp_schedule)
# if len(available_slots) < len(applications):
# messages.error(
# request,
# f"Not enough available slots. Required: {len(applications)}, Available: {len(available_slots)}",
# )
# return render(
# request,
# "interviews/schedule_interviews.html",
# {"form": form, "job": job},
# )
# # Create a preview schedule
# preview_schedule = []
# for i, application in enumerate(applications):
# slot = available_slots[i]
# preview_schedule.append(
# {"application": application, "date": slot["date"], "time": slot["time"]}
# )
# # Save the form data to session for later use
# schedule_data = {
# "start_date": start_date.isoformat(),
# "end_date": end_date.isoformat(),
# "working_days": working_days,
# "start_time": start_time.isoformat(),
# "end_time": end_time.isoformat(),
# "interview_duration": interview_duration,
# "buffer_time": buffer_time,
# "break_start_time": break_start_time.isoformat() if break_start_time else None,
# "break_end_time": break_end_time.isoformat() if break_end_time else None,
# "candidate_ids": [c.id for c in applications],
# "schedule_interview_type":schedule_interview_type
# }
# request.session[SESSION_DATA_KEY] = schedule_data
# # Render the preview page
# return render(
# request,
# "interviews/preview_schedule.html",
# {
# "job": job,
# "schedule": preview_schedule,
# "start_date": start_date,
# "end_date": end_date,
# "working_days": working_days,
# "start_time": start_time,
# "end_time": end_time,
# "break_start_time": break_start_time,
# "break_end_time": break_end_time,
# "interview_duration": interview_duration,
# "buffer_time": buffer_time,
# "schedule_interview_type":schedule_interview_type,
# "form":OnsiteLocationForm()
# },
# )
# else:
# # Re-render the form if validation fails
# return render(
# request,
# "interviews/schedule_interviews.html",
# {"form": form, "job": job},
# )
# def _handle_confirm_schedule(request, slug, job):
# """
# Handles the final POST request (Confirm Schedule).
# Creates the main schedule record and queues individual interviews asynchronously.
# """
# SESSION_DATA_KEY = "interview_schedule_data"
# SESSION_ID_KEY = f"schedule_candidate_ids_{slug}"
# # 1. Get schedule data from session
# schedule_data = request.session.get(SESSION_DATA_KEY)
# if not schedule_data:
# messages.error(request, "Session expired. Please try again.")
# return redirect("schedule_interviews", slug=slug)
# # 2. Create the Interview Schedule (Parent Record)
# try:
# # Handle break times: If they exist, convert them; otherwise, pass None.
# break_start = schedule_data.get("break_start_time")
# break_end = schedule_data.get("break_end_time")
# schedule = BulkInterviewTemplate.objects.create(
# job=job,
# created_by=request.user,
# start_date=datetime.fromisoformat(schedule_data["start_date"]).date(),
# end_date=datetime.fromisoformat(schedule_data["end_date"]).date(),
# working_days=schedule_data["working_days"],
# start_time=time.fromisoformat(schedule_data["start_time"]),
# end_time=time.fromisoformat(schedule_data["end_time"]),
# interview_duration=schedule_data["interview_duration"],
# buffer_time=schedule_data["buffer_time"],
# # Convert time strings to time objects only if they exist and handle None gracefully
# break_start_time=time.fromisoformat(break_start) if break_start else None,
# break_end_time=time.fromisoformat(break_end) if break_end else None,
# schedule_interview_type=schedule_data.get("schedule_interview_type")
# )
# except Exception as e:
# # Clear data on failure to prevent stale data causing repeated errors
# messages.error(request, f"Error creating schedule: {e}")
# if SESSION_ID_KEY in request.session: del request.session[SESSION_ID_KEY]
# if SESSION_DATA_KEY in request.session: del request.session[SESSION_DATA_KEY]
# return redirect("schedule_interviews", slug=slug)
# # 3. Setup candidates and get slots
# candidates = Application.objects.filter(id__in=schedule_data["candidate_ids"])
# schedule.applications.set(candidates)
# available_slots = get_available_time_slots(schedule)
# # 4. Handle Remote/Onsite logic
# if schedule_data.get("schedule_interview_type") == 'Remote':
# # ... (Remote logic remains unchanged)
# queued_count = 0
# for i, candidate in enumerate(candidates):
# if i < len(available_slots):
# slot = available_slots[i]
# async_task(
# "recruitment.tasks.create_interview_and_meeting",
# candidate.pk, job.pk, schedule.pk, slot["date"], slot["time"], schedule.interview_duration,
# )
# queued_count += 1
# messages.success(
# request,
# f"Schedule successfully created. Queued {queued_count} interviews to be booked asynchronously. Check back in a moment!",
# )
# if SESSION_DATA_KEY in request.session: del request.session[SESSION_DATA_KEY]
# if SESSION_ID_KEY in request.session: del request.session[SESSION_ID_KEY]
# return redirect("job_detail", slug=slug)
# elif schedule_data.get("schedule_interview_type") == 'Onsite':
# print("inside...")
# if request.method == 'POST':
# form = OnsiteLocationForm(request.POST)
# if form.is_valid():
# if not available_slots:
# messages.error(request, "No available slots found for the selected schedule range.")
# return render(request, 'interviews/onsite_location_form.html', {'form': form, 'schedule': schedule, 'job': job})
# # Extract common location data from the form
# physical_address = form.cleaned_data['physical_address']
# room_number = form.cleaned_data['room_number']
# topic=form.cleaned_data['topic']
# try:
# # 1. Iterate over candidates and create a NEW Location object for EACH
# for i, candidate in enumerate(candidates):
# if i < len(available_slots):
# slot = available_slots[i]
# location_start_dt = datetime.combine(slot['date'], schedule.start_time)
# # --- CORE FIX: Create a NEW Location object inside the loop ---
# onsite_location = OnsiteLocationDetails.objects.create(
# start_time=location_start_dt,
# duration=schedule.interview_duration,
# physical_address=physical_address,
# room_number=room_number,
# location_type="Onsite",
# topic=topic
# )
# # 2. Create the ScheduledInterview, linking the unique location
# ScheduledInterview.objects.create(
# application=candidate,
# job=job,
# schedule=schedule,
# interview_date=slot['date'],
# interview_time=slot['time'],
# interview_location=onsite_location,
# )
# messages.success(
# request,
# f"Onsite schedule interviews created successfully for {len(candidates)} candidates."
# )
# # Clear session data keys upon successful completion
# if SESSION_DATA_KEY in request.session: del request.session[SESSION_DATA_KEY]
# if SESSION_ID_KEY in request.session: del request.session[SESSION_ID_KEY]
# return redirect('job_detail', slug=job.slug)
# except Exception as e:
# messages.error(request, f"Error creating onsite location/interviews: {e}")
# # On failure, re-render the form with the error and ensure 'job' is present
# return render(request, 'interviews/onsite_location_form.html', {'form': form, 'schedule': schedule, 'job': job})
# else:
# # Form is invalid, re-render with errors
# # Ensure 'job' is passed to prevent NoReverseMatch
# return render(request, 'interviews/onsite_location_form.html', {'form': form, 'schedule': schedule, 'job': job})
# else:
# # For a GET request
# form = OnsiteLocationForm()
# return render(request, 'interviews/onsite_location_form.html', {'form': form, 'schedule': schedule, 'job': job})
return render(
request,
"interviews/schedule_interviews.html",
{"form": form, "job": job},
)
# def schedule_interviews_view(request, slug):
# job = get_object_or_404(JobPosting, slug=slug)
# if request.method == "POST":
# # return _handle_confirm_schedule(request, slug, job)
# return _handle_preview_submission(request, slug, job)
# else:
# return _handle_get_request(request, slug, job)
def _handle_preview_submission(request, slug, job):
"""
Handles the initial POST request (Preview Schedule).
Validates forms, calculates slots, saves data to session, and renders preview.
"""
SESSION_DATA_KEY = "interview_schedule_data"
form = BulkInterviewTemplateForm(slug, request.POST)
# break_formset = BreakTimeFormSet(request.POST,prefix='breaktime')
if form.is_valid():
# Get the form data
applications = form.cleaned_data["applications"]
start_date = form.cleaned_data["start_date"]
end_date = form.cleaned_data["end_date"]
working_days = form.cleaned_data["working_days"]
start_time = form.cleaned_data["start_time"]
end_time = form.cleaned_data["end_time"]
interview_duration = form.cleaned_data["interview_duration"]
buffer_time = form.cleaned_data["buffer_time"]
break_start_time = form.cleaned_data["break_start_time"]
break_end_time = form.cleaned_data["break_end_time"]
schedule_interview_type=form.cleaned_data["schedule_interview_type"]
physical_address=form.cleaned_data["physical_address"]
# Process break times
# breaks = []
# for break_form in break_formset:
# print(break_form.cleaned_data)
# if break_form.cleaned_data and not break_form.cleaned_data.get("DELETE"):
# breaks.append(
# {
# "start_time": break_form.cleaned_data["start_time"].strftime("%H:%M:%S"),
# "end_time": break_form.cleaned_data["end_time"].strftime("%H:%M:%S"),
# }
# )
# Create a temporary schedule object (not saved to DB)
temp_schedule = BulkInterviewTemplate(
job=job,
start_date=start_date,
end_date=end_date,
working_days=working_days,
start_time=start_time,
end_time=end_time,
interview_duration=interview_duration,
buffer_time=buffer_time or 5,
break_start_time=break_start_time or None,
break_end_time=break_end_time or None,
schedule_interview_type=schedule_interview_type,
physical_address=physical_address
)
# Get available slots (temp_breaks logic moved into get_available_time_slots if needed)
available_slots = get_available_time_slots(temp_schedule)
if len(available_slots) < len(applications):
messages.error(
request,
f"Not enough available slots. Required: {len(applications)}, Available: {len(available_slots)}",
)
return render(
request,
"interviews/schedule_interviews.html",
{"form": form, "job": job},
)
# Create a preview schedule
preview_schedule = []
for i, application in enumerate(applications):
slot = available_slots[i]
preview_schedule.append(
{"application": application, "date": slot["date"], "time": slot["time"]}
)
# Save the form data to session for later use
schedule_data = {
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
"working_days": working_days,
"start_time": start_time.isoformat(),
"end_time": end_time.isoformat(),
"interview_duration": interview_duration,
"buffer_time": buffer_time,
"break_start_time": break_start_time.isoformat() if break_start_time else None,
"break_end_time": break_end_time.isoformat() if break_end_time else None,
"candidate_ids": [c.id for c in applications],
"schedule_interview_type":schedule_interview_type,
"physical_address":physical_address,
"topic":form.cleaned_data.get("topic"),
}
request.session[SESSION_DATA_KEY] = schedule_data
# Render the preview page
return render(
request,
"interviews/preview_schedule.html",
{
"job": job,
"schedule": preview_schedule,
"start_date": start_date,
"end_date": end_date,
"working_days": working_days,
"start_time": start_time,
"end_time": end_time,
"break_start_time": break_start_time,
"break_end_time": break_end_time,
"interview_duration": interview_duration,
"buffer_time": buffer_time,
# "schedule_interview_type":schedule_interview_type,
# "form":OnsiteLocationForm()
},
)
else:
# Re-render the form if validation fails
return render(
request,
"interviews/schedule_interviews.html",
{"form": form, "job": job},
)
# def confirm_schedule_interviews_view(request, slug):
# job = get_object_or_404(JobPosting, slug=slug)
# if request.method == "POST":
# return _handle_confirm_schedule(request, slug, job)
def _handle_confirm_schedule(request, slug, job):
"""
Handles the final POST request (Confirm Schedule).
Creates the main schedule record and queues individual interviews asynchronously.
"""
SESSION_DATA_KEY = "interview_schedule_data"
SESSION_ID_KEY = f"schedule_candidate_ids_{slug}"
# 1. Get schedule data from session
schedule_data = request.session.get(SESSION_DATA_KEY)
if not schedule_data:
messages.error(request, "Session expired. Please try again.")
return redirect("schedule_interviews", slug=slug)
# 2. Create the Interview Schedule (Parent Record)
try:
# Handle break times: If they exist, convert them; otherwise, pass None.
break_start = schedule_data.get("break_start_time")
break_end = schedule_data.get("break_end_time")
schedule = BulkInterviewTemplate.objects.create(
job=job,
created_by=request.user,
start_date=datetime.fromisoformat(schedule_data["start_date"]).date(),
end_date=datetime.fromisoformat(schedule_data["end_date"]).date(),
working_days=schedule_data["working_days"],
start_time=time.fromisoformat(schedule_data["start_time"]),
end_time=time.fromisoformat(schedule_data["end_time"]),
interview_duration=schedule_data["interview_duration"],
buffer_time=schedule_data["buffer_time"],
break_start_time=time.fromisoformat(break_start) if break_start else None,
break_end_time=time.fromisoformat(break_end) if break_end else None,
schedule_interview_type=schedule_data.get("schedule_interview_type"),
physical_address=schedule_data.get("physical_address"),
topic=schedule_data.get("topic"),
)
except Exception as e:
# Clear data on failure to prevent stale data causing repeated errors
messages.error(request, f"Error creating schedule: {e}")
if SESSION_ID_KEY in request.session: del request.session[SESSION_ID_KEY]
if SESSION_DATA_KEY in request.session: del request.session[SESSION_DATA_KEY]
return redirect("schedule_interviews", slug=slug)
applications = Application.objects.filter(id__in=schedule_data["candidate_ids"])
schedule.applications.set(applications)
available_slots = get_available_time_slots(schedule)
if schedule_data.get("schedule_interview_type") == 'Remote':
queued_count = 0
for i, application in enumerate(applications):
if i < len(available_slots):
slot = available_slots[i]
async_task(
"recruitment.tasks.create_interview_and_meeting",
application.pk, job.pk, schedule.pk, slot["date"], slot["time"], schedule.interview_duration,
)
queued_count += 1
messages.success(
request,
f"Schedule successfully created. Queued {queued_count} interviews to be booked asynchronously. Check back in a moment!",
)
if SESSION_DATA_KEY in request.session: del request.session[SESSION_DATA_KEY]
if SESSION_ID_KEY in request.session: del request.session[SESSION_ID_KEY]
return redirect("job_detail", slug=slug)
elif schedule_data.get("schedule_interview_type") == 'Onsite':
try:
for i, application in enumerate(applications):
if i < len(available_slots):
slot = available_slots[i]
start_dt = datetime.combine(slot['date'], schedule.start_time)
interview = Interview.objects.create(
topic=schedule.topic,
start_time=start_dt,
duration=schedule.interview_duration,
location_type="Onsite",
physical_address=schedule.physical_address,
)
# 2. Create the ScheduledInterview, linking the unique location
ScheduledInterview.objects.create(
application=application,
job=job,
schedule=schedule,
interview_date=slot['date'],
interview_time=slot['time'],
interview=interview
)
messages.success(
request,
f"created successfully for {len(applications)} application."
)
# Clear session data keys upon successful completion
if SESSION_DATA_KEY in request.session: del request.session[SESSION_DATA_KEY]
if SESSION_ID_KEY in request.session: del request.session[SESSION_ID_KEY]
return redirect('job_detail', slug=job.slug)
except Exception as e:
messages.error(request, f"Error creating onsite interviews: {e}")
return redirect("schedule_interviews", slug=slug)
def schedule_interviews_view(request, slug):
job = get_object_or_404(JobPosting, slug=slug)
if request.method == "POST":
# return _handle_confirm_schedule(request, slug, job)
return _handle_preview_submission(request, slug, job)
else:
# if request.session.get("interview_schedule_data"):
print(request.session.get("interview_schedule_data"))
return _handle_get_request(request, slug, job)
# return redirect("applications_interview_view", slug=slug)
def confirm_schedule_interviews_view(request, slug):
job = get_object_or_404(JobPosting, slug=slug)
if request.method == "POST":
# print(request.session['interview_schedule_data'])
return _handle_confirm_schedule(request, slug, job)
@staff_user_required
@ -4716,7 +4695,7 @@ def message_detail(request, message_id):
@login_required
def message_create(request):
"""Create a new message"""
from .email_service import EmailService
from .email_service import EmailService
if request.method == "POST":
form = MessageForm(request.user, request.POST)
@ -4727,10 +4706,7 @@ def message_create(request):
# Send email if message_type is 'email' and recipient has email
if message.recipient and message.recipient.email:
try:
email_result = async_task('recruitment.tasks._task_send_individual_email',
subject=message.subject,
body_message=message.content,
@ -4758,9 +4734,35 @@ def message_create(request):
messages.error(request, "Please correct the errors below.")
else:
form = MessageForm(request.user)
form.fields["job"].widget.attrs.update({"hx-get": "/en/messages/create/",
"hx-target": "#id_recipient",
"hx-select": "#id_recipient",
"hx-swap": "outerHTML",})
if request.user.user_type == "staff":
job_id = request.GET.get("job")
if job_id:
job = get_object_or_404(JobPosting, id=job_id)
applications=job.applications.all()
applicant_users = User.objects.filter(person_profile__in=applications.values_list('person', flat=True))
agency_users = User.objects.filter(id__in=AgencyJobAssignment.objects.filter(job=job).values_list('agency__user', flat=True))
form.fields["recipient"].queryset = applicant_users | agency_users
# form.fields["recipient"].queryset = User.objects.filter(person_profile__)
else:
form.fields['recipient'].widget = HiddenInput()
if request.method == "GET" and "HX-Request" in request.headers and request.user.user_type in ["candidate","agency"]:
print()
job_id = request.GET.get("job")
if job_id:
job = get_object_or_404(JobPosting, id=job_id)
form.fields["recipient"].queryset = User.objects.filter(id=job.assigned_to.id)
form.fields["recipient"].initial = job.assigned_to
context = {
"form": form,
}
@ -4896,7 +4898,7 @@ def message_delete(request, message_id):
Redirects to the message list on success (either via standard redirect
or HTMX's hx-redirect header).
"""
# 1. Retrieve the message
# Use select_related to fetch linked objects efficiently for checks/logging
message = get_object_or_404(
@ -4907,13 +4909,13 @@ def message_delete(request, message_id):
# Only the sender or recipient can delete the message
if message.sender != request.user and message.recipient != request.user:
messages.error(request, "You don't have permission to delete this message.")
# HTMX requests should handle redirection via client-side logic (hx-redirect)
if "HX-Request" in request.headers:
# Returning 403 or 400 is ideal, but 200 with an empty body is often accepted
# by HTMX and the message is shown on the next page/refresh.
return HttpResponse(status=403)
return HttpResponse(status=403)
# Standard navigation redirect
return redirect("message_list")
@ -4927,7 +4929,7 @@ def message_delete(request, message_id):
# 1. Set the HTMX response header for redirection
response = HttpResponse(status=200)
response["HX-Redirect"] = reverse("message_list") # <--- EXPLICIT HEADER
return response
return response
# Standard navigation fallback
return redirect("message_list")
@ -5097,7 +5099,8 @@ def document_upload(request, slug):
if upload_target == 'person':
return redirect("applicant_portal_dashboard")
else:
return redirect("applicant_application_detail", slug=application.slug)
return render(request, 'recruitment/application_detail.html', {'application': application})
# return redirect("application_detail", slug=application.slug)
# Handle GET request for AJAX
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
@ -5109,7 +5112,6 @@ def document_upload(request, slug):
def document_delete(request, document_id):
"""Delete a document"""
document = get_object_or_404(Document, id=document_id)
print(document)
# Initialize variables for redirection outside of the complex logic
is_htmx = "HX-Request" in request.headers
@ -5172,7 +5174,9 @@ def document_delete(request, document_id):
if is_htmx or request.headers.get("X-Requested-With") == "XMLHttpRequest":
# For HTMX, return a 200 OK. The front-end is expected to use hx-swap='outerHTML'
# to remove the element, or hx-redirect to navigate.
return HttpResponse(status=200)
response = HttpResponse(status=200)
response["HX-Refresh"] = "true" # Instruct HTMX to refresh the current view
return response
# --- Standard Navigation Fallback ---
else:
@ -6583,3 +6587,57 @@ def interview_detail(request, slug):
# messages.error(request, f"Failed to send invitation emails: {str(e)}")
# return redirect('meeting_details', slug=slug)
def application_add_note(request, slug):
from .models import Note
from .forms import NoteForm
application = get_object_or_404(Application, slug=slug)
notes = Note.objects.filter(application=application).order_by('-created_at')
if request.method == 'POST':
form = NoteForm(request.POST)
if form.is_valid():
form.save()
# messages.success(request, "Note added successfully.")
else:
messages.error(request, "Note content cannot be empty.")
return render(request, 'recruitment/partials/note_form.html', {'notes':notes})
else:
form = NoteForm()
form.initial['application'] = application
form.fields['application'].widget = HiddenInput()
form.fields['interview'].widget = HiddenInput()
form.initial['author'] = request.user
form.fields['author'].widget = HiddenInput()
url = reverse('application_add_note', kwargs={'slug':slug})
notes = Note.objects.filter(application=application).order_by('-created_at')
return render(request, 'recruitment/partials/note_form.html', {'form': form,'instance':application,'notes':notes,'url':url})
def interview_add_note(request, slug):
from .models import Note
from .forms import NoteForm
interview = get_object_or_404(Interview, slug=slug)
if request.method == 'POST':
form = NoteForm(request.POST)
if form.is_valid():
form.save()
messages.success(request, "Note added successfully.")
else:
messages.error(request, "Note content cannot be empty.")
return redirect('interview_detail', slug=slug)
else:
form = NoteForm()
form.initial['interview'] = interview
form.fields['interview'].widget = HiddenInput()
form.fields['application'].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()})

View File

@ -50,7 +50,7 @@
<nav class="navbar navbar-expand-lg navbar-dark sticky-top">
<div class="container-fluid max-width-1600">
<a class="navbar-brand text-white d-none d-lg-block me-4 pe-4" href="{% url 'dashboard' %}" aria-label="Home">
<img src="{% static 'image/kaauh_green1.png' %}" alt="{% trans 'kaauh logo green bg' %}" style="width: 60px; height: 60px;">
@ -206,7 +206,7 @@
</form>
{% endif %}
</li>
<li class="d-lg-none"><a class="dropdown-item py-2 px-4 d-flex align-items-center text-decoration-none text-teal" href="{% url 'message_list' %}"> <i class="fas fa-envelope fs-5 me-3"></i> <span>{% trans "Messages" %}</span></a></li>
{% if request.user.is_authenticated %}
<li><a class="dropdown-item py-2 px-4 d-flex align-items-center text-decoration-none text-teal" href="{% url 'user_detail' request.user.pk %}"><i class="fas fa-user-circle me-3 fs-5"></i> <span>{% trans "My Profile" %}</span></a></li>
@ -293,10 +293,10 @@
</a>
</li>
<li class="nav-item me-lg-4">
<a class="nav-link {% if request.resolver_match.url_name == 'list_meetings' %}active{% endif %}" href="{% url 'interview_list' %}">
<a class="nav-link {% if request.resolver_match.url_name == 'interview_list' %}active{% endif %}" href="{% url 'interview_list' %}">
<span class="d-flex align-items-center gap-2">
<i class="fas fa-calendar-check me-2"></i>
{% trans "Meetings" %}
{% trans "Meetings & interviews" %}
</span>
</a>
</li>
@ -444,11 +444,25 @@
});
});
}
function remove_form_loader(){
const forms = document.querySelectorAll('form');
forms.forEach(form => {
form.addEventListener('htmx:afterRequest', function(evt) {
const submitButton = form.querySelector('button[type="submit"], input[type="submit"]');
if (submitButton) {
submitButton.disabled = false;
submitButton.classList.remove('loading');
}
});
});
}
//form_loader();
try{
document.addEventListener('htmx:afterSwap', form_loader);
document.body.addEventListener('htmx:afterRequest', function(evt) {
remove_form_loader();
});
}catch(e){
console.error(e)
}

View File

@ -1,6 +1,7 @@
{% load static %}
{% load file_filters %}
{% load i18n %}
<div class="card shadow-sm">
<div class="card-header bg-white border-bottom d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0 text-primary">{% trans "Documents" %}</h5>
@ -25,12 +26,8 @@
<form
method="post"
action="{% url 'application_document_upload' application.slug %}"
enctype="multipart/form-data"
hx-post="{% url 'application_document_upload' application.slug %}"
hx-target="#documents-pane"
hx-select="#documents-pane"
hx-swap="outerHTML"
hx-on::after-request="bootstrap.Modal.getInstance(document.getElementById('documentUploadModal')).hide()"
>
{% csrf_token %}
<div class="modal-body">
@ -64,7 +61,7 @@
id="documentDescription"
rows="3"
class="form-control"
placeholder="{% trans "Optional description..." %}"
placeholder='{% trans "Optional description..." %}'
></textarea>
</div>
</div>
@ -101,22 +98,23 @@
<div class="d-flex align-items-center">
<a
href="{% url 'document_download' document.id %}"
class="btn btn-sm btn-outline-primary me-2"
title="{% trans "Download" %}"
>
<i class="fas fa-download"></i>
href="{% url 'document_download' document.id %}"
class="btn btn-sm btn-outline-primary me-2"
title='{% trans "Download" %}'
>
<i class="fas fa-download"></i>
</a>
{% if user.is_superuser or application.job.assigned_to == user %}
<button
<a
hx-post="{% url 'document_delete' document.id %}"
hx-confirm='{% trans "Are you sure you want to delete" %}'
type="button"
class="btn btn-sm btn-outline-danger"
onclick="confirmDelete({{ document.id }}, '{{ document.file.name|filename|default:"Document" }}')"
title="{% trans "Delete" %}"
title='{% trans "Delete" %}'
>
<i class="fas fa-trash"></i>
</button>
</a>
{% endif %}
</div>
</div>
@ -131,6 +129,7 @@
</div>
</div>
<style>
.hover-bg-light:hover {
background-color: #f8f9fa;
@ -139,7 +138,7 @@
</style>
<script>
function confirmDelete(documentId, fileName) {
/*function confirmDelete(documentId, fileName) {
var deletePrefix = "{% trans "Are you sure you want to delete" %}";
if (confirm(deletePrefix + ' "' + fileName + '"?')) {
htmx.ajax('POST', `{% url 'document_delete' 0 %}`.replace('0', documentId), {
@ -147,5 +146,16 @@ function confirmDelete(documentId, fileName) {
swap: 'innerHTML'
});
}
}
*/
function closeUploadModal() {
var modalElement = document.getElementById('documentUploadModal');
if (modalElement) {
var modal = bootstrap.Modal.getInstance(modalElement);
if (modal) {
modal.hide();
}
}
}
</script>

View File

@ -170,11 +170,7 @@
</tbody>
</table>
</div>
{% if schedule_interview_type == "Onsite" %}
<button type="submit" name="confirm_schedule" class="btn btn-teal-primary px-4" data-bs-toggle="modal" data-bs-target="#interviewDetailsModal" data-placement="top">
<i class="fas fa-check me-2"></i> {% trans "Confirm Schedule" %}
</button>
{% else %}
<form method="post" action="{% url 'confirm_schedule_interviews_view' slug=job.slug %}" class="mt-4 d-flex justify-content-end gap-3">
{% csrf_token %}
<a href="{% url 'schedule_interviews' slug=job.slug %}" class="btn btn-secondary px-4">
@ -184,7 +180,6 @@
<i class="fas fa-check me-2"></i> {% trans "Confirm Schedule" %}
</button>
</form>
{% endif %}
</div>
</div>
</div>
@ -196,20 +191,20 @@
<h5 class="modal-title" id="interviewDetailsModalLabel">{% trans "Interview Details" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body"> <form method="post" action="{% url 'confirm_schedule_interviews_view' job.slug %}" enctype="multipart/form-data" id="onsite-form">
{% comment %} <div class="modal-body"> <form method="post" action="{% url 'confirm_schedule_interviews_view' job.slug %}" enctype="multipart/form-data" id="onsite-form">
{% csrf_token %}
{# Renders the single 'location' field using the crispy filter #}
{{ form|crispy }}
</form>
{{ form|crispy }}
</form> {% endcomment %}
</div>
<div class="modal-footer">
<div class="d-flex align-items-center justify-content-between mt-4 mb-0">
<a href="{% url 'list_meetings' %}" class="btn btn-secondary me-2">
<div class="d-flex align-items-center justify-content-between mt-4 mb-0">
<a href="#" class="btn btn-secondary me-2">
<i class="fas fa-times me-1"></i> Close
</a>
<button type="submit" class="btn btn-primary" form="onsite-form">
</a>
<button type="submit" class="btn btn-primary" form="onsite-form">
<i class="fas fa-save me-1"></i> Save Location
</button>
</div>

View File

@ -1,5 +1,6 @@
{% extends 'base.html' %}
{% load static i18n %}
{% load widget_tweaks %}
{% block title %}Bulk Interview Scheduling - {{ job.title }} - ATS{% endblock %}
@ -125,7 +126,6 @@
<div class="row">
<div class="col-md-4">
<h5 class="section-header">{% trans "Select Candidates" %}</h5>
<div class="form-group">
<label for="{{ form.candidates.id_for_label }}">
{% trans "Candidates to Schedule (Hold Ctrl/Cmd to select multiple)" %}
@ -141,14 +141,19 @@
<h5 class="section-header">{% trans "Schedule Details" %}</h5>
<div class="row">
<div class="col-md-12">
<div class="form-group mb-3">
<label for="{{ form.topic.id_for_label }}">{% trans "Topic" %}</label>
{{ form.topic }}
</div>
</div>
</div>
<div class="row">
<div class="col-md-4">
<div class="form-group mb-3">
<label for="{{ form.schedule_interview_type.id_for_label }}">{% trans "Interview Type" %}</label>
{{ form.schedule_interview_type }}
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
@ -217,8 +222,17 @@
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-8">
<div class="form-group mb-3">
<label for="{{ form.physical_address.id_for_label }}">{% trans "Physical Address" %}</label>
{{ form.physical_address }}
{% if form.physical_address.errors %}
<div class="text-danger small mt-1">{{ form.physical_address.errors }}</div>
{% endif %}
</div>
</div>
</div>
</div>

View File

@ -1,5 +1,5 @@
{% extends "portal_base.html" %}
{% load static %}
{% load static crispy_forms_tags %}
{% load i18n %}
{% block title %}{% if form.instance.pk %}{% trans "Reply to Message" %}{% else %}{% trans "Compose Message" %}{% endif %}{% endblock %}
@ -11,23 +11,23 @@
<div class="card-header">
<h5 class="mb-0">
{% if form.instance.pk %}
<i class="fas fa-reply"></i> Reply to Message
<i class="fas fa-reply"></i> {% trans "Reply to Message" %}
{% else %}
<i class="fas fa-envelope"></i> Compose Message
<i class="fas fa-envelope"></i> {% trans "Compose Message" %}
{% endif %}
</h5>
</div>
<div class="card-body">
{% if form.instance.parent_message %}
<div class="alert alert-info mb-4">
<strong>Replying to:</strong> {{ form.instance.parent_message.subject }}
<strong>{% trans "Replying to:" %}</strong> {{ form.instance.parent_message.subject }}
<br>
<small class="text-muted">
From {{ form.instance.parent_message.sender.get_full_name|default:form.instance.parent_message.sender.username }}
on {{ form.instance.parent_message.created_at|date:"M d, Y H:i" }}
{% trans "From" %} {{ form.instance.parent_message.sender.get_full_name|default:form.instance.parent_message.sender.username }}
{% trans "on" %} {{ form.instance.parent_message.created_at|date:"M d, Y H:i" }}
</small>
<div class="mt-2">
<strong>Original message:</strong>
<strong>{% trans "Original message:" %}</strong>
<div class="border-start ps-3 mt-2">
{{ form.instance.parent_message.content|linebreaks }}
</div>
@ -38,99 +38,17 @@
<form method="post" id="messageForm">
{% csrf_token %}
<div class="row">
<div class="col-md-4">
<div class="mb-3">
<label for="{{ form.job.id_for_label }}" class="form-label">
Related Job <span class="text-danger">*</span>
</label>
{{ form.job }}
{% if form.job.errors %}
<div class="text-danger small mt-1">
{{ form.job.errors.0 }}
</div>
{% endif %}
<div class="form-text">
Select a job if this message is related to a specific position
</div>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label for="{{ form.recipient.id_for_label }}" class="form-label">
Recipient <span class="text-danger">*</span>
</label>
{{ form.recipient }}
{% if form.recipient.errors %}
<div class="text-danger small mt-1">
{{ form.recipient.errors.0 }}
</div>
{% endif %}
<div class="form-text">
Select the user who will receive this message
</div>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label for="{{ form.message_type.id_for_label }}" class="form-label">
Message Type <span class="text-danger">*</span>
</label>
{{ form.message_type }}
{% if form.message_type.errors %}
<div class="text-danger small mt-1">
{{ form.message_type.errors.0 }}
</div>
{% endif %}
<div class="form-text">
Select the type of message you're sending
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="{{ form.subject.id_for_label }}" class="form-label">
Subject <span class="text-danger">*</span>
</label>
{{ form.subject }}
{% if form.subject.errors %}
<div class="text-danger small mt-1">
{{ form.subject.errors.0 }}
</div>
{% endif %}
</div>
</div>
</div>
<div class="mb-3">
<label for="{{ form.content.id_for_label }}" class="form-label">
Message <span class="text-danger">*</span>
</label>
{{ form.content }}
{% if form.content.errors %}
<div class="text-danger small mt-1">
{{ form.content.errors.0 }}
</div>
{% endif %}
<div class="form-text">
Write your message here. You can use line breaks and basic formatting.
</div>
</div>
{{form|crispy}}
<div class="d-flex justify-content-between">
<a href="{% url 'message_list' %}" class="btn btn-secondary">
<i class="fas fa-times"></i> Cancel
<a href="{% url 'message_list' %}" class="btn btn-outline-primary">
<i class="fas fa-times"></i> {% trans "Cancel" %}
</a>
<button type="submit" class="btn btn-main-action">
<i class="fas fa-paper-plane"></i>
{% if form.instance.pk %}
Send Reply
{% trans "Send Reply" %}
{% else %}
Send Message
{% trans "Send Message" %}
{% endif %}
</button>
</div>
@ -184,6 +102,7 @@ document.addEventListener('DOMContentLoaded', function() {
// Character counter for subject
const subjectField = document.getElementById('id_subject');
const maxLength = 200;
const charsLabel = "{% trans 'characters' %}";
if (subjectField) {
// Add character counter display
@ -194,7 +113,7 @@ document.addEventListener('DOMContentLoaded', function() {
function updateCounter() {
const remaining = maxLength - subjectField.value.length;
counter.textContent = `${subjectField.value.length}/${maxLength} characters`;
counter.textContent = `${subjectField.value.length}/${maxLength} ${charsLabel}`;
if (remaining < 20) {
counter.className = 'text-warning';
} else {
@ -216,19 +135,19 @@ document.addEventListener('DOMContentLoaded', function() {
if (!recipient) {
e.preventDefault();
alert('Please select a recipient.');
alert("{% trans 'Please select a recipient.' %}");
return false;
}
if (!subject) {
e.preventDefault();
alert('Please enter a subject.');
alert("{% trans 'Please enter a subject.' %}");
return false;
}
if (!content) {
e.preventDefault();
alert('Please enter a message.');
alert("{% trans 'Please enter a message.' %}");
return false;
}
});

View File

@ -480,7 +480,7 @@
{# TAB 5 CONTENT: PARSED SUMMARY #}
{% if application.parsed_summary %}
<div class="tab-pane fade" id="summary-pane" role="tabpanel" aria-labelledby="summary-tab">
<h5 class="text-primary mb-4">{% trans "AI Generated Summary" %}</h5>
<div class="border-start border-primary ps-3 pt-1 pb-1">
@ -663,7 +663,7 @@
<i class="fas fa-eye me-1"></i>
{% trans "View Actual Resume" %}
</a> {% endcomment %}
<a href="{{ application.resume.url }}" download class="btn btn-outline-primary">
<i class="fas fa-download me-1"></i>
{% trans "Download Resume" %}

View File

@ -278,7 +278,7 @@
{% trans "To Offer" %}
</option>
</select>
<button type="submit" class="btn btn-main-action btn-sm">
<button id="changeStage" type="submit" class="btn btn-main-action btn-sm">
<i class="fas fa-arrow-right me-1"></i> {% trans "Change Stage" %}
</button>
</form>
@ -286,7 +286,7 @@
{# Separator (Vertical Rule) - Aligns automatically at the bottom with align-items-end #}
<div class="vr" style="height: 28px;"></div>
<button type="button" class="btn btn-outline-primary btn-sm"
<button id="emailBotton" type="button" class="btn btn-outline-primary btn-sm"
data-bs-toggle="modal"
hx-boost='true'
data-bs-target="#emailModal"
@ -323,6 +323,9 @@
<th scope="col" style="width: 28%;">
<i class="fas fa-file-alt me-1"></i> {% trans "Documents" %}
</th>
<th scope="col" style="width: 10%;">
<i class="fas fa-file-alt me-1"></i> {% trans "Notes" %}
</th>
<th scope="col" style="width: 10%;">
<i class="fas fa-cog me-1"></i> {% trans "Actions" %}
</th>
@ -394,6 +397,15 @@
{% endif %}
{% endwith %}
</td>
<td><button type="button" class="btn btn-outline-primary btn-sm"
data-bs-toggle="modal"
data-bs-target="#noteModal"
hx-get="{% url 'application_add_note' application.slug %}"
hx-swap="outerHTML"
hx-target=".notemodal">
<i class="fas fa-calendar-plus me-1"></i>
Add note
</button></td>
<td class="text-center">
<button type="button" class="btn btn-outline-secondary btn-sm"
data-bs-toggle="modal"
@ -462,51 +474,78 @@
</div>
</div>
</div>
{% include "recruitment/partials/note_modal.html" %}
{% endblock %}
{% block customJS %}
<script>
document.addEventListener('DOMContentLoaded', function () {
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
const rowCheckboxes = document.querySelectorAll('.rowCheckbox');
document.addEventListener('DOMContentLoaded', function () {
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
const rowCheckboxes = document.querySelectorAll('.rowCheckbox');
const changeStageButton = document.getElementById('changeStage');
const emailButton = document.getElementById('emailBotton');
const updateStatus = document.getElementById('update_status');
if (selectAllCheckbox) {
// Function to safely update header checkbox state
function updateSelectAllState() {
const checkedCount = Array.from(rowCheckboxes).filter(cb => cb.checked).length;
const totalCount = rowCheckboxes.length;
if (selectAllCheckbox) {
if (checkedCount === 0) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
} else if (checkedCount === totalCount) {
selectAllCheckbox.checked = true;
selectAllCheckbox.indeterminate = false;
} else {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = true;
// Function to safely update the header checkbox state
function updateSelectAllState() {
const checkedCount = Array.from(rowCheckboxes).filter(cb => cb.checked).length;
const totalCount = rowCheckboxes.length;
if (checkedCount === 0) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
changeStageButton.disabled = true;
emailButton.disabled = true;
updateStatus.disabled = true;
} else if (checkedCount === totalCount) {
selectAllCheckbox.checked = true;
selectAllCheckbox.indeterminate = false;
changeStageButton.disabled = false;
emailButton.disabled = false;
updateStatus.disabled = false;
} else {
// Set to indeterminate state (partially checked)
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = true;
changeStageButton.disabled = false;
emailButton.disabled = false;
updateStatus.disabled = false;
}
}
}
// 1. Logic for 'Select All' checkbox (Clicking it updates all rows)
selectAllCheckbox.addEventListener('change', function () {
const isChecked = selectAllCheckbox.checked;
// 1. Logic for the 'Select All' checkbox (Clicking it updates all rows)
selectAllCheckbox.addEventListener('change', function () {
const isChecked = selectAllCheckbox.checked;
rowCheckboxes.forEach(checkbox => {
checkbox.checked = isChecked;
checkbox.dispatchEvent(new Event('change', { bubbles: true }));
// Temporarily disable the change listener on rows to prevent cascading events
rowCheckboxes.forEach(checkbox => checkbox.removeEventListener('change', updateSelectAllState));
// Update all row checkboxes
rowCheckboxes.forEach(function (checkbox) {
checkbox.checked = isChecked;
// Dispatch event for the framework (data-bind-selections)
checkbox.dispatchEvent(new Event('change', { bubbles: true }));
});
// Re-attach the change listeners to the rows
rowCheckboxes.forEach(checkbox => checkbox.addEventListener('change', updateSelectAllState));
// Ensure the header state is correct after forcing all changes
updateSelectAllState();
});
// 2. Logic to update 'Select All' state based on row checkboxes
// Attach the function to be called whenever a row checkbox changes
rowCheckboxes.forEach(function (checkbox) {
checkbox.addEventListener('change', updateSelectAllState);
});
// Initial check to set the correct state on load (in case items are pre-checked)
updateSelectAllState();
});
// 2. Logic to update 'Select All' state based on row checkboxes
rowCheckboxes.forEach(function (checkbox) {
checkbox.addEventListener('change', updateSelectAllState);
});
// Initial check to set correct state on load
updateSelectAllState();
}
});
}
});
</script>
{% endblock %}

View File

@ -225,11 +225,11 @@
</div>
{# Button #}
<button type="submit" class="btn btn-main-action btn-sm">
<button id="changeStage" type="submit" class="btn btn-main-action btn-sm">
<i class="fas fa-arrow-right me-1"></i> {% trans "Change Stage" %}
</button>
<button type="button" class="btn btn-outline-primary btn-sm"
<button id="emailBotton" type="button" class="btn btn-outline-primary btn-sm"
data-bs-toggle="modal"
hx-boost='true'
data-bs-target="#emailModal"
@ -261,9 +261,10 @@
<th style="width: 15%;">{% trans "Name" %}</th>
<th style="width: 15%;">{% trans "Contact Info" %}</th>
<th style="width: 10%;" class="text-center">{% trans "AI Score" %}</th>
<th style="width: 15%;">{% trans "Exam Date" %}</th>
<th style="width: 15%;">{% trans "Exam Score" %}</th>
<th style="width: 10%;">{% trans "Exam Date" %}</th>
<th style="width: 10%;">{% trans "Exam Score" %}</th>
<th style="width: 10%;" class="text-center">{% trans "Exam Results" %}</th>
<th style="width: 10%"> {% trans "Notes"%}</th>
<th style="width: 15%;">{% trans "Actions" %}</th>
</tr>
</thead>
@ -324,6 +325,15 @@
{% endif %}
{% endif %}
</td>
<td><button type="button" class="btn btn-outline-primary btn-sm"
data-bs-toggle="modal"
data-bs-target="#noteModal"
hx-get="{% url 'application_add_note' application.slug %}"
hx-swap="outerHTML"
hx-target=".notemodal">
<i class="fas fa-calendar-plus me-1"></i>
Add note
</button></td>
<td >
<button type="button" class="btn btn-outline-secondary btn-sm"
@ -395,14 +405,18 @@
</div>
</div>
</div>
{% include "recruitment/partials/note_modal.html" %}
{% endblock %}
{% block customJS %}
<script>
document.addEventListener('DOMContentLoaded', function () {
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
const rowCheckboxes = document.querySelectorAll('.rowCheckbox');
const changeStageButton = document.getElementById('changeStage');
const emailButton = document.getElementById('emailBotton');
const updateStatus = document.getElementById('update_status');
if (selectAllCheckbox) {
@ -414,13 +428,22 @@
if (checkedCount === 0) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
changeStageButton.disabled = true;
emailButton.disabled = true;
updateStatus.disabled = true;
} else if (checkedCount === totalCount) {
selectAllCheckbox.checked = true;
selectAllCheckbox.indeterminate = false;
changeStageButton.disabled = false;
emailButton.disabled = false;
updateStatus.disabled = false;
} else {
// Set to indeterminate state (partially checked)
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = true;
changeStageButton.disabled = false;
emailButton.disabled = false;
updateStatus.disabled = false;
}
}

View File

@ -218,7 +218,7 @@
{% trans "To Exam" %}
</option>
</select>
<button type="submit" class="btn btn-main-action btn-sm">
<button id="changeStage" type="submit" class="btn btn-main-action btn-sm">
<i class="fas fa-arrow-right me-1"></i> {% trans "Change Stage" %}
</button>
</form>
@ -227,23 +227,23 @@
<div class="vr" style="height: 28px;"></div>
{# Form 2: Schedule Interviews #}
<form hx-boost="true" hx-include="#application-form" action="#" method="get" class="action-group">
<button type="submit" class="btn btn-main-action btn-sm">
<i class="fas fa-calendar-plus me-1"></i> {% trans "Schedule Interviews" %}
<form hx-boost="true" hx-include="#application-form" action="{% url 'schedule_interviews' job.slug %}" method="get" class="action-group">
<button id="scheduleInterview" type="submit" class="btn btn-main-action btn-sm">
<i class="fas fa-calendar-plus me-1"></i> {% trans "Bulk Schedule Interviews" %}
</button>
</form>
<div class="vr" style="height: 28px;"></div>
<button type="button" class="btn btn-outline-info btn-sm"
data-bs-toggle="modal"
hx-boost='true'
data-bs-target="#emailModal"
hx-get="{% url 'compose_application_email' job.slug %}"
hx-target="#emailModalBody"
hx-include="#application-form"
title="Email Participants">
<i class="fas fa-envelope"></i>
<button id="emailBotton" type="button" class="btn btn-outline-info btn-sm"
data-bs-toggle="modal"
hx-boost='true'
data-bs-target="#emailModal"
hx-get="{% url 'compose_application_email' job.slug %}"
hx-target="#emailModalBody"
hx-include="#application-form"
title="Email Participants">
<i class="fas fa-envelope"></i>
</button>
</div>
</div>
@ -271,8 +271,9 @@
<th style="width: 10%"><i class="fas fa-calendar me-1"></i> {% trans "Meeting Date" %}</th>
<th style="width: 7%"><i class="fas fa-video me-1"></i> {% trans "Link" %}</th>
<th style="width: 8%"><i class="fas fa-check-circle me-1"></i> {% trans "Meeting Status" %}</th> {% endcomment %}
<th style="width: 15%"><i class="fas fa-check-circle me-1"></i> {% trans "Interview Result"%}</th>
<th style="width: 15%"><i class="fas fa-check-circle me-1"></i> {% trans "Interview List"%}</th>
<th style="width: 10%"><i class="fas fa-video me-1"></i> {% trans "Interviews"%}</th>
<th style="width: 10%"><i class="fas fa-sticky-note me-1"></i> {% trans "Notes"%}</th>
<th style="width: 5%"><i class="fas fa-check-circle me-1"></i> {% trans "Result"%}</th>
<th style="width: 10%"><i class="fas fa-cog me-1"></i> {% trans "Actions" %}</th>
</tr>
</thead>
@ -353,6 +354,26 @@
{% endif %}
{% endwith %}
</td> {% endcomment %}
<td>
<button type="button" class="btn btn-outline-primary btn-sm"
data-bs-toggle="modal"
data-bs-target="#candidateviewModal"
hx-get="{% url 'get_interview_list' application.slug %}"
hx-target="#candidateviewModalBody">
View
<i class="fas fa-list"></i>
{{candidate.get_interviews}}
</button>
</td>
<td><button type="button" class="btn btn-outline-primary btn-sm"
data-bs-toggle="modal"
data-bs-target="#noteModal"
hx-get="{% url 'application_add_note' application.slug %}"
hx-swap="outerHTML"
hx-target=".notemodal">
<i class="fas fa-calendar-plus me-1"></i>
Add note
</button></td>
<td class="text-center" id="interview-result-{{ application.pk }}">
{% if not application.interview_status %}
<button type="button" class="btn btn-warning btn-sm"
@ -379,18 +400,6 @@
{% endif %}
</td>
<td>
<button type="button" class="btn btn-outline-primary btn-sm"
data-bs-toggle="modal"
data-bs-target="#candidateviewModal"
hx-get="{% url 'get_interview_list' application.slug %}"
hx-target="#candidateviewModalBody">
Interview List
<i class="fas fa-list"></i>
{{candidate.get_interviews}}
</button>
</td>
<td>
{% if application.get_latest_meeting %}
{% if application.get_latest_meeting.location_type == 'Remote'%}
@ -445,7 +454,6 @@
<i class="fas fa-calendar-plus me-1"></i>
Schedule Interview
</button>
{% endif %}
</td>
</tr>
@ -504,6 +512,9 @@
</div>
</div>
{% include "recruitment/partials/note_modal.html" %}
{% endblock %}
{% block customJS %}
@ -511,6 +522,10 @@
document.addEventListener('DOMContentLoaded', function () {
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
const rowCheckboxes = document.querySelectorAll('.rowCheckbox');
const changeStageButton = document.getElementById('changeStage');
const emailButton = document.getElementById('emailBotton');
const updateStatus = document.getElementById('update_status');
const scheduleInterviewButton = document.getElementById('scheduleInterview');
if (selectAllCheckbox) {
@ -522,13 +537,25 @@
if (checkedCount === 0) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
changeStageButton.disabled = true;
emailButton.disabled = true;
updateStatus.disabled = true;
scheduleInterviewButton.disabled = true;
} else if (checkedCount === totalCount) {
selectAllCheckbox.checked = true;
selectAllCheckbox.indeterminate = false;
changeStageButton.disabled = false;
emailButton.disabled = false;
updateStatus.disabled = false;
scheduleInterviewButton.disabled = false;
} else {
// Set to indeterminate state (partially checked)
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = true;
changeStageButton.disabled = false;
emailButton.disabled = false;
updateStatus.disabled = false;
scheduleInterviewButton.disabled = false;
}
}

View File

@ -220,26 +220,23 @@
</select>
{# Button #}
<button type="submit" class="btn btn-main-action btn-sm">
<button id="changeStage" type="submit" class="btn btn-main-action btn-sm">
<i class="fas fa-arrow-right me-1"></i> {% trans "Change Stage" %}
</button>
</form>
{# Separator (Vertical Rule) #}
<div class="vr" style="height: 28px;"></div>
<button type="button" class="btn btn-outline-primary btn-sm"
data-bs-toggle="modal"
hx-boost='true'
data-bs-target="#emailModal"
hx-get="{% url 'compose_application_email' job.slug %}"
hx-target="#emailModalBody"
hx-include="#application-form"
title="Email Participants">
<i class="fas fa-envelope"></i>
<button id="emailBotton" type="button" class="btn btn-outline-primary btn-sm"
data-bs-toggle="modal"
hx-boost='true'
data-bs-target="#emailModal"
hx-get="{% url 'compose_application_email' job.slug %}"
hx-target="#emailModalBody"
hx-include="#application-form"
title="Email Participants">
<i class="fas fa-envelope"></i>
</button>
</div>
@ -263,8 +260,9 @@
<th style="width: 15%"><i class="fas fa-phone me-1"></i> {% trans "Contact" %}</th>
<th class="text-center" style="width: 10%"><i class="fas fa-check-circle me-1"></i> {% trans "Offer" %}</th>
<th scope="col" style="width: 30%;">
<i class="fas fa-file-alt me-1"></i> {% trans "Documents" %}
</th>
<i class="fas fa-file-alt me-1"></i> {% trans "Documents" %}
</th>
<th style="width: 10%"><i class="fas fa-phone me-1"></i> {% trans "Notes" %}</th>
<th style="width: 5%"><i class="fas fa-cog me-1"></i> {% trans "Actions" %}</th>
</tr>
</thead>
@ -356,6 +354,15 @@
{% endif %}
{% endwith %}
</td>
<td><button type="button" class="btn btn-outline-primary btn-sm"
data-bs-toggle="modal"
data-bs-target="#noteModal"
hx-get="{% url 'application_add_note' application.slug %}"
hx-swap="outerHTML"
hx-target=".notemodal">
<i class="fas fa-calendar-plus me-1"></i>
Add note
</button></td>
<td>
<button type="button" class="btn btn-outline-secondary btn-sm"
data-bs-toggle="modal"
@ -423,6 +430,8 @@
</div>
</div>
</div>
{% include "recruitment/partials/note_modal.html" %}
{% endblock %}
{% block customJS %}
@ -430,6 +439,9 @@
document.addEventListener('DOMContentLoaded', function () {
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
const rowCheckboxes = document.querySelectorAll('.rowCheckbox');
const changeStageButton = document.getElementById('changeStage');
const emailButton = document.getElementById('emailBotton');
const updateStatus = document.getElementById('update_status');
if (selectAllCheckbox) {
@ -441,13 +453,22 @@
if (checkedCount === 0) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
changeStageButton.disabled = true;
emailButton.disabled = true;
updateStatus.disabled = true;
} else if (checkedCount === totalCount) {
selectAllCheckbox.checked = true;
selectAllCheckbox.indeterminate = false;
changeStageButton.disabled = false;
emailButton.disabled = false;
updateStatus.disabled = false;
} else {
// Set to indeterminate state (partially checked)
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = true;
changeStageButton.disabled = false;
emailButton.disabled = false;
updateStatus.disabled = false;
}
}

View File

@ -340,11 +340,11 @@
</div>
{# Button #}
<button type="submit" class="btn btn-main-action btn-sm">
<button id="changeStage" type="submit" class="btn btn-main-action btn-sm">
<i class="fas fa-arrow-right me-1"></i> {% trans "Change Stage" %}
</button>
{# email button#}
<button type="button" class="btn btn-outline-primary btn-sm"
<button id="emailBotton" type="button" class="btn btn-outline-primary btn-sm"
data-bs-toggle="modal"
hx-boost='true'
data-bs-target="#emailModal"
@ -374,28 +374,31 @@
</div>
{% endif %}
</th>
<th scope="col" style="width: 8%;">
<th scope="col" style="width: 13%;">
<i class="fas fa-user me-1"></i> {% trans "Name" %}
</th>
<th scope="col" style="width: 10%;">
<th scope="col" style="width: 15%;">
<i class="fas fa-phone me-1"></i> {% trans "Contact Info" %}
</th>
<th scope="col" style="width: 5%;">
<i class="fas fa-graduation-cap me-1"></i> {% trans "GPA" %}
</th>
<th scope="col" style="width: 6%;" class="text-center">
<th scope="col" style="width: 5%;" class="text-center">
<i class="fas fa-robot me-1"></i> {% trans "AI Score" %}
</th>
<th scope="col" style="width: 15%;" class="text-center">
<th scope="col" style="width: 10%;" class="text-center">
<i class="fas fa-robot me-1"></i> {% trans "Is Qualified?" %}
</th>
<th scope="col" style="width: 10%;">
<th scope="col" style="width: 20%;">
<i class="fas fa-graduation-cap me-1"></i> {% trans "Professional Category" %}
</th>
<th scope="col" style="width: 15%;">
<i class="fas fa-graduation-cap me-1"></i> {% trans "Top 3 Skills" %}
</th>
<th scope="col" style="width: 10%;" class="text-center">
<i class="fas fa-cog me-1"></i> {% trans "Note" %}
</th>
<th scope="col" style="width: 5%;" class="text-center">
<i class="fas fa-cog me-1"></i> {% trans "Actions" %}
</th>
</tr>
@ -466,7 +469,15 @@
</div>
{% endif %}
</td>
<td><button type="button" class="btn btn-outline-primary btn-sm"
data-bs-toggle="modal"
data-bs-target="#noteModal"
hx-get="{% url 'application_add_note' application.slug %}"
hx-swap="outerHTML"
hx-target=".notemodal">
<i class="fas fa-calendar-plus me-1"></i>
Add note
</button></td>
<td class="text-center">
<button type="button" class="btn btn-outline-secondary btn-sm"
data-bs-toggle="modal"
@ -536,6 +547,8 @@
</div>
</div>
</div>
{% include "recruitment/partials/note_modal.html" %}
{% endblock %}
@ -544,8 +557,12 @@
document.addEventListener('DOMContentLoaded', function () {
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
const rowCheckboxes = document.querySelectorAll('.rowCheckbox');
const changeStageButton = document.getElementById('changeStage');
const emailButton = document.getElementById('emailBotton');
const updateStatus = document.getElementById('update_status');
if (selectAllCheckbox) {
// Function to safely update the header checkbox state
function updateSelectAllState() {
const checkedCount = Array.from(rowCheckboxes).filter(cb => cb.checked).length;
@ -554,12 +571,22 @@
if (checkedCount === 0) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
changeStageButton.disabled = true;
emailButton.disabled = true;
updateStatus.disabled = true;
} else if (checkedCount === totalCount) {
selectAllCheckbox.checked = true;
selectAllCheckbox.indeterminate = false;
changeStageButton.disabled = false;
emailButton.disabled = false;
updateStatus.disabled = false;
} else {
// Set to indeterminate state (partially checked)
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = true;
changeStageButton.disabled = false;
emailButton.disabled = false;
updateStatus.disabled = false;
}
}
@ -567,18 +594,26 @@
selectAllCheckbox.addEventListener('change', function () {
const isChecked = selectAllCheckbox.checked;
// Temporarily disable the change listener on rows to prevent cascading events
rowCheckboxes.forEach(checkbox => checkbox.removeEventListener('change', updateSelectAllState));
// Update all row checkboxes
rowCheckboxes.forEach(function (checkbox) {
checkbox.checked = isChecked;
// Dispatch event for the framework (data-bind-selections)
checkbox.dispatchEvent(new Event('change', { bubbles: true }));
});
// Re-attach the change listeners to the rows
rowCheckboxes.forEach(checkbox => checkbox.addEventListener('change', updateSelectAllState));
// Ensure the header state is correct after forcing all changes
updateSelectAllState();
});
// 2. Logic to update 'Select All' state based on row checkboxes
// Attach the function to be called whenever a row checkbox changes
rowCheckboxes.forEach(function (checkbox) {
checkbox.addEventListener('change', updateSelectAllState);
});

View File

@ -0,0 +1,52 @@
{% load i18n crispy_forms_tags %}
<div class="p-3">
<form hx-boost="true" id="noteform" action="{{url}}" method="post" hx-select=".note-table-body" hx-target=".note-table-body" hx-swap="outerHTML" hx-push-url="false">
{% csrf_token %}
{{form|crispy}}
<div class="modal-footer">
<button type="button" id="notesubmit" class="btn btn-outline-secondary" data-bs-dismiss="modal">{% trans "Cancel" %}</button>
<button type="submit" class="btn btn-main-action" id="saveNoteBtn">{% trans "Save Note" %}</button>
</div>
</form>
<div class="table-responsive mt-3">
<table class="table table-sm" id="notesTable">
<thead>
<tr>
<th scope="col">{% trans "Author" %}</th>
<th scope="col" style="width: 60%;">{% trans "Note" %}</th>
<th scope="col">{% trans "Created" %}</th>
<th scope="col" class="text-end">{% trans "Actions" %}</th>
</tr>
</thead>
<tbody class="note-table-body">
{% if notes %}
{% for note in notes %}
<tr id="note-{{ note.id }}">
<td class="align-middle">
{{ note.author.get_full_name|default:note.author.username }}
</td>
<td class="align-middle">
{{ note.content|linebreaksbr }}
</td>
<td class="align-middle text-nowrap">
<span class="text-muted">
{{ note.created_at|date:"SHORT_DATETIME_FORMAT" }}
</span>
</td>
<td class="align-middle text-end">
<button type="button" class="btn btn-sm btn-outline-danger delete-note-btn"
data-note-id="{{ note.id }}">
{% trans "Delete" %}
</button>
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="4" class="text-center text-muted py-3">{% trans "No notes yet." %}</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>

View File

@ -0,0 +1,13 @@
<div class="modal fade" id="noteModal" tabindex="-1" aria-labelledby="noteModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content kaauh-card">
<div class="modal-header" style="border-bottom: 1px solid var(--kaauh-border);">
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body notemodal">
</div>
</div>
</div>
</div>
</div>