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_UNIQUE_EMAIL = True
ACCOUNT_EMAIL_VERIFICATION = 'none' ACCOUNT_EMAIL_VERIFICATION = 'none'
ACCOUNT_USER_MODEL_USERNAME_FIELD = None ACCOUNT_USER_MODEL_USERNAME_FIELD = None
ACCOUNT_EMAIL_VERIFICATION = "mandatory" ACCOUNT_EMAIL_VERIFICATION = "optional"
ACCOUNT_LOGIN_ON_EMAIL_CONFIRMATION = True ACCOUNT_LOGIN_ON_EMAIL_CONFIRMATION = True

View File

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

View File

@ -18,7 +18,7 @@ from .models import (
BulkInterviewTemplate, BulkInterviewTemplate,
BreakTime, BreakTime,
JobPostingImage, JobPostingImage,
InterviewNote, Note,
ScheduledInterview, ScheduledInterview,
Source, Source,
HiringAgency, HiringAgency,
@ -720,94 +720,100 @@ class FormTemplateForm(forms.ModelForm):
# BreakTimeFormSet = formset_factory(BreakTimeForm, extra=1, can_delete=True) # BreakTimeFormSet = formset_factory(BreakTimeForm, extra=1, can_delete=True)
# class BulkInterviewTemplateForm(forms.ModelForm): class BulkInterviewTemplateForm(forms.ModelForm):
# applications = forms.ModelMultipleChoiceField( applications = forms.ModelMultipleChoiceField(
# queryset=Application.objects.none(), queryset=Application.objects.none(),
# widget=forms.CheckboxSelectMultiple, widget=forms.CheckboxSelectMultiple,
# required=True, required=True,
# ) )
# working_days = forms.MultipleChoiceField( working_days = forms.MultipleChoiceField(
# choices=[ choices=[
# (0, "Monday"), (0, "Monday"),
# (1, "Tuesday"), (1, "Tuesday"),
# (2, "Wednesday"), (2, "Wednesday"),
# (3, "Thursday"), (3, "Thursday"),
# (4, "Friday"), (4, "Friday"),
# (5, "Saturday"), (5, "Saturday"),
# (6, "Sunday"), (6, "Sunday"),
# ], ],
# widget=forms.CheckboxSelectMultiple, widget=forms.CheckboxSelectMultiple,
# required=True, required=True,
# ) )
# class Meta: class Meta:
# model = BulkInterviewTemplate model = BulkInterviewTemplate
# fields = [ fields = [
# 'schedule_interview_type', 'schedule_interview_type',
# "applications", 'topic',
# "start_date", 'physical_address',
# "end_date", "applications",
# "working_days", "start_date",
# "start_time", "end_date",
# "end_time", "working_days",
# "interview_duration", "start_time",
# "buffer_time", "end_time",
# "break_start_time", "interview_duration",
# "break_end_time", "buffer_time",
# ] "break_start_time",
# widgets = { "break_end_time",
# "start_date": forms.DateInput( ]
# attrs={"type": "date", "class": "form-control"} widgets = {
# ), "topic": forms.TextInput(attrs={"class": "form-control"}),
# "end_date": forms.DateInput( "start_date": forms.DateInput(
# attrs={"type": "date", "class": "form-control"} attrs={"type": "date", "class": "form-control"}
# ), ),
# "start_time": forms.TimeInput( "end_date": forms.DateInput(
# attrs={"type": "time", "class": "form-control"} attrs={"type": "date", "class": "form-control"}
# ), ),
# "end_time": forms.TimeInput( "start_time": forms.TimeInput(
# attrs={"type": "time", "class": "form-control"} attrs={"type": "time", "class": "form-control"}
# ), ),
# "interview_duration": forms.NumberInput(attrs={"class": "form-control"}), "end_time": forms.TimeInput(
# "buffer_time": forms.NumberInput(attrs={"class": "form-control"}), attrs={"type": "time", "class": "form-control"}
# "break_start_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_end_time": forms.TimeInput( "break_start_time": forms.TimeInput(
# attrs={"type": "time", "class": "form-control"} attrs={"type": "time", "class": "form-control"}
# ), ),
# "schedule_interview_type":forms.RadioSelect() "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): def __init__(self, slug, *args, **kwargs):
# super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# self.fields["applications"].queryset = Application.objects.filter( self.fields["applications"].queryset = Application.objects.filter(
# job__slug=slug, stage="Interview" job__slug=slug, stage="Interview"
# ) )
# def clean_working_days(self): def clean_working_days(self):
# working_days = self.cleaned_data.get("working_days") working_days = self.cleaned_data.get("working_days")
# return [int(day) for day in working_days] return [int(day) for day in working_days]
# class InterviewNoteForm(forms.ModelForm): class NoteForm(forms.ModelForm):
# """Form for creating and editing meeting comments""" """Form for creating and editing meeting comments"""
# class Meta: class Meta:
# model = InterviewNote model = Note
# fields = ["content"] fields = "__all__"
# widgets = { widgets = {
# "content": CKEditor5Widget( "content": CKEditor5Widget(
# attrs={ attrs={
# "class": "form-control", "class": "form-control",
# "placeholder": _("Enter your comment or note"), "placeholder": _("Enter your comment or note"),
# }, },
# config_name="extends", config_name="extends",
# ), ),
# } }
# labels = { labels = {
# "content": _("Comment"), "content": _("Note"),
# } }
# def __init__(self, *args, **kwargs): # def __init__(self, *args, **kwargs):
# super().__init__(*args, **kwargs) # super().__init__(*args, **kwargs)
@ -892,6 +898,15 @@ class JobPostingStatusForm(forms.ModelForm):
"status": forms.Select(attrs={"class": "form-select"}), "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): class LinkedPostContentForm(forms.ModelForm):
class Meta: class Meta:
@ -2090,23 +2105,19 @@ class CandidateEmailForm(forms.Form):
from django.forms import HiddenInput
class MessageForm(forms.ModelForm): class MessageForm(forms.ModelForm):
"""Form for creating and editing messages between users""" """Form for creating and editing messages between users"""
class Meta: class Meta:
model = Message model = Message
fields = ["recipient", "job", "subject", "content", "message_type"] fields = ["job","recipient", "subject", "content", "message_type"]
widgets = { widgets = {
"recipient": forms.Select( "recipient": forms.Select(
attrs={"class": "form-select", "placeholder": "Select recipient","required": True,} attrs={"class": "form-select", "placeholder": "Select recipient","required": True,}
), ),
"job": forms.Select( "job": forms.Select(
attrs={"class": "form-select", "placeholder": "Select job", attrs={"class": "form-select", "placeholder": "Select job"}
"hx-get": "/en/messages/create/",
"hx-target": "#id_recipient",
"hx-select": "#id_recipient",
"hx-swap": "outerHTML",}
), ),
"subject": forms.TextInput( "subject": forms.TextInput(
attrs={ attrs={
@ -2211,6 +2222,8 @@ class MessageForm(forms.ModelForm):
user_type="staff" user_type="staff"
).order_by("username") ).order_by("username")
def clean(self): def clean(self):
"""Validate message form data""" """Validate message form data"""
cleaned_data = super().clean() cleaned_data = super().clean()

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.models
import django.contrib.auth.validators import django.contrib.auth.validators
@ -31,6 +31,18 @@ class Migration(migrations.Migration):
('end_time', models.TimeField(verbose_name='End Time')), ('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( migrations.CreateModel(
name='FormStage', name='FormStage',
fields=[ 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')), ('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')), ('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')), ('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')), ('timezone', models.CharField(default='UTC', max_length=50, verbose_name='Timezone')),
('start_time', models.DateTimeField(db_index=True, verbose_name='Start Time')), ('start_time', models.DateTimeField(db_index=True, verbose_name='Start Time')),
('duration', models.PositiveIntegerField(verbose_name='Duration (minutes)')), ('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')), ('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)), ('password', models.CharField(blank=True, max_length=20, null=True)),
('zoom_gateway_response', models.JSONField(blank=True, 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)), ('participant_video', models.BooleanField(default=True)),
('join_before_host', models.BooleanField(default=False)), ('join_before_host', models.BooleanField(default=False)),
('host_email', models.CharField(blank=True, max_length=255, null=True)), ('host_email', models.CharField(blank=True, max_length=255, null=True)),
@ -278,24 +290,6 @@ class Migration(migrations.Migration):
'verbose_name_plural': 'Applications', '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( migrations.CreateModel(
name='JobPosting', name='JobPosting',
fields=[ fields=[
@ -363,12 +357,15 @@ class Migration(migrations.Migration):
('start_date', models.DateField(db_index=True, verbose_name='Start Date')), ('start_date', models.DateField(db_index=True, verbose_name='Start Date')),
('end_date', models.DateField(db_index=True, verbose_name='End Date')), ('end_date', models.DateField(db_index=True, verbose_name='End Date')),
('working_days', models.JSONField(verbose_name='Working Days')), ('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')), ('start_time', models.TimeField(verbose_name='Start Time')),
('end_time', models.TimeField(verbose_name='End Time')), ('end_time', models.TimeField(verbose_name='End Time')),
('break_start_time', models.TimeField(blank=True, null=True, verbose_name='Break Start 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')), ('break_end_time', models.TimeField(blank=True, null=True, verbose_name='Break End Time')),
('interview_duration', models.PositiveIntegerField(verbose_name='Interview Duration (minutes)')), ('interview_duration', models.PositiveIntegerField(verbose_name='Interview Duration (minutes)')),
('buffer_time', models.PositiveIntegerField(default=0, verbose_name='Buffer Time (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')), ('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)), ('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)')), ('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'], '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( migrations.CreateModel(
name='Notification', name='Notification',
fields=[ 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')), ('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')), ('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')), ('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={ options={
'verbose_name': 'Person', '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( working_days = models.JSONField(
verbose_name=_("Working Days") verbose_name=_("Working Days")
) )
topic = models.CharField(max_length=255, verbose_name=_("Interview Topic"))
start_time = models.TimeField(verbose_name=_("Start Time")) start_time = models.TimeField(verbose_name=_("Start Time"))
end_time = models.TimeField(verbose_name=_("End Time")) end_time = models.TimeField(verbose_name=_("End Time"))
@ -1414,6 +1415,14 @@ class BulkInterviewTemplate(Base):
buffer_time = models.PositiveIntegerField( buffer_time = models.PositiveIntegerField(
verbose_name=_("Buffer Time (minutes)"), default=0 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( created_by = models.ForeignKey(
User, on_delete=models.CASCADE, db_index=True User, on_delete=models.CASCADE, db_index=True
) )
@ -1509,7 +1518,7 @@ class ScheduledInterview(Base):
return self.interview_location return self.interview_location
# --- 3. Interview Notes Model (Fixed) --- # --- 3. Interview Notes Model (Fixed) ---
class InterviewNote(Base): class Note(Base):
"""Model for storing notes, feedback, or comments related to a specific ScheduledInterview.""" """Model for storing notes, feedback, or comments related to a specific ScheduledInterview."""
class NoteType(models.TextChoices): class NoteType(models.TextChoices):
@ -1517,13 +1526,24 @@ class InterviewNote(Base):
LOGISTICS = 'Logistics', _('Logistical Note') LOGISTICS = 'Logistics', _('Logistical Note')
GENERAL = 'General', _('General Comment') 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 = models.ForeignKey(
Interview, Interview,
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name="notes", related_name="notes",
verbose_name=_("Scheduled Interview"), verbose_name=_("Scheduled Interview"),
db_index=True db_index=True,
null=True,
blank=True
) )
author = models.ForeignKey( author = models.ForeignKey(
@ -2692,3 +2712,5 @@ class Document(Base):
if self.file: if self.file:
return self.file.name.split(".")[-1].upper() return self.file.name.split(".")[-1].upper()
return "" return ""

View File

@ -12,7 +12,7 @@ from . linkedin_service import LinkedInService
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from . models import JobPosting from . models import JobPosting
from django.utils import timezone 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 from django.contrib.auth import get_user_model
User = get_user_model() User = get_user_model()
# Add python-docx import for Word document processing # Add python-docx import for Word document processing
@ -27,9 +27,9 @@ except ImportError:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
OPENROUTER_API_KEY ='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' 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 = 'openai/gpt-oss-20b'
# OPENROUTER_MODEL = 'mistralai/mistral-small-3.2-24b-instruct:free' # 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. 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. 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 from django.utils import timezone
def create_interview_and_meeting( def create_interview_and_meeting(
candidate_id, application_id,
job_id, job_id,
schedule_id, schedule_id,
slot_date, slot_date,
@ -679,24 +680,13 @@ def create_interview_and_meeting(
Synchronous task for a single interview slot, dispatched by django-q. Synchronous task for a single interview slot, dispatched by django-q.
""" """
try: try:
application = Application.objects.get(pk=candidate_id) application = Application.objects.get(pk=application_id)
job = JobPosting.objects.get(pk=job_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)) 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) result = create_zoom_meeting(meeting_topic, interview_datetime, duration)
if result["status"] == "success": if result["status"] == "success":
@ -711,33 +701,19 @@ def create_interview_and_meeting(
password=result["meeting_details"]["password"], password=result["meeting_details"]["password"],
location_type="Remote" 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.interview = interview
schedule.status = "scheduled" schedule.status = "scheduled"
schedule.save() 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}") logger.info(f"Successfully scheduled interview for {Application.name}")
return True # Task succeeded return True # Task succeeded
else: else:

View File

@ -587,16 +587,16 @@ urlpatterns = [
# path('interviews/<slug:slug>/delete/', views.ScheduledInterviewDeleteView.as_view(), name='delete_scheduled_interview'), # path('interviews/<slug:slug>/delete/', views.ScheduledInterviewDeleteView.as_view(), name='delete_scheduled_interview'),
#interview and meeting related urls #interview and meeting related urls
# path( path(
# "jobs/<slug:slug>/schedule-interviews/", "jobs/<slug:slug>/schedule-interviews/",
# views.schedule_interviews_view, views.schedule_interviews_view,
# name="schedule_interviews", name="schedule_interviews",
# ), ),
# path( path(
# "jobs/<slug:slug>/confirm-schedule-interviews/", "jobs/<slug:slug>/confirm-schedule-interviews/",
# views.confirm_schedule_interviews_view, views.confirm_schedule_interviews_view,
# name="confirm_schedule_interviews_view", name="confirm_schedule_interviews_view",
# ), ),
# path( # path(
# "meetings/create-meeting/", # "meetings/create-meeting/",
@ -682,5 +682,6 @@ urlpatterns = [
# Email invitation URLs # 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-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("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, PasswordResetForm,
StaffAssignmentForm, StaffAssignmentForm,
RemoteInterviewForm, RemoteInterviewForm,
OnsiteInterviewForm OnsiteInterviewForm,
BulkInterviewTemplateForm
) )
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods from django.views.decorators.http import require_http_methods
@ -132,7 +133,8 @@ from .models import (
Source, Source,
Message, Message,
Document, Document,
Interview Interview,
BulkInterviewTemplate
) )
@ -517,6 +519,7 @@ def job_detail(request, slug):
job_status = status_form.cleaned_data["status"] job_status = status_form.cleaned_data["status"]
form_template = job.form_template form_template = job.form_template
if job_status == "ACTIVE": if job_status == "ACTIVE":
form_template.is_active = True form_template.is_active = True
form_template.save(update_fields=["is_active"]) form_template.save(update_fields=["is_active"])
else: else:
@ -533,7 +536,9 @@ def job_detail(request, slug):
return redirect("job_detail", slug=slug) return redirect("job_detail", slug=slug)
else: 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) --- # --- 2. Quality Metrics (JSON Aggregation) ---
@ -607,8 +612,11 @@ def job_detail(request, slug):
) )
category_data = ( category_data = (
applications.filter(ai_analysis_data__analysis_data_en__category__isnull=False) applications.filter(
.values("ai_analysis_data__analysis_data_en__category") 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( .annotate(
application_count=Count("id"), application_count=Count("id"),
category=Cast( category=Cast(
@ -617,6 +625,7 @@ def job_detail(request, slug):
) )
.order_by("ai_analysis_data__analysis_data_en__category") .order_by("ai_analysis_data__analysis_data_en__category")
) )
# Prepare data for Chart.js # Prepare data for Chart.js
categories = [item["category"] for item in category_data] categories = [item["category"] for item in category_data]
applications_count = [item["application_count"] for item in category_data] applications_count = [item["application_count"] for item in category_data]
@ -1463,323 +1472,293 @@ def form_submission_details(request, template_id, slug):
) )
# def _handle_get_request(request, slug, job): def _handle_get_request(request, slug, job):
# """ """
# Handles GET requests, setting up forms and restoring candidate selections Handles GET requests, setting up forms and restoring candidate selections
# from the session for persistence. from the session for persistence.
# """ """
# SESSION_KEY = f"schedule_candidate_ids_{slug}" SESSION_KEY = f"schedule_candidate_ids_{slug}"
# form = BulkInterviewTemplateForm(slug=slug) form = BulkInterviewTemplateForm(slug=slug)
# # break_formset = BreakTimeFormSet(prefix='breaktime') # break_formset = BreakTimeFormSet(prefix='breaktime')
# selected_ids = [] selected_ids = []
# # 1. Capture IDs from HTMX request and store in session (when first clicked) # 1. Capture IDs from HTMX request and store in session (when first clicked)
# if "HX-Request" in request.headers: if "HX-Request" in request.headers:
# candidate_ids = request.GET.getlist("candidate_ids") candidate_ids = request.GET.getlist("candidate_ids")
# if candidate_ids: if candidate_ids:
# request.session[SESSION_KEY] = candidate_ids request.session[SESSION_KEY] = candidate_ids
# selected_ids = candidate_ids selected_ids = candidate_ids
# # 2. Restore IDs from session (on refresh or navigation) # 2. Restore IDs from session (on refresh or navigation)
# if not selected_ids: if not selected_ids:
# selected_ids = request.session.get(SESSION_KEY, []) selected_ids = request.session.get(SESSION_KEY, [])
# # 3. Use the list of IDs to initialize the form # 3. Use the list of IDs to initialize the form
# if selected_ids: if selected_ids:
# candidates_to_load = Application.objects.filter(pk__in=selected_ids) candidates_to_load = Application.objects.filter(pk__in=selected_ids)
# print(candidates_to_load) form.initial["applications"] = candidates_to_load
# form.initial["applications"] = candidates_to_load
# return render( return render(
# request, request,
# "interviews/schedule_interviews.html", "interviews/schedule_interviews.html",
# {"form": form, "job": job}, {"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})
# def schedule_interviews_view(request, slug): def _handle_preview_submission(request, slug, job):
# job = get_object_or_404(JobPosting, slug=slug) """
# if request.method == "POST": Handles the initial POST request (Preview Schedule).
# # return _handle_confirm_schedule(request, slug, job) Validates forms, calculates slots, saves data to session, and renders preview.
# return _handle_preview_submission(request, slug, job) """
# else: SESSION_DATA_KEY = "interview_schedule_data"
# return _handle_get_request(request, slug, job) 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): def _handle_confirm_schedule(request, slug, job):
# job = get_object_or_404(JobPosting, slug=slug) """
# if request.method == "POST": Handles the final POST request (Confirm Schedule).
# return _handle_confirm_schedule(request, slug, job) 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 @staff_user_required
@ -4727,10 +4706,7 @@ def message_create(request):
# Send email if message_type is 'email' and recipient has email # Send email if message_type is 'email' and recipient has email
if message.recipient and message.recipient.email: if message.recipient and message.recipient.email:
try: try:
email_result = async_task('recruitment.tasks._task_send_individual_email', email_result = async_task('recruitment.tasks._task_send_individual_email',
subject=message.subject, subject=message.subject,
body_message=message.content, body_message=message.content,
@ -4758,9 +4734,35 @@ def message_create(request):
messages.error(request, "Please correct the errors below.") messages.error(request, "Please correct the errors below.")
else: else:
form = MessageForm(request.user) 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 = { context = {
"form": form, "form": form,
} }
@ -5097,7 +5099,8 @@ def document_upload(request, slug):
if upload_target == 'person': if upload_target == 'person':
return redirect("applicant_portal_dashboard") return redirect("applicant_portal_dashboard")
else: 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 # Handle GET request for AJAX
if request.headers.get("X-Requested-With") == "XMLHttpRequest": if request.headers.get("X-Requested-With") == "XMLHttpRequest":
@ -5109,7 +5112,6 @@ def document_upload(request, slug):
def document_delete(request, document_id): def document_delete(request, document_id):
"""Delete a document""" """Delete a document"""
document = get_object_or_404(Document, id=document_id) document = get_object_or_404(Document, id=document_id)
print(document)
# Initialize variables for redirection outside of the complex logic # Initialize variables for redirection outside of the complex logic
is_htmx = "HX-Request" in request.headers 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": 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' # 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. # 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 --- # --- Standard Navigation Fallback ---
else: else:
@ -6583,3 +6587,57 @@ def interview_detail(request, slug):
# messages.error(request, f"Failed to send invitation emails: {str(e)}") # messages.error(request, f"Failed to send invitation emails: {str(e)}")
# return redirect('meeting_details', slug=slug) # 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

@ -293,10 +293,10 @@
</a> </a>
</li> </li>
<li class="nav-item me-lg-4"> <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"> <span class="d-flex align-items-center gap-2">
<i class="fas fa-calendar-check me-2"></i> <i class="fas fa-calendar-check me-2"></i>
{% trans "Meetings" %} {% trans "Meetings & interviews" %}
</span> </span>
</a> </a>
</li> </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(); //form_loader();
try{ try{
document.addEventListener('htmx:afterSwap', form_loader); document.body.addEventListener('htmx:afterRequest', function(evt) {
remove_form_loader();
});
}catch(e){ }catch(e){
console.error(e) console.error(e)
} }

View File

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

View File

@ -170,11 +170,7 @@
</tbody> </tbody>
</table> </table>
</div> </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"> <form method="post" action="{% url 'confirm_schedule_interviews_view' slug=job.slug %}" class="mt-4 d-flex justify-content-end gap-3">
{% csrf_token %} {% csrf_token %}
<a href="{% url 'schedule_interviews' slug=job.slug %}" class="btn btn-secondary px-4"> <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" %} <i class="fas fa-check me-2"></i> {% trans "Confirm Schedule" %}
</button> </button>
</form> </form>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
@ -196,17 +191,17 @@
<h5 class="modal-title" id="interviewDetailsModalLabel">{% trans "Interview Details" %}</h5> <h5 class="modal-title" id="interviewDetailsModalLabel">{% trans "Interview Details" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </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 %} {% csrf_token %}
{# Renders the single 'location' field using the crispy filter #} {# Renders the single 'location' field using the crispy filter #}
{{ form|crispy }} {{ form|crispy }}
</form> </form> {% endcomment %}
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<div class="d-flex align-items-center justify-content-between mt-4 mb-0"> <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"> <a href="#" class="btn btn-secondary me-2">
<i class="fas fa-times me-1"></i> Close <i class="fas fa-times me-1"></i> Close
</a> </a>
<button type="submit" class="btn btn-primary" form="onsite-form"> <button type="submit" class="btn btn-primary" form="onsite-form">

View File

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

View File

@ -1,5 +1,5 @@
{% extends "portal_base.html" %} {% extends "portal_base.html" %}
{% load static %} {% load static crispy_forms_tags %}
{% load i18n %} {% load i18n %}
{% block title %}{% if form.instance.pk %}{% trans "Reply to Message" %}{% else %}{% trans "Compose Message" %}{% endif %}{% endblock %} {% block title %}{% if form.instance.pk %}{% trans "Reply to Message" %}{% else %}{% trans "Compose Message" %}{% endif %}{% endblock %}
@ -11,23 +11,23 @@
<div class="card-header"> <div class="card-header">
<h5 class="mb-0"> <h5 class="mb-0">
{% if form.instance.pk %} {% if form.instance.pk %}
<i class="fas fa-reply"></i> Reply to Message <i class="fas fa-reply"></i> {% trans "Reply to Message" %}
{% else %} {% else %}
<i class="fas fa-envelope"></i> Compose Message <i class="fas fa-envelope"></i> {% trans "Compose Message" %}
{% endif %} {% endif %}
</h5> </h5>
</div> </div>
<div class="card-body"> <div class="card-body">
{% if form.instance.parent_message %} {% if form.instance.parent_message %}
<div class="alert alert-info mb-4"> <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> <br>
<small class="text-muted"> <small class="text-muted">
From {{ form.instance.parent_message.sender.get_full_name|default:form.instance.parent_message.sender.username }} {% trans "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 "on" %} {{ form.instance.parent_message.created_at|date:"M d, Y H:i" }}
</small> </small>
<div class="mt-2"> <div class="mt-2">
<strong>Original message:</strong> <strong>{% trans "Original message:" %}</strong>
<div class="border-start ps-3 mt-2"> <div class="border-start ps-3 mt-2">
{{ form.instance.parent_message.content|linebreaks }} {{ form.instance.parent_message.content|linebreaks }}
</div> </div>
@ -38,99 +38,17 @@
<form method="post" id="messageForm"> <form method="post" id="messageForm">
{% csrf_token %} {% csrf_token %}
<div class="row"> {{form|crispy}}
<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>
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<a href="{% url 'message_list' %}" class="btn btn-secondary"> <a href="{% url 'message_list' %}" class="btn btn-outline-primary">
<i class="fas fa-times"></i> Cancel <i class="fas fa-times"></i> {% trans "Cancel" %}
</a> </a>
<button type="submit" class="btn btn-main-action"> <button type="submit" class="btn btn-main-action">
<i class="fas fa-paper-plane"></i> <i class="fas fa-paper-plane"></i>
{% if form.instance.pk %} {% if form.instance.pk %}
Send Reply {% trans "Send Reply" %}
{% else %} {% else %}
Send Message {% trans "Send Message" %}
{% endif %} {% endif %}
</button> </button>
</div> </div>
@ -184,6 +102,7 @@ document.addEventListener('DOMContentLoaded', function() {
// Character counter for subject // Character counter for subject
const subjectField = document.getElementById('id_subject'); const subjectField = document.getElementById('id_subject');
const maxLength = 200; const maxLength = 200;
const charsLabel = "{% trans 'characters' %}";
if (subjectField) { if (subjectField) {
// Add character counter display // Add character counter display
@ -194,7 +113,7 @@ document.addEventListener('DOMContentLoaded', function() {
function updateCounter() { function updateCounter() {
const remaining = maxLength - subjectField.value.length; const remaining = maxLength - subjectField.value.length;
counter.textContent = `${subjectField.value.length}/${maxLength} characters`; counter.textContent = `${subjectField.value.length}/${maxLength} ${charsLabel}`;
if (remaining < 20) { if (remaining < 20) {
counter.className = 'text-warning'; counter.className = 'text-warning';
} else { } else {
@ -216,19 +135,19 @@ document.addEventListener('DOMContentLoaded', function() {
if (!recipient) { if (!recipient) {
e.preventDefault(); e.preventDefault();
alert('Please select a recipient.'); alert("{% trans 'Please select a recipient.' %}");
return false; return false;
} }
if (!subject) { if (!subject) {
e.preventDefault(); e.preventDefault();
alert('Please enter a subject.'); alert("{% trans 'Please enter a subject.' %}");
return false; return false;
} }
if (!content) { if (!content) {
e.preventDefault(); e.preventDefault();
alert('Please enter a message.'); alert("{% trans 'Please enter a message.' %}");
return false; return false;
} }
}); });

View File

@ -278,7 +278,7 @@
{% trans "To Offer" %} {% trans "To Offer" %}
</option> </option>
</select> </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" %} <i class="fas fa-arrow-right me-1"></i> {% trans "Change Stage" %}
</button> </button>
</form> </form>
@ -286,7 +286,7 @@
{# Separator (Vertical Rule) - Aligns automatically at the bottom with align-items-end #} {# Separator (Vertical Rule) - Aligns automatically at the bottom with align-items-end #}
<div class="vr" style="height: 28px;"></div> <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" data-bs-toggle="modal"
hx-boost='true' hx-boost='true'
data-bs-target="#emailModal" data-bs-target="#emailModal"
@ -323,6 +323,9 @@
<th scope="col" style="width: 28%;"> <th scope="col" style="width: 28%;">
<i class="fas fa-file-alt me-1"></i> {% trans "Documents" %} <i class="fas fa-file-alt me-1"></i> {% trans "Documents" %}
</th> </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%;"> <th scope="col" style="width: 10%;">
<i class="fas fa-cog me-1"></i> {% trans "Actions" %} <i class="fas fa-cog me-1"></i> {% trans "Actions" %}
</th> </th>
@ -394,6 +397,15 @@
{% endif %} {% endif %}
{% endwith %} {% endwith %}
</td> </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"> <td class="text-center">
<button type="button" class="btn btn-outline-secondary btn-sm" <button type="button" class="btn btn-outline-secondary btn-sm"
data-bs-toggle="modal" data-bs-toggle="modal"
@ -462,51 +474,78 @@
</div> </div>
</div> </div>
</div> </div>
{% include "recruitment/partials/note_modal.html" %}
{% endblock %} {% endblock %}
{% block customJS %} {% block customJS %}
<script> <script>
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
const selectAllCheckbox = document.getElementById('selectAllCheckbox'); const selectAllCheckbox = document.getElementById('selectAllCheckbox');
const rowCheckboxes = document.querySelectorAll('.rowCheckbox'); const rowCheckboxes = document.querySelectorAll('.rowCheckbox');
const changeStageButton = document.getElementById('changeStage');
const emailButton = document.getElementById('emailBotton');
const updateStatus = document.getElementById('update_status');
if (selectAllCheckbox) { 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 (checkedCount === 0) { // Function to safely update the header checkbox state
selectAllCheckbox.checked = false; function updateSelectAllState() {
selectAllCheckbox.indeterminate = false; const checkedCount = Array.from(rowCheckboxes).filter(cb => cb.checked).length;
} else if (checkedCount === totalCount) { const totalCount = rowCheckboxes.length;
selectAllCheckbox.checked = true;
selectAllCheckbox.indeterminate = false; if (checkedCount === 0) {
} else { selectAllCheckbox.checked = false;
selectAllCheckbox.checked = false; selectAllCheckbox.indeterminate = false;
selectAllCheckbox.indeterminate = true; 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) // 1. Logic for the 'Select All' checkbox (Clicking it updates all rows)
selectAllCheckbox.addEventListener('change', function () { selectAllCheckbox.addEventListener('change', function () {
const isChecked = selectAllCheckbox.checked; const isChecked = selectAllCheckbox.checked;
rowCheckboxes.forEach(checkbox => { // Temporarily disable the change listener on rows to prevent cascading events
checkbox.checked = isChecked; rowCheckboxes.forEach(checkbox => checkbox.removeEventListener('change', updateSelectAllState));
checkbox.dispatchEvent(new Event('change', { bubbles: true }));
// 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(); 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> </script>
{% endblock %} {% endblock %}

View File

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

View File

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

View File

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

View File

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