pre person model change

This commit is contained in:
ismail 2025-11-10 16:21:29 +03:00
parent caa7ed88aa
commit eb79173e26
31 changed files with 1808 additions and 197 deletions

View File

@ -193,7 +193,7 @@ EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
# Crispy Forms Configuration
CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5"
CRISPY_TEMPLATE_PACK = "bootstrapconsole5"
CRISPY_TEMPLATE_PACK = "bootstrap5"
# Bootstrap 5 Configuration
CRISPY_BS5 = {

View File

@ -26,6 +26,7 @@ urlpatterns = [
path('application/<slug:template_slug>/', views.application_submit_form, name='application_submit_form'),
path('application/<slug:template_slug>/submit/', views.application_submit, name='application_submit'),
path('application/<slug:slug>/apply/', views.application_detail, name='application_detail'),
path('application/<slug:slug>/signup/', views.candidate_signup, name='candidate_signup'),
path('application/<slug:slug>/success/', views.application_success, name='application_success'),
path('api/templates/', views.list_form_templates, name='list_form_templates'),

View File

@ -26,6 +26,7 @@ from .models import (
AgencyJobAssignment,
AgencyAccessLink,
Participants,
Message,
)
# from django_summernote.widgets import SummernoteWidget
@ -1609,3 +1610,187 @@ class CandidateEmailForm(forms.Form):
message += meeting_info
return message
class MessageForm(forms.ModelForm):
"""Form for creating and editing messages between users"""
class Meta:
model = Message
fields = ["recipient", "job", "subject", "content", "message_type"]
widgets = {
"recipient": forms.Select(
attrs={"class": "form-select", "placeholder": "Select recipient"}
),
"job": forms.Select(
attrs={"class": "form-select", "placeholder": "Select job (optional)"}
),
"subject": forms.TextInput(
attrs={
"class": "form-control",
"placeholder": "Enter message subject",
"required": True,
}
),
"content": forms.Textarea(
attrs={
"class": "form-control",
"rows": 6,
"placeholder": "Enter your message here...",
"required": True,
}
),
"message_type": forms.Select(attrs={"class": "form-select"}),
}
labels = {
"recipient": _("Recipient"),
"job": _("Related Job"),
"subject": _("Subject"),
"content": _("Message"),
"message_type": _("Message Type"),
}
def __init__(self, user, *args, **kwargs):
super().__init__(*args, **kwargs)
self.user = user
self.helper = FormHelper()
self.helper.form_method = "post"
self.helper.form_class = "g-3"
# Filter job options based on user type
self._filter_job_field()
# Filter recipient options based on user type
self._filter_recipient_field()
self.helper.layout = Layout(
Row(
Column("recipient", css_class="col-md-6"),
Column("job", css_class="col-md-6"),
css_class="g-3 mb-3",
),
Field("subject", css_class="form-control"),
Field("message_type", css_class="form-control"),
Field("content", css_class="form-control"),
Div(
Submit("submit", _("Send Message"), css_class="btn btn-main-action"),
css_class="col-12 mt-4",
),
)
def _filter_job_field(self):
"""Filter job options based on user type"""
if self.user.user_type == "agency":
# Agency users can only see jobs assigned to their agency
self.fields["job"].queryset = JobPosting.objects.filter(
hiring_agency__user=self.user,
status="ACTIVE"
).order_by("-created_at")
elif self.user.user_type == "candidate":
# Candidates can only see jobs they applied for
self.fields["job"].queryset = JobPosting.objects.filter(
candidates__user=self.user
).distinct().order_by("-created_at")
else:
# Staff can see all jobs
self.fields["job"].queryset = JobPosting.objects.filter(
status="ACTIVE"
).order_by("-created_at")
def _filter_recipient_field(self):
"""Filter recipient options based on user type"""
if self.user.user_type == "staff":
# Staff can message anyone
self.fields["recipient"].queryset = User.objects.all().order_by("username")
elif self.user.user_type == "agency":
# Agency can message staff and their candidates
from django.db.models import Q
self.fields["recipient"].queryset = User.objects.filter(
Q(user_type="staff") |
Q(candidate_profile__job__hiring_agency__user=self.user)
).distinct().order_by("username")
elif self.user.user_type == "candidate":
# Candidates can only message staff
self.fields["recipient"].queryset = User.objects.filter(
user_type="staff"
).order_by("username")
def clean(self):
"""Validate message form data"""
cleaned_data = super().clean()
job = cleaned_data.get("job")
recipient = cleaned_data.get("recipient")
# If job is selected but no recipient, auto-assign to job.assigned_to
if job and not recipient:
if job.assigned_to:
cleaned_data["recipient"] = job.assigned_to
# Set message type to job_related
cleaned_data["message_type"] = Message.MessageType.JOB_RELATED
else:
raise forms.ValidationError(
_("Selected job is not assigned to any user. Please assign the job first.")
)
# Validate messaging permissions
if self.user and cleaned_data.get("recipient"):
self._validate_messaging_permissions(cleaned_data)
return cleaned_data
def _validate_messaging_permissions(self, cleaned_data):
"""Validate if user can message the recipient"""
recipient = cleaned_data.get("recipient")
job = cleaned_data.get("job")
# Staff can message anyone
if self.user.user_type == "staff":
return
# Agency users validation
if self.user.user_type == "agency":
if recipient.user_type not in ["staff", "candidate"]:
raise forms.ValidationError(
_("Agencies can only message staff or candidates.")
)
# If messaging a candidate, ensure candidate is from their agency
if recipient.user_type == "candidate" and job:
if not job.hiring_agency.filter(user=self.user).exists():
raise forms.ValidationError(
_("You can only message candidates from your assigned jobs.")
)
# Candidate users validation
if self.user.user_type == "candidate":
if recipient.user_type != "staff":
raise forms.ValidationError(
_("Candidates can only message staff.")
)
# If job-related, ensure candidate applied for the job
if job:
if not Candidate.objects.filter(job=job, user=self.user).exists():
raise forms.ValidationError(
_("You can only message about jobs you have applied for.")
)
class CandidateSignupForm(forms.Form):
first_name = forms.CharField(max_length=30, required=True)
middle_name = forms.CharField(max_length=30, required=False)
last_name = forms.CharField(max_length=30, required=True)
email = forms.EmailField(max_length=254, required=True)
phone = forms.CharField(max_length=30, required=True)
password = forms.CharField(widget=forms.PasswordInput, required=True)
confirm_password = forms.CharField(widget=forms.PasswordInput, required=True)
def clean(self):
cleaned_data = super().clean()
password = cleaned_data.get("password")
confirm_password = cleaned_data.get("confirm_password")
if password != confirm_password:
raise forms.ValidationError("Passwords do not match.")
return cleaned_data

View File

@ -1,7 +1,10 @@
# Generated by Django 5.2.6 on 2025-10-30 10:22
# Generated by Django 5.2.6 on 2025-11-09 15:04
import django.contrib.auth.models
import django.contrib.auth.validators
import django.core.validators
import django.db.models.deletion
import django.utils.timezone
import django_ckeditor_5.fields
import django_countries.fields
import django_extensions.db.fields
@ -15,7 +18,7 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
@ -44,28 +47,6 @@ class Migration(migrations.Migration):
'ordering': ['order'],
},
),
migrations.CreateModel(
name='HiringAgency',
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')),
('name', models.CharField(max_length=200, unique=True, verbose_name='Agency Name')),
('contact_person', models.CharField(blank=True, max_length=150, verbose_name='Contact Person')),
('email', models.EmailField(blank=True, max_length=254)),
('phone', models.CharField(blank=True, max_length=20)),
('website', models.URLField(blank=True)),
('notes', models.TextField(blank=True, help_text='Internal notes about the agency')),
('country', django_countries.fields.CountryField(blank=True, max_length=2, null=True)),
('address', models.TextField(blank=True, null=True)),
],
options={
'verbose_name': 'Hiring Agency',
'verbose_name_plural': 'Hiring Agencies',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Participants',
fields=[
@ -137,6 +118,33 @@ class Migration(migrations.Migration):
'abstract': False,
},
),
migrations.CreateModel(
name='CustomUser',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('user_type', models.CharField(choices=[('staff', 'Staff'), ('agency', 'Agency'), ('candidate', 'Candidate')], default='staff', max_length=20, verbose_name='User Type')),
('phone', models.CharField(blank=True, max_length=20, null=True, verbose_name='Phone')),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
'verbose_name': 'User',
'verbose_name_plural': 'Users',
},
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
migrations.CreateModel(
name='FormField',
fields=[
@ -205,6 +213,29 @@ class Migration(migrations.Migration):
name='template',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stages', to='recruitment.formtemplate'),
),
migrations.CreateModel(
name='HiringAgency',
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')),
('name', models.CharField(max_length=200, unique=True, verbose_name='Agency Name')),
('contact_person', models.CharField(blank=True, max_length=150, verbose_name='Contact Person')),
('email', models.EmailField(blank=True, max_length=254)),
('phone', models.CharField(blank=True, max_length=20)),
('website', models.URLField(blank=True)),
('notes', models.TextField(blank=True, help_text='Internal notes about the agency')),
('country', django_countries.fields.CountryField(blank=True, max_length=2, null=True)),
('address', models.TextField(blank=True, null=True)),
('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='agency_profile', to=settings.AUTH_USER_MODEL, verbose_name='User')),
],
options={
'verbose_name': 'Hiring Agency',
'verbose_name_plural': 'Hiring Agencies',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Candidate',
fields=[
@ -232,9 +263,10 @@ class Migration(migrations.Migration):
('offer_status', models.CharField(blank=True, choices=[('Accepted', 'Accepted'), ('Rejected', 'Rejected')], max_length=100, null=True, verbose_name='Offer Status')),
('hired_date', models.DateField(blank=True, null=True, verbose_name='Hired Date')),
('join_date', models.DateField(blank=True, null=True, verbose_name='Join Date')),
('ai_analysis_data', models.JSONField(default=dict, help_text='Full JSON output from the resume scoring model.', verbose_name='AI Analysis Data')),
('ai_analysis_data', models.JSONField(blank=True, default=dict, help_text='Full JSON output from the resume scoring model.', null=True, verbose_name='AI Analysis Data')),
('retry', models.SmallIntegerField(default=3, verbose_name='Resume Parsing Retry')),
('hiring_source', models.CharField(blank=True, choices=[('Public', 'Public'), ('Internal', 'Internal'), ('Agency', 'Agency')], default='Public', max_length=255, null=True, verbose_name='Hiring Source')),
('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='candidate_profile', to=settings.AUTH_USER_MODEL, verbose_name='User')),
('hiring_agency', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='candidates', to='recruitment.hiringagency', verbose_name='Hiring Agency')),
],
options={
@ -251,8 +283,8 @@ class Migration(migrations.Migration):
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('title', models.CharField(max_length=200)),
('department', models.CharField(blank=True, max_length=100)),
('job_type', models.CharField(choices=[('FULL_TIME', 'Full-time'), ('PART_TIME', 'Part-time'), ('CONTRACT', 'Contract'), ('INTERNSHIP', 'Internship'), ('FACULTY', 'Faculty'), ('TEMPORARY', 'Temporary')], default='FULL_TIME', max_length=20)),
('workplace_type', models.CharField(choices=[('ON_SITE', 'On-site'), ('REMOTE', 'Remote'), ('HYBRID', 'Hybrid')], default='ON_SITE', max_length=20)),
('job_type', models.CharField(choices=[('Full-time', 'Full-time'), ('Part-time', 'Part-time'), ('Contract', 'Contract'), ('Internship', 'Internship'), ('Faculty', 'Faculty'), ('Temporary', 'Temporary')], default='FULL_TIME', max_length=20)),
('workplace_type', models.CharField(choices=[('On-site', 'On-site'), ('Remote', 'Remote'), ('Hybrid', 'Hybrid')], default='ON_SITE', max_length=20)),
('location_city', models.CharField(blank=True, max_length=100)),
('location_state', models.CharField(blank=True, max_length=100)),
('location_country', models.CharField(default='Saudia Arabia', max_length=100)),
@ -281,6 +313,7 @@ class Migration(migrations.Migration):
('cancel_reason', models.TextField(blank=True, help_text='Reason for canceling the job posting', verbose_name='Cancel Reason')),
('cancelled_by', models.CharField(blank=True, help_text='Name of person who cancelled this job', max_length=100, verbose_name='Cancelled By')),
('cancelled_at', models.DateTimeField(blank=True, null=True)),
('assigned_to', models.ForeignKey(blank=True, help_text='The user who has been assigned to this job', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_jobs', to=settings.AUTH_USER_MODEL, verbose_name='Assigned To')),
('hiring_agency', models.ManyToManyField(blank=True, help_text='External agency responsible for sourcing candidates for this role', related_name='jobs', to='recruitment.hiringagency', verbose_name='Hiring Agency')),
('users', models.ManyToManyField(blank=True, help_text='Internal staff involved in the recruitment process for this job', related_name='jobs_assigned', to=settings.AUTH_USER_MODEL, verbose_name='Internal Participant')),
('participants', models.ManyToManyField(blank=True, help_text='External participants involved in the recruitment process for this job', related_name='jobs_participating', to='recruitment.participants', verbose_name='External Participant')),
@ -356,6 +389,28 @@ class Migration(migrations.Migration):
('job', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='post_images', to='recruitment.jobposting')),
],
),
migrations.CreateModel(
name='Message',
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')),
('subject', models.CharField(max_length=200, verbose_name='Subject')),
('content', models.TextField(verbose_name='Message Content')),
('message_type', models.CharField(choices=[('direct', 'Direct Message'), ('job_related', 'Job Related'), ('system', 'System Notification')], default='direct', max_length=20, verbose_name='Message Type')),
('is_read', models.BooleanField(default=False, verbose_name='Is Read')),
('read_at', models.DateTimeField(blank=True, null=True, verbose_name='Read At')),
('job', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='recruitment.jobposting', verbose_name='Related Job')),
('recipient', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='received_messages', to=settings.AUTH_USER_MODEL, verbose_name='Recipient')),
('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_messages', to=settings.AUTH_USER_MODEL, verbose_name='Sender')),
],
options={
'verbose_name': 'Message',
'verbose_name_plural': 'Messages',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='Profile',
fields=[
@ -571,6 +626,22 @@ class Migration(migrations.Migration):
name='agencyjobassignment',
unique_together={('agency', 'job')},
),
migrations.AddIndex(
model_name='message',
index=models.Index(fields=['sender', 'created_at'], name='recruitment_sender__49d984_idx'),
),
migrations.AddIndex(
model_name='message',
index=models.Index(fields=['recipient', 'is_read', 'created_at'], name='recruitment_recipie_af0e6d_idx'),
),
migrations.AddIndex(
model_name='message',
index=models.Index(fields=['job', 'created_at'], name='recruitment_job_id_18f813_idx'),
),
migrations.AddIndex(
model_name='message',
index=models.Index(fields=['message_type', 'created_at'], name='recruitment_message_f25659_idx'),
),
migrations.AddIndex(
model_name='jobposting',
index=models.Index(fields=['status', 'created_at', 'title'], name='recruitment_status_8b77aa_idx'),

View File

@ -1,23 +0,0 @@
# Generated by Django 5.2.6 on 2025-11-03 08:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='jobposting',
name='job_type',
field=models.CharField(choices=[('Full-time', 'Full-time'), ('Part-time', 'Part-time'), ('Contract', 'Contract'), ('Internship', 'Internship'), ('Faculty', 'Faculty'), ('Temporary', 'Temporary')], default='FULL_TIME', max_length=20),
),
migrations.AlterField(
model_name='jobposting',
name='workplace_type',
field=models.CharField(choices=[('On-site', 'On-site'), ('Remote', 'Remote'), ('Hybrid', 'Hybrid')], default='ON_SITE', max_length=20),
),
]

View File

@ -0,0 +1,37 @@
# Generated by Django 5.2.6 on 2025-11-09 19:56
import django.db.models.deletion
import django_extensions.db.fields
import recruitment.validators
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Document',
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')),
('file', models.FileField(upload_to='candidate_documents/%Y/%m/', validators=[recruitment.validators.validate_image_size], verbose_name='Document File')),
('document_type', models.CharField(choices=[('resume', 'Resume'), ('cover_letter', 'Cover Letter'), ('certificate', 'Certificate'), ('id_document', 'ID Document'), ('passport', 'Passport'), ('education', 'Education Document'), ('experience', 'Experience Letter'), ('other', 'Other')], default='other', max_length=20, verbose_name='Document Type')),
('description', models.CharField(blank=True, max_length=200, verbose_name='Description')),
('candidate', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='recruitment.candidate', verbose_name='Candidate')),
('uploaded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Uploaded By')),
],
options={
'verbose_name': 'Document',
'verbose_name_plural': 'Documents',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['candidate', 'document_type', 'created_at'], name='recruitment_candida_f6ec68_idx')],
},
),
]

View File

@ -1,38 +0,0 @@
# Generated migration for adding user relationships
from django.db import migrations, models
import django.db.models.deletion
from django.conf import settings
class Migration(migrations.Migration):
dependencies = [
("recruitment", "0002_alter_jobposting_job_type_and_more"),
]
operations = [
migrations.AddField(
model_name="candidate",
name="user",
field=models.OneToOneField(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="candidate_profile",
to=settings.AUTH_USER_MODEL,
verbose_name="User",
),
),
migrations.AddField(
model_name="hiringagency",
name="user",
field=models.OneToOneField(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="agency_profile",
to=settings.AUTH_USER_MODEL,
verbose_name="User",
),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.2.6 on 2025-11-05 13:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0003_auto_20251105_1616'),
]
operations = [
migrations.AlterField(
model_name='candidate',
name='ai_analysis_data',
field=models.JSONField(blank=True, default=dict, help_text='Full JSON output from the resume scoring model.', null=True, verbose_name='AI Analysis Data'),
),
]

View File

@ -1,44 +0,0 @@
# Generated by Django 5.2.6 on 2025-11-05 13:37
import django.contrib.auth.models
import django.contrib.auth.validators
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
('recruitment', '0004_alter_candidate_ai_analysis_data'),
]
operations = [
migrations.CreateModel(
name='CustomUser',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('user_type', models.CharField(choices=[('staff', 'Staff'), ('agency', 'Agency'), ('candidate', 'Candidate')], default='staff', max_length=20, verbose_name='User Type')),
('phone', models.CharField(blank=True, max_length=20, null=True, verbose_name='Phone')),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
'verbose_name': 'User',
'verbose_name_plural': 'Users',
},
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
]

View File

@ -239,6 +239,15 @@ class JobPosting(Base):
verbose_name=_("Cancelled By"),
)
cancelled_at = models.DateTimeField(null=True, blank=True)
assigned_to = models.ForeignKey(
User,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="assigned_jobs",
help_text=_("The user who has been assigned to this job"),
verbose_name=_("Assigned To"),
)
class Meta:
ordering = ["-created_at"]
@ -1907,3 +1916,201 @@ class Participants(Base):
def __str__(self):
return f"{self.name} - {self.email}"
class Message(Base):
"""Model for messaging between different user types"""
class MessageType(models.TextChoices):
DIRECT = "direct", _("Direct Message")
JOB_RELATED = "job_related", _("Job Related")
SYSTEM = "system", _("System Notification")
sender = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="sent_messages",
verbose_name=_("Sender"),
)
recipient = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="received_messages",
null=True,
blank=True,
verbose_name=_("Recipient"),
)
job = models.ForeignKey(
JobPosting,
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="messages",
verbose_name=_("Related Job"),
)
subject = models.CharField(max_length=200, verbose_name=_("Subject"))
content = models.TextField(verbose_name=_("Message Content"))
message_type = models.CharField(
max_length=20,
choices=MessageType.choices,
default=MessageType.DIRECT,
verbose_name=_("Message Type"),
)
is_read = models.BooleanField(default=False, verbose_name=_("Is Read"))
read_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Read At"))
class Meta:
verbose_name = _("Message")
verbose_name_plural = _("Messages")
ordering = ["-created_at"]
indexes = [
models.Index(fields=["sender", "created_at"]),
models.Index(fields=["recipient", "is_read", "created_at"]),
models.Index(fields=["job", "created_at"]),
models.Index(fields=["message_type", "created_at"]),
]
def __str__(self):
return f"Message from {self.sender.get_username()} to {self.recipient.get_username() if self.recipient else 'N/A'}"
def mark_as_read(self):
"""Mark message as read and set read timestamp"""
if not self.is_read:
self.is_read = True
self.read_at = timezone.now()
self.save(update_fields=["is_read", "read_at"])
@property
def is_job_related(self):
"""Check if message is related to a job"""
return self.job is not None
def get_auto_recipient(self):
"""Get auto recipient based on job assignment"""
if self.job and self.job.assigned_to:
return self.job.assigned_to
return None
def clean(self):
"""Validate message constraints"""
super().clean()
# For job-related messages, ensure recipient is assigned to the job
if self.job and not self.recipient:
if self.job.assigned_to:
self.recipient = self.job.assigned_to
else:
raise ValidationError(_("Job is not assigned to any user. Please assign the job first."))
# Validate sender can message this recipient based on user types
# if self.sender and self.recipient:
# self._validate_messaging_permissions()
def _validate_messaging_permissions(self):
"""Validate if sender can message recipient based on user types"""
sender_type = self.sender.user_type
recipient_type = self.recipient.user_type
# Staff can message anyone
if sender_type == "staff":
return
# Agency users can only message staff or their own candidates
if sender_type == "agency":
if recipient_type not in ["staff", "candidate"]:
raise ValidationError(_("Agencies can only message staff or candidates."))
# If messaging a candidate, ensure candidate is from their agency
if recipient_type == "candidate" and self.job:
if not self.job.hiring_agency.filter(user=self.sender).exists():
raise ValidationError(_("You can only message candidates from your assigned jobs."))
# Candidate users can only message staff
if sender_type == "candidate":
if recipient_type != "staff":
raise ValidationError(_("Candidates can only message staff."))
# If job-related, ensure candidate applied for the job
if self.job:
if not Candidate.objects.filter(job=self.job, user=self.sender).exists():
raise ValidationError(_("You can only message about jobs you have applied for."))
def save(self, *args, **kwargs):
"""Override save to handle auto-recipient logic"""
self.clean()
super().save(*args, **kwargs)
class Document(Base):
"""Model for storing candidate documents"""
class DocumentType(models.TextChoices):
RESUME = "resume", _("Resume")
COVER_LETTER = "cover_letter", _("Cover Letter")
CERTIFICATE = "certificate", _("Certificate")
ID_DOCUMENT = "id_document", _("ID Document")
PASSPORT = "passport", _("Passport")
EDUCATION = "education", _("Education Document")
EXPERIENCE = "experience", _("Experience Letter")
OTHER = "other", _("Other")
candidate = models.ForeignKey(
Candidate,
on_delete=models.CASCADE,
related_name="documents",
verbose_name=_("Candidate"),
)
file = models.FileField(
upload_to="candidate_documents/%Y/%m/",
verbose_name=_("Document File"),
validators=[validate_image_size],
)
document_type = models.CharField(
max_length=20,
choices=DocumentType.choices,
default=DocumentType.OTHER,
verbose_name=_("Document Type"),
)
description = models.CharField(
max_length=200,
blank=True,
verbose_name=_("Description"),
)
uploaded_by = models.ForeignKey(
User,
on_delete=models.SET_NULL,
null=True,
blank=True,
verbose_name=_("Uploaded By"),
)
class Meta:
verbose_name = _("Document")
verbose_name_plural = _("Documents")
ordering = ["-created_at"]
indexes = [
models.Index(fields=["candidate", "document_type", "created_at"]),
]
def __str__(self):
return f"{self.get_document_type_display()} - {self.candidate.name}"
@property
def file_size(self):
"""Return file size in human readable format"""
if self.file:
size = self.file.size
if size < 1024:
return f"{size} bytes"
elif size < 1024 * 1024:
return f"{size / 1024:.1f} KB"
else:
return f"{size / (1024 * 1024):.1f} MB"
return "0 bytes"
@property
def file_extension(self):
"""Return file extension"""
if self.file:
return self.file.name.split('.')[-1].upper()
return ""

View File

@ -1,17 +1,18 @@
import logging
import random
from django.db import transaction
from django_q.models import Schedule
from django_q.tasks import schedule
from django.dispatch import receiver
from django_q.tasks import async_task
from django.db.models.signals import post_save
from django.contrib.auth.models import User
from django.utils import timezone
from .models import FormField,FormStage,FormTemplate,Candidate,JobPosting,Notification
from .models import FormField,FormStage,FormTemplate,Candidate,JobPosting,Notification,HiringAgency
from django.contrib.auth import get_user_model
logger = logging.getLogger(__name__)
User = get_user_model()
@receiver(post_save, sender=JobPosting)
def format_job(sender, instance, created, **kwargs):
if created:
@ -58,7 +59,7 @@ def format_job(sender, instance, created, **kwargs):
@receiver(post_save, sender=Candidate)
def score_candidate_resume(sender, instance, created, **kwargs):
if not instance.is_resume_parsed:
if instance.resume and not instance.is_resume_parsed:
logger.info(f"Scoring resume for candidate {instance.pk}")
async_task(
'recruitment.tasks.handle_reume_parsing_and_scoring',
@ -397,3 +398,34 @@ def notification_created(sender, instance, created, **kwargs):
SSE_NOTIFICATION_CACHE[user_id] = SSE_NOTIFICATION_CACHE[user_id][-50:]
logger.info(f"Notification cached for SSE: {notification_data}")
def generate_random_password():
import string
return ''.join(random.choices(string.ascii_letters + string.digits, k=12))
@receiver(post_save, sender=HiringAgency)
def hiring_agency_created(sender, instance, created, **kwargs):
if created:
logger.info(f"New hiring agency created: {instance.pk} - {instance.name}")
user = User.objects.create_user(
username=instance.name,
email=instance.email,
user_type="agency"
)
user.set_password(generate_random_password())
user.save()
instance.user = user
instance.save()
@receiver(post_save, sender=Candidate)
def candidate_created(sender, instance, created, **kwargs):
if created:
logger.info(f"New candidate created: {instance.pk} - {instance.email}")
user = User.objects.create_user(
username=instance.slug,
first_name=instance.first_name,
last_name=instance.last_name,
email=instance.email,
phone=instance.phone,
user_type="candidate"
)
instance.user = user
instance.save()

View File

@ -0,0 +1,27 @@
from django import template
register = template.Library()
@register.filter
def filename(value):
"""
Extract just the filename from a file path.
Example: 'documents/resume.pdf' -> 'resume.pdf'
"""
if not value:
return ''
# Convert to string and split by path separators
import os
return os.path.basename(str(value))
@register.filter
def split(value, delimiter):
"""
Split a string by a delimiter and return a list.
This is a custom implementation of the split functionality.
"""
if not value:
return []
return str(value).split(delimiter)

View File

@ -560,4 +560,18 @@ urlpatterns = [
views.compose_candidate_email,
name="compose_candidate_email",
),
# Message URLs
path("messages/", views.message_list, name="message_list"),
path("messages/create/", views.message_create, name="message_create"),
path("messages/<int:message_id>/", views.message_detail, name="message_detail"),
path("messages/<int:message_id>/reply/", views.message_reply, name="message_reply"),
path("messages/<int:message_id>/mark-read/", views.message_mark_read, name="message_mark_read"),
path("messages/<int:message_id>/mark-unread/", views.message_mark_unread, name="message_mark_unread"),
path("messages/<int:message_id>/delete/", views.message_delete, name="message_delete"),
path("api/unread-count/", views.api_unread_count, name="api_unread_count"),
# Documents
path("documents/upload/<int:candidate_id>/", views.document_upload, name="document_upload"),
path("documents/<int:document_id>/delete/", views.document_delete, name="document_delete"),
path("documents/<int:document_id>/download/", views.document_download, name="document_download"),
]

View File

@ -1,13 +1,12 @@
import json
from rich import print
from django.utils.translation import gettext as _
from django.contrib.auth import get_user_model, authenticate, login, logout
from django.contrib.auth.decorators import login_required
from django.contrib.admin.views.decorators import staff_member_required
from django.contrib.auth.mixins import LoginRequiredMixin
from rich import print
from .forms import StaffUserCreationForm,ToggleAccountForm, JobPostingStatusForm,LinkedPostContentForm,CandidateEmailForm
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from django.http import HttpResponse, JsonResponse
@ -46,6 +45,7 @@ from .forms import (
AgencyCandidateSubmissionForm,
AgencyLoginForm,
PortalLoginForm,
MessageForm,
)
from easyaudit.models import CRUDEvent, LoginEvent, RequestEvent
from rest_framework import viewsets
@ -87,6 +87,8 @@ from .models import (
AgencyAccessLink,
Notification,
Source,
Message,
Document,
)
import logging
from datastar_py.django import (
@ -102,6 +104,7 @@ from django.db.models import FloatField
logger = logging.getLogger(__name__)
User = get_user_model()
class JobPostingViewSet(viewsets.ModelViewSet):
queryset = JobPosting.objects.all()
@ -3480,38 +3483,39 @@ def portal_login(request):
user = authenticate(request, username=email, password=password)
if user is not None:
# Check if user type matches
print(user.user_type)
if hasattr(user, "user_type") and user.user_type == user_type:
login(request, user)
if user_type == "agency":
# Check if user has agency profile
if hasattr(user, "agency_profile") and user.agency_profile:
messages.success(
request, f"Welcome, {user.agency_profile.name}!"
)
return redirect("agency_portal_dashboard")
else:
messages.error(
request, "No agency profile found for this user."
)
logout(request)
# if user_type == "agency":
# # Check if user has agency profile
# if hasattr(user, "agency_profile") and user.agency_profile:
# messages.success(
# request, f"Welcome, {user.agency_profile.name}!"
# )
# return redirect("agency_portal_dashboard")
# else:
# messages.error(
# request, "No agency profile found for this user."
# )
# logout(request)
elif user_type == "candidate":
# Check if user has candidate profile
if (
hasattr(user, "candidate_profile")
and user.candidate_profile
):
messages.success(
request,
f"Welcome, {user.candidate_profile.first_name}!",
)
return redirect("candidate_portal_dashboard")
else:
messages.error(
request, "No candidate profile found for this user."
)
logout(request)
# elif user_type == "candidate":
# # Check if user has candidate profile
# if (
# hasattr(user, "candidate_profile")
# and user.candidate_profile
# ):
# messages.success(
# request,
# f"Welcome, {user.candidate_profile.first_name}!",
# )
# return redirect("candidate_portal_dashboard")
# else:
# messages.error(
# request, "No candidate profile found for this user."
# )
# logout(request)
else:
messages.error(request, "Invalid user type selected.")
else:
@ -3949,6 +3953,316 @@ def agency_portal_delete_candidate(request, candidate_id):
return JsonResponse({"success": False, "error": "Method not allowed"})
# Message Views
@login_required
def message_list(request):
"""List all messages for the current user"""
# Get filter parameters
status_filter = request.GET.get("status", "")
message_type_filter = request.GET.get("type", "")
search_query = request.GET.get("q", "")
# Base queryset - get messages where user is either sender or recipient
message_list = Message.objects.filter(
Q(sender=request.user) | Q(recipient=request.user)
).select_related("sender", "recipient", "job").order_by("-created_at")
# Apply filters
if status_filter:
if status_filter == "read":
message_list = message_list.filter(is_read=True)
elif status_filter == "unread":
message_list = message_list.filter(is_read=False)
if message_type_filter:
message_list = message_list.filter(message_type=message_type_filter)
if search_query:
message_list = message_list.filter(
Q(subject__icontains=search_query) |
Q(content__icontains=search_query)
)
# Pagination
paginator = Paginator(message_list, 20) # Show 20 messages per page
page_number = request.GET.get("page")
page_obj = paginator.get_page(page_number)
# Statistics
total_messages = message_list.count()
unread_messages = message_list.filter(is_read=False).count()
context = {
"page_obj": page_obj,
"total_messages": total_messages,
"unread_messages": unread_messages,
"status_filter": status_filter,
"type_filter": message_type_filter,
"search_query": search_query,
}
return render(request, "messages/message_list.html", context)
@login_required
def message_detail(request, message_id):
"""View details of a specific message"""
message = get_object_or_404(
Message.objects.select_related("sender", "recipient", "job"),
id=message_id
)
# Check if user has permission to view this message
if message.sender != request.user and message.recipient != request.user:
messages.error(request, "You don't have permission to view this message.")
return redirect("message_list")
# Mark as read if it was unread and user is the recipient
if not message.is_read and message.recipient == request.user:
message.is_read = True
message.read_at = timezone.now()
message.save(update_fields=["is_read", "read_at"])
context = {
"message": message,
}
return render(request, "messages/message_detail.html", context)
@login_required
def message_create(request):
"""Create a new message"""
if request.method == "POST":
form = MessageForm(request.user, request.POST)
if form.is_valid():
message = form.save(commit=False)
message.sender = request.user
message.save()
messages.success(request, "Message sent successfully!")
return redirect("message_list")
else:
messages.error(request, "Please correct the errors below.")
else:
form = MessageForm(request.user)
context = {
"form": form,
}
return render(request, "messages/message_form.html", context)
@login_required
def message_reply(request, message_id):
"""Reply to a message"""
parent_message = get_object_or_404(
Message.objects.select_related("sender", "recipient", "job"),
id=message_id
)
# Check if user has permission to reply to this message
if parent_message.recipient != request.user and parent_message.sender != request.user:
messages.error(request, "You don't have permission to reply to this message.")
return redirect("message_list")
if request.method == "POST":
form = MessageForm(request.user, request.POST)
if form.is_valid():
message = form.save(commit=False)
message.sender = request.user
message.parent_message = parent_message
# Set recipient as the original sender
message.recipient = parent_message.sender
message.save()
messages.success(request, "Reply sent successfully!")
return redirect("message_detail", message_id=parent_message.id)
else:
messages.error(request, "Please correct the errors below.")
else:
# Pre-fill form with reply context
form = MessageForm(request.user)
form.initial["subject"] = f"Re: {parent_message.subject}"
form.initial["recipient"] = parent_message.sender
if parent_message.job:
form.initial["job"] = parent_message.job
form.initial["message_type"] = Message.MessageType.JOB_RELATED
context = {
"form": form,
"parent_message": parent_message,
}
return render(request, "messages/message_form.html", context)
@login_required
def message_mark_read(request, message_id):
"""Mark a message as read"""
message = get_object_or_404(
Message.objects.select_related("sender", "recipient"),
id=message_id
)
# Check if user has permission to mark this message as read
if message.recipient != request.user:
messages.error(request, "You can only mark messages you received as read.")
return redirect("message_list")
# Mark as read
message.is_read = True
message.read_at = timezone.now()
message.save(update_fields=["is_read", "read_at"])
messages.success(request, "Message marked as read.")
# Handle HTMX requests
if "HX-Request" in request.headers:
return HttpResponse(status=200) # HTMX success response
return redirect("message_list")
@login_required
def message_mark_unread(request, message_id):
"""Mark a message as unread"""
message = get_object_or_404(
Message.objects.select_related("sender", "recipient"),
id=message_id
)
# Check if user has permission to mark this message as unread
if message.recipient != request.user:
messages.error(request, "You can only mark messages you received as unread.")
return redirect("message_list")
# Mark as unread
message.is_read = False
message.read_at = None
message.save(update_fields=["is_read", "read_at"])
messages.success(request, "Message marked as unread.")
# Handle HTMX requests
if "HX-Request" in request.headers:
return HttpResponse(status=200) # HTMX success response
return redirect("message_list")
@login_required
def message_delete(request, message_id):
"""Delete a message"""
message = get_object_or_404(
Message.objects.select_related("sender", "recipient"),
id=message_id
)
# Check if user has permission to delete this message
if message.sender != request.user and message.recipient != request.user:
messages.error(request, "You don't have permission to delete this message.")
return redirect("message_list")
if request.method == "POST":
message.delete()
messages.success(request, "Message deleted successfully.")
# Handle HTMX requests
if "HX-Request" in request.headers:
return HttpResponse(status=200) # HTMX success response
return redirect("message_list")
# For GET requests, show confirmation page
context = {
"message": message,
"title": "Delete Message",
"message": f'Are you sure you want to delete this message from {message.sender.get_full_name() or message.sender.username}?',
"cancel_url": reverse("message_detail", kwargs={"message_id": message_id}),
}
return render(request, "messages/message_confirm_delete.html", context)
@login_required
def api_unread_count(request):
"""API endpoint to get unread message count"""
unread_count = Message.objects.filter(
recipient=request.user,
is_read=False
).count()
return JsonResponse({"unread_count": unread_count})
# Document Views
@login_required
def document_upload(request, candidate_id):
"""Upload a document for a candidate"""
candidate = get_object_or_404(Candidate, pk=candidate_id)
if request.method == "POST":
if request.FILES.get('file'):
document = Document.objects.create(
candidate=candidate,
file=request.FILES['file'],
document_type=request.POST.get('document_type', 'other'),
description=request.POST.get('description', ''),
uploaded_by=request.user,
)
messages.success(request, f'Document "{document.get_document_type_display()}" uploaded successfully!')
# Handle AJAX requests
# if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
# return JsonResponse({
# 'success': True,
# 'message': 'Document uploaded successfully!',
# 'document_id': document.id,
# 'file_name': document.file.name,
# 'file_size': document.file_size,
# })
return redirect('candidate_detail', slug=candidate.job.slug)
@login_required
def document_delete(request, document_id):
"""Delete a document"""
document = get_object_or_404(Document, id=document_id)
# Check permission
if document.candidate.job.assigned_to != request.user and not request.user.is_superuser:
messages.error(request, "You don't have permission to delete this document.")
return JsonResponse({'success': False, 'error': 'Permission denied'})
if request.method == "POST":
file_name = document.file.name if document.file else "Unknown"
document.delete()
messages.success(request, f'Document "{file_name}" deleted successfully!')
# Handle AJAX requests
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return JsonResponse({'success': True, 'message': 'Document deleted successfully!'})
else:
return redirect('candidate_detail', slug=document.candidate.job.slug)
return JsonResponse({'success': False, 'error': 'Method not allowed'})
@login_required
def document_download(request, document_id):
"""Download a document"""
document = get_object_or_404(Document, id=document_id)
# Check permission
if document.candidate.job.assigned_to != request.user and not request.user.is_superuser:
messages.error(request, "You don't have permission to download this document.")
return JsonResponse({'success': False, 'error': 'Permission denied'})
if document.file:
response = HttpResponse(document.file.read(), content_type='application/octet-stream')
response['Content-Disposition'] = f'attachment; filename="{document.file.name}"'
return response
return JsonResponse({'success': False, 'error': 'File not found'})
def portal_logout(request):
"""Logout from portal"""
logout(request)
@ -4384,3 +4698,19 @@ def source_toggle_status(request, slug):
# For GET requests, return error
return JsonResponse({"success": False, "error": "Method not allowed"})
def candidate_signup(request,slug):
from .forms import CandidateSignupForm
job = get_object_or_404(JobPosting, slug=slug)
if request.method == "POST":
form = CandidateSignupForm(request.POST)
if form.is_valid():
candidate = form.save(commit=False)
candidate.job = job
candidate.save()
return redirect("application_submit_form",template_slug=job.form_template.slug)
form = CandidateSignupForm()
return render(request, "recruitment/candidate_signup.html", {"form": form, "job": job})

View File

@ -1071,4 +1071,4 @@ class ParticipantsDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView
success_url = reverse_lazy('participants_list') # Redirect to the participants list after success
success_message = 'Participant deleted successfully.'
slug_url_kwarg = 'slug'
slug_url_kwarg = 'slug'

View File

@ -122,6 +122,11 @@
</ul>
</li>
{% endif %} {% endcomment %}
<li class="nav-item me-2">
<a class="nav-link" href="{% url 'message_list' %}">
<i class="fas fa-envelope"></i>
</a>
</li>
<li class="nav-item dropdown">
<button
@ -389,6 +394,23 @@
</script>
<!-- Message Count JavaScript -->
<script>
document.addEventListener('DOMContentLoaded', function() {
// Update unread message count on page load
fetch('/api/unread-count/')
.then(response => response.json())
.then(data => {
const badge = document.getElementById('unread-messages-badge');
if (badge && data.unread_count > 0) {
badge.textContent = data.unread_count;
badge.style.display = 'inline-block';
}
})
.catch(error => console.error('Error fetching unread count:', error));
});
</script>
<!-- Notification JavaScript for Admin Users -->
{% comment %} {% if request.user.is_authenticated and request.user.is_staff %}
<script>

View File

@ -0,0 +1,149 @@
{% load static %}
{% load file_filters %}
<div class="card shadow-sm">
<div class="card-header bg-white border-bottom d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0 text-primary">Documents</h5>
<button
type="button"
class="btn bg-primary-theme text-white btn-sm"
data-bs-toggle="modal"
data-bs-target="#documentUploadModal"
>
<i class="fas fa-plus me-2"></i>Upload Document
</button>
</div>
<!-- Document Upload Modal -->
<div class="modal fade" id="documentUploadModal" tabindex="-1" aria-labelledby="documentUploadModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="documentUploadModalLabel">Upload Document</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form
method="post"
enctype="multipart/form-data"
hx-post="{% url 'document_upload' candidate.id %}"
hx-target="#documents-pane"
hx-select="#documents-pane"
hx-swap="outerHTML"
hx-on::after-request="bootstrap.Modal.getInstance(document.getElementById('documentUploadModal')).hide()"
>
{% csrf_token %}
<div class="modal-body">
<div class="mb-3">
<label for="documentType" class="form-label">Document Type</label>
<select name="document_type" id="documentType" class="form-select">
<option value="resume">Resume</option>
<option value="cover_letter">Cover Letter</option>
<option value="portfolio">Portfolio</option>
<option value="certificate">Certificate</option>
<option value="id_proof">ID Proof</option>
<option value="other">Other</option>
</select>
</div>
<div class="mb-3">
<label for="documentFile" class="form-label">File</label>
<input
type="file"
name="file"
id="documentFile"
class="form-control"
required
>
</div>
<div class="mb-3">
<label for="documentDescription" class="form-label">Description</label>
<textarea
name="description"
id="documentDescription"
rows="3"
class="form-control"
placeholder="Optional description..."
></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">
<i class="fas fa-upload me-2"></i>Upload
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Documents List -->
<div class="card-body" id="document-list-container">
{% if documents %}
{% for document in documents %}
<div class="d-flex justify-content-between align-items-center p-3 border-bottom hover-bg-light">
<div class="d-flex align-items-center">
<i class="fas fa-file text-primary me-3"></i>
<div>
<div class="fw-medium text-dark">{{ document.get_document_type_display }}</div>
<div class="small text-muted">{{ document.file.name|filename }}</div>
{% if document.description %}
<div class="small text-muted">{{ document.description }}</div>
{% endif %}
<div class="small text-muted">
Uploaded by {{ document.uploaded_by.get_full_name|default:document.uploaded_by.username }} on {{ document.created_at|date:"M d, Y" }}
</div>
</div>
</div>
<div class="d-flex align-items-center">
<a
href="{% url 'document_download' document.id %}"
class="btn btn-sm btn-outline-primary me-2"
title="Download"
>
<i class="fas fa-download"></i>
</a>
{% if user.is_superuser or candidate.job.assigned_to == user %}
<button
type="button"
class="btn btn-sm btn-outline-danger"
onclick="confirmDelete({{ document.id }}, '{{ document.file.name|filename|default:"Document" }}')"
title="Delete"
>
<i class="fas fa-trash"></i>
</button>
{% endif %}
</div>
</div>
{% endfor %}
{% else %}
<div class="text-center py-5 text-muted">
<i class="fas fa-file-alt fa-3x mb-3"></i>
<p class="mb-2">No documents uploaded yet.</p>
<p class="small">Click "Upload Document" to add files for this candidate.</p>
</div>
{% endif %}
</div>
</div>
<style>
.hover-bg-light:hover {
background-color: #f8f9fa;
transition: background-color 0.2s ease;
}
</style>
<script>
function confirmDelete(documentId, fileName) {
if (confirm(`Are you sure you want to delete "${fileName}"?`)) {
htmx.ajax('POST', `{% url 'document_delete' 0 %}`.replace('0', documentId), {
target: '#document-list-container',
swap: 'innerHTML'
});
}
}
</script>

View File

@ -0,0 +1,179 @@
{% extends "base.html" %}
{% load static %}
{% block title %}{{ message.subject }}{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row">
<div class="col-12">
<!-- Message Header -->
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">
{{ message.subject }}
{% if message.parent_message %}
<span class="badge bg-secondary ms-2">Reply</span>
{% endif %}
</h5>
<div class="btn-group" role="group">
<a href="{% url 'message_reply' message.id %}" class="btn btn-outline-info">
<i class="fas fa-reply"></i> Reply
</a>
{% if message.recipient == request.user %}
<a href="{% url 'message_mark_unread' message.id %}"
class="btn btn-outline-warning"
hx-post="{% url 'message_mark_unread' message.id %}">
<i class="fas fa-envelope"></i> Mark Unread
</a>
{% endif %}
<a href="{% url 'message_delete' message.id %}"
class="btn btn-outline-danger"
hx-get="{% url 'message_delete' message.id %}"
hx-confirm="Are you sure you want to delete this message?">
<i class="fas fa-trash"></i> Delete
</a>
<a href="{% url 'message_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left"></i> Back to Messages
</a>
</div>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-6">
<strong>From:</strong>
<span class="text-primary">{{ message.sender.get_full_name|default:message.sender.username }}</span>
</div>
<div class="col-md-6">
<strong>To:</strong>
<span class="text-primary">{{ message.recipient.get_full_name|default:message.recipient.username }}</span>
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<strong>Type:</strong>
<span class="badge bg-{{ message.message_type|lower }}">
{{ message.get_message_type_display }}
</span>
</div>
<div class="col-md-6">
<strong>Status:</strong>
{% if message.is_read %}
<span class="badge bg-success">Read</span>
{% if message.read_at %}
<small class="text-muted">({{ message.read_at|date:"M d, Y H:i" }})</small>
{% endif %}
{% else %}
<span class="badge bg-warning">Unread</span>
{% endif %}
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<strong>Created:</strong>
<span>{{ message.created_at|date:"M d, Y H:i" }}</span>
</div>
{% if message.job %}
<div class="col-md-6">
<strong>Related Job:</strong>
<a href="{% url 'job_detail' message.job.slug %}" class="text-primary">
{{ message.job.title }}
</a>
</div>
{% endif %}
</div>
{% if message.parent_message %}
<div class="alert alert-info">
<strong>In reply to:</strong>
<a href="{% url 'message_detail' message.parent_message.id %}">
{{ message.parent_message.subject }}
</a>
<small class="text-muted d-block">
From {{ message.parent_message.sender.get_full_name|default:message.parent_message.sender.username }}
on {{ message.parent_message.created_at|date:"M d, Y H:i" }}
</small>
</div>
{% endif %}
</div>
</div>
<!-- Message Content -->
<div class="card">
<div class="card-header">
<h6 class="mb-0">Message</h6>
</div>
<div class="card-body">
<div class="message-content">
{{ message.content|linebreaks }}
</div>
</div>
</div>
<!-- Message Thread (if this is a reply and has replies) -->
{% if message.replies.all %}
<div class="card mt-4">
<div class="card-header">
<h6 class="mb-0">
<i class="fas fa-comments"></i> Replies ({{ message.replies.count }})
</h6>
</div>
<div class="card-body">
{% for reply in message.replies.all %}
<div class="border-start ps-3 mb-3">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<strong>{{ reply.sender.get_full_name|default:reply.sender.username }}</strong>
<small class="text-muted ms-2">
{{ reply.created_at|date:"M d, Y H:i" }}
</small>
</div>
<span class="badge bg-{{ reply.message_type|lower }}">
{{ reply.get_message_type_display }}
</span>
</div>
<div class="reply-content">
{{ reply.content|linebreaks }}
</div>
<div class="mt-2">
<a href="{% url 'message_reply' reply.id %}" class="btn btn-sm btn-outline-info">
<i class="fas fa-reply"></i> Reply to this
</a>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}
{% block extra_css %}
<style>
.message-content {
white-space: pre-wrap;
word-wrap: break-word;
line-height: 1.6;
padding: 1rem;
background-color: #f8f9fa;
border-radius: 0.375rem;
border: 1px solid #dee2e6;
}
.reply-content {
white-space: pre-wrap;
word-wrap: break-word;
line-height: 1.5;
font-size: 0.9rem;
}
.border-start {
border-left: 3px solid #0d6efd;
}
.ps-3 {
padding-left: 1rem;
}
</style>
{% endblock %}

View File

@ -0,0 +1,237 @@
{% extends "base.html" %}
{% load static %}
{% block title %}{% if form.instance.pk %}Reply to Message{% else %}Compose Message{% endif %}{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
{% if form.instance.pk %}
<i class="fas fa-reply"></i> Reply to Message
{% else %}
<i class="fas fa-envelope"></i> Compose Message
{% endif %}
</h5>
</div>
<div class="card-body">
{% if form.instance.parent_message %}
<div class="alert alert-info mb-4">
<strong>Replying to:</strong> {{ form.instance.parent_message.subject }}
<br>
<small class="text-muted">
From {{ form.instance.parent_message.sender.get_full_name|default:form.instance.parent_message.sender.username }}
on {{ form.instance.parent_message.created_at|date:"M d, Y H:i" }}
</small>
<div class="mt-2">
<strong>Original message:</strong>
<div class="border-start ps-3 mt-2">
{{ form.instance.parent_message.content|linebreaks }}
</div>
</div>
</div>
{% endif %}
<form method="post" id="messageForm">
{% csrf_token %}
<div class="row">
<div class="col-md-6">
<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-6">
<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 class="col-md-6">
<div class="mb-3">
<label for="{{ form.job.id_for_label }}" class="form-label">
Related Job
</label>
{{ form.job }}
{% if form.job.errors %}
<div class="text-danger small mt-1">
{{ form.job.errors.0 }}
</div>
{% endif %}
<div class="form-text">
Optional: Select a job if this message is related to a specific position
</div>
</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">
<a href="{% url 'message_list' %}" class="btn btn-secondary">
<i class="fas fa-times"></i> Cancel
</a>
<button type="submit" class="btn btn-primary">
<i class="fas fa-paper-plane"></i>
{% if form.instance.pk %}
Send Reply
{% else %}
Send Message
{% endif %}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_css %}
<style>
#id_content {
min-height: 200px;
resize: vertical;
}
.form-select {
{% if form.recipient.field.widget.attrs.disabled %}
background-color: #f8f9fa;
{% endif %}
}
.border-start {
border-left: 3px solid #0d6efd;
}
.ps-3 {
padding-left: 1rem;
}
</style>
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Auto-resize textarea based on content
const textarea = document.getElementById('id_content');
if (textarea) {
textarea.addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = (this.scrollHeight) + 'px';
});
// Set initial height
textarea.style.height = 'auto';
textarea.style.height = (textarea.scrollHeight) + 'px';
}
// Character counter for subject
const subjectField = document.getElementById('id_subject');
const maxLength = 200;
if (subjectField) {
// Add character counter display
const counter = document.createElement('small');
counter.className = 'text-muted';
counter.style.float = 'right';
subjectField.parentNode.appendChild(counter);
function updateCounter() {
const remaining = maxLength - subjectField.value.length;
counter.textContent = `${subjectField.value.length}/${maxLength} characters`;
if (remaining < 20) {
counter.className = 'text-warning';
} else {
counter.className = 'text-muted';
}
}
subjectField.addEventListener('input', updateCounter);
updateCounter();
}
// Form validation before submit
const form = document.getElementById('messageForm');
if (form) {
form.addEventListener('submit', function(e) {
const content = document.getElementById('id_content').value.trim();
const subject = document.getElementById('id_subject').value.trim();
const recipient = document.getElementById('id_recipient').value;
if (!recipient) {
e.preventDefault();
alert('Please select a recipient.');
return false;
}
if (!subject) {
e.preventDefault();
alert('Please enter a subject.');
return false;
}
if (!content) {
e.preventDefault();
alert('Please enter a message.');
return false;
}
});
}
});
</script>
{% endblock %}

View File

@ -0,0 +1,230 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Messages{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="mb-0">Messages</h4>
<a href="{% url 'message_create' %}" class="btn btn-main-action">
<i class="fas fa-plus"></i> Compose Message
</a>
</div>
<!-- Filters -->
<div class="card mb-4">
<div class="card-body">
<form method="get" class="row g-3">
<div class="col-md-3">
<label for="status" class="form-label">Status</label>
<select name="status" id="status" class="form-select">
<option value="">All Status</option>
<option value="read" {% if status_filter == 'read' %}selected{% endif %}>Read</option>
<option value="unread" {% if status_filter == 'unread' %}selected{% endif %}>Unread</option>
</select>
</div>
<div class="col-md-3">
<label for="type" class="form-label">Type</label>
<select name="type" id="type" class="form-select">
<option value="">All Types</option>
<option value="GENERAL" {% if type_filter == 'GENERAL' %}selected{% endif %}>General</option>
<option value="JOB_RELATED" {% if type_filter == 'JOB_RELATED' %}selected{% endif %}>Job Related</option>
<option value="INTERVIEW" {% if type_filter == 'INTERVIEW' %}selected{% endif %}>Interview</option>
<option value="OFFER" {% if type_filter == 'OFFER' %}selected{% endif %}>Offer</option>
</select>
</div>
<div class="col-md-4">
<label for="q" class="form-label">Search</label>
<div class="input-group">
<input type="text" name="q" id="q" class="form-control"
value="{{ search_query }}" placeholder="Search messages...">
<button class="btn btn-outline-secondary" type="submit">
<i class="fas fa-search"></i>
</button>
</div>
</div>
<div class="col-md-2">
<label class="form-label">&nbsp;</label>
<button type="submit" class="btn btn-secondary w-100">Filter</button>
</div>
</form>
</div>
</div>
<!-- Statistics -->
<div class="row mb-3">
<div class="col-md-6">
<div class="card bg-light">
<div class="card-body">
<h6 class="card-title">Total Messages</h6>
<h3 class="text-primary">{{ total_messages }}</h3>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card bg-light">
<div class="card-body">
<h6 class="card-title">Unread Messages</h6>
<h3 class="text-warning">{{ unread_messages }}</h3>
</div>
</div>
</div>
</div>
<!-- Messages List -->
<div class="card">
<div class="card-body">
{% if page_obj %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Subject</th>
<th>Sender</th>
<th>Recipient</th>
<th>Type</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for message in page_obj %}
<tr class="{% if not message.is_read %}table-warning{% endif %}">
<td>
<a href="{% url 'message_detail' message.id %}"
class="{% if not message.is_read %}fw-bold{% endif %}">
{{ message.subject }}
</a>
{% if message.parent_message %}
<span class="badge bg-secondary ms-2">Reply</span>
{% endif %}
</td>
<td>{{ message.sender.get_full_name|default:message.sender.username }}</td>
<td>{{ message.recipient.get_full_name|default:message.recipient.username }}</td>
<td>
<span>
{{ message.get_message_type_display }}
</span>
</td>
<td>
{% if message.is_read %}
<span class="badge bg-primary-theme">Read</span>
{% else %}
<span class="badge bg-warning">Unread</span>
{% endif %}
</td>
<td>{{ message.created_at|date:"M d, Y H:i" }}</td>
<td>
<div class="btn-group" role="group">
<a href="{% url 'message_detail' message.id %}"
class="btn btn-sm btn-outline-primary" title="View">
<i class="fas fa-eye"></i>
</a>
{% if not message.is_read and message.recipient == request.user %}
<a href="{% url 'message_mark_read' message.id %}"
class="btn btn-sm btn-outline-success"
hx-post="{% url 'message_mark_read' message.id %}"
title="Mark as Read">
<i class="fas fa-check"></i>
</a>
{% endif %}
<a href="{% url 'message_reply' message.id %}"
class="btn btn-sm btn-outline-primary" title="Reply">
<i class="fas fa-reply"></i>
</a>
<a href="{% url 'message_delete' message.id %}"
class="btn btn-sm btn-outline-danger"
hx-get="{% url 'message_delete' message.id %}"
hx-confirm="Are you sure you want to delete this message?"
title="Delete">
<i class="fas fa-trash"></i>
</a>
</div>
</td>
</tr>
{% empty %}
<tr>
<td colspan="7" class="text-center text-muted">
<i class="fas fa-inbox fa-3x mb-3"></i>
<p class="mb-0">No messages found.</p>
<p class="small">Try adjusting your filters or compose a new message.</p>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if page_obj.has_other_pages %}
<nav aria-label="Message pagination">
<ul class="pagination justify-content-center">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}&status={{ status_filter }}&type={{ type_filter }}&q={{ search_query }}">
<i class="fas fa-chevron-left"></i>
</a>
</li>
{% endif %}
{% for num in page_obj.paginator.page_range %}
{% if page_obj.number == num %}
<li class="page-item active">
<span class="page-link">{{ num }}</span>
</li>
{% else %}
<li class="page-item">
<a class="page-link" href="?page={{ num }}&status={{ status_filter }}&type={{ type_filter }}&q={{ search_query }}">{{ num }}</a>
</li>
{% endif %}
{% endfor %}
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}&status={{ status_filter }}&type={{ type_filter }}&q={{ search_query }}">
<i class="fas fa-chevron-right"></i>
</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
<div class="text-center text-muted py-5">
<i class="fas fa-inbox fa-3x mb-3"></i>
<p class="mb-0">No messages found.</p>
<p class="small">Try adjusting your filters or compose a new message.</p>
<a href="{% url 'message_create' %}" class="btn btn-main-action">
<i class="fas fa-plus"></i> Compose Message
</a>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
// Auto-refresh unread count every 30 seconds
setInterval(() => {
fetch('/api/unread-count/')
.then(response => response.json())
.then(data => {
// Update unread count in navigation if it exists
const unreadBadge = document.querySelector('.unread-messages-count');
if (unreadBadge) {
unreadBadge.textContent = data.unread_count;
unreadBadge.style.display = data.unread_count > 0 ? 'inline-block' : 'none';
}
})
.catch(error => console.error('Error fetching unread count:', error));
}, 30000);
</script>
{% endblock %}

View File

@ -321,6 +321,12 @@
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="documents-tab" data-bs-toggle="tab" data-bs-target="#documents-pane" type="button" role="tab" aria-controls="documents-pane" aria-selected="false">
<i class="fas fa-file-alt me-1"></i> {% trans "Documents" %}
</button>
</li>
</ul>
<div class="card-body">
@ -417,7 +423,7 @@
</div>
{% endif %}
{% if candidate.get_interview_date %}
<div class="timeline-item">
<div class="timeline-icon timeline-bg-applied"><i class="fas fas fa-comments"></i></div>
@ -440,13 +446,13 @@
<p class="timeline-stage fw-bold mb-0 ms-2">{% trans "Offer" %}</p>
<small class="text-muted">
<i class="far fa-calendar-alt me-1"></i> {{ candidate.offer_date|date:"M d, Y" }}
</small>
</div>
</div>
{% endif %}
{% if candidate.hired_date %}
<div class="timeline-item">
<div class="timeline-icon timeline-bg-applied"><i class="fas fas fa-handshake"></i></div>
@ -454,7 +460,7 @@
<p class="timeline-stage fw-bold mb-0 ms-2">{% trans "Offer" %}</p>
<small class="text-muted">
<i class="far fa-calendar-alt me-1"></i> {{ candidate.hired_date|date:"M d, Y" }}
</small>
</div>
@ -466,7 +472,14 @@
</div>
</div>
{# TAB 4 CONTENT: PARSED SUMMARY #}
{# TAB 4 CONTENT: DOCUMENTS #}
<div class="tab-pane fade" id="documents-pane" role="tabpanel" aria-labelledby="documents-tab">
{% with documents=candidate.documents.all %}
{% include 'includes/document_list.html' %}
{% endwith %}
</div>
{# TAB 5 CONTENT: PARSED SUMMARY #}
{% if candidate.parsed_summary %}
<div class="tab-pane fade" id="summary-pane" role="tabpanel" aria-labelledby="summary-tab">
<h5 class="text-primary mb-4">{% trans "AI Generated Summary" %}</h5>
@ -666,7 +679,7 @@
<div class="card shadow-sm mb-4 p-2">
<h5 class="text-muted mb-3"><i class="fas fa-clock me-2"></i>{% trans "Time to Hire:" %}
{% with days=candidate.time_to_hire_days %}
{% if days > 0 %}
{{ days }} day{{ days|pluralize }}
@ -712,4 +725,4 @@
{% if user.is_staff %}
{% include "recruitment/partials/stage_update_modal.html" with candidate=candidate form=stage_form %}
{% endif %}
{% endblock %}
{% endblock %}

View File

@ -307,14 +307,14 @@
</span>
</td>
<td>
{% if candidate.hiring_agency %}
{% if candidate.hiring_agency and candidate.hiring_source == 'Agency' %}
<a href="{% url 'agency_detail' candidate.hiring_agency.slug %}" class="text-decoration-none">
<span class="badge bg-info">
<span class="badge bg-primary">
<i class="fas fa-building"></i> {{ candidate.hiring_agency.name }}
</span>
</a>
{% else %}
<span class="text-muted">-</span>
<span class="badge bg-primary">{{ candidate.hiring_source }}</span>
{% endif %}
</td>
<td>{{ candidate.created_at|date:"d-m-Y" }}</td>