pre person model change
This commit is contained in:
parent
caa7ed88aa
commit
eb79173e26
Binary file not shown.
@ -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 = {
|
||||
|
||||
@ -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'),
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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
|
||||
@ -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'),
|
||||
|
||||
@ -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),
|
||||
),
|
||||
]
|
||||
37
recruitment/migrations/0002_document.py
Normal file
37
recruitment/migrations/0002_document.py
Normal 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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -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()),
|
||||
],
|
||||
),
|
||||
]
|
||||
Binary file not shown.
@ -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 ""
|
||||
|
||||
@ -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()
|
||||
27
recruitment/templatetags/file_filters.py
Normal file
27
recruitment/templatetags/file_filters.py
Normal 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)
|
||||
@ -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"),
|
||||
]
|
||||
|
||||
@ -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})
|
||||
@ -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'
|
||||
|
||||
@ -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>
|
||||
|
||||
149
templates/includes/document_list.html
Normal file
149
templates/includes/document_list.html
Normal 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>
|
||||
179
templates/messages/message_detail.html
Normal file
179
templates/messages/message_detail.html
Normal 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 %}
|
||||
237
templates/messages/message_form.html
Normal file
237
templates/messages/message_form.html
Normal 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 %}
|
||||
230
templates/messages/message_list.html
Normal file
230
templates/messages/message_list.html
Normal 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"> </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 %}
|
||||
@ -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 %}
|
||||
|
||||
@ -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>
|
||||
|
||||
0
templates/recruitment/candidate_signup.html
Normal file
0
templates/recruitment/candidate_signup.html
Normal file
Loading…
x
Reference in New Issue
Block a user