diff --git a/NorahUniversity/__pycache__/urls.cpython-313.pyc b/NorahUniversity/__pycache__/urls.cpython-313.pyc index 2aebbd2..3a0fc54 100644 Binary files a/NorahUniversity/__pycache__/urls.cpython-313.pyc and b/NorahUniversity/__pycache__/urls.cpython-313.pyc differ diff --git a/NorahUniversity/settings.py b/NorahUniversity/settings.py index fb2b574..486c536 100644 --- a/NorahUniversity/settings.py +++ b/NorahUniversity/settings.py @@ -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 = { diff --git a/NorahUniversity/urls.py b/NorahUniversity/urls.py index bf381ce..93b02be 100644 --- a/NorahUniversity/urls.py +++ b/NorahUniversity/urls.py @@ -26,6 +26,7 @@ urlpatterns = [ path('application//', views.application_submit_form, name='application_submit_form'), path('application//submit/', views.application_submit, name='application_submit'), path('application//apply/', views.application_detail, name='application_detail'), + path('application//signup/', views.candidate_signup, name='candidate_signup'), path('application//success/', views.application_success, name='application_success'), path('api/templates/', views.list_form_templates, name='list_form_templates'), diff --git a/recruitment/__pycache__/admin.cpython-313.pyc b/recruitment/__pycache__/admin.cpython-313.pyc index dc7eec9..d507901 100644 Binary files a/recruitment/__pycache__/admin.cpython-313.pyc and b/recruitment/__pycache__/admin.cpython-313.pyc differ diff --git a/recruitment/__pycache__/forms.cpython-313.pyc b/recruitment/__pycache__/forms.cpython-313.pyc index 10fc957..34ebd84 100644 Binary files a/recruitment/__pycache__/forms.cpython-313.pyc and b/recruitment/__pycache__/forms.cpython-313.pyc differ diff --git a/recruitment/__pycache__/models.cpython-313.pyc b/recruitment/__pycache__/models.cpython-313.pyc index ad0d791..68ac5e0 100644 Binary files a/recruitment/__pycache__/models.cpython-313.pyc and b/recruitment/__pycache__/models.cpython-313.pyc differ diff --git a/recruitment/__pycache__/signals.cpython-313.pyc b/recruitment/__pycache__/signals.cpython-313.pyc index 4c2eb87..f8ef1b1 100644 Binary files a/recruitment/__pycache__/signals.cpython-313.pyc and b/recruitment/__pycache__/signals.cpython-313.pyc differ diff --git a/recruitment/__pycache__/views.cpython-313.pyc b/recruitment/__pycache__/views.cpython-313.pyc index bc38135..719d7d9 100644 Binary files a/recruitment/__pycache__/views.cpython-313.pyc and b/recruitment/__pycache__/views.cpython-313.pyc differ diff --git a/recruitment/__pycache__/views_frontend.cpython-313.pyc b/recruitment/__pycache__/views_frontend.cpython-313.pyc index 3d85957..43e05b3 100644 Binary files a/recruitment/__pycache__/views_frontend.cpython-313.pyc and b/recruitment/__pycache__/views_frontend.cpython-313.pyc differ diff --git a/recruitment/forms.py b/recruitment/forms.py index 4fa6af8..80bb4e1 100644 --- a/recruitment/forms.py +++ b/recruitment/forms.py @@ -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 \ No newline at end of file diff --git a/recruitment/migrations/0001_initial.py b/recruitment/migrations/0001_initial.py index 306ceb7..bb16241 100644 --- a/recruitment/migrations/0001_initial.py +++ b/recruitment/migrations/0001_initial.py @@ -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'), diff --git a/recruitment/migrations/0002_alter_jobposting_job_type_and_more.py b/recruitment/migrations/0002_alter_jobposting_job_type_and_more.py deleted file mode 100644 index f596235..0000000 --- a/recruitment/migrations/0002_alter_jobposting_job_type_and_more.py +++ /dev/null @@ -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), - ), - ] diff --git a/recruitment/migrations/0002_document.py b/recruitment/migrations/0002_document.py new file mode 100644 index 0000000..cb62c62 --- /dev/null +++ b/recruitment/migrations/0002_document.py @@ -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')], + }, + ), + ] diff --git a/recruitment/migrations/0003_auto_20251105_1616.py b/recruitment/migrations/0003_auto_20251105_1616.py deleted file mode 100644 index 1feecfd..0000000 --- a/recruitment/migrations/0003_auto_20251105_1616.py +++ /dev/null @@ -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", - ), - ), - ] diff --git a/recruitment/migrations/0004_alter_candidate_ai_analysis_data.py b/recruitment/migrations/0004_alter_candidate_ai_analysis_data.py deleted file mode 100644 index 7f7beaf..0000000 --- a/recruitment/migrations/0004_alter_candidate_ai_analysis_data.py +++ /dev/null @@ -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'), - ), - ] diff --git a/recruitment/migrations/0005_customuser.py b/recruitment/migrations/0005_customuser.py deleted file mode 100644 index 0f209bf..0000000 --- a/recruitment/migrations/0005_customuser.py +++ /dev/null @@ -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()), - ], - ), - ] diff --git a/recruitment/migrations/__pycache__/0001_initial.cpython-313.pyc b/recruitment/migrations/__pycache__/0001_initial.cpython-313.pyc index b86dad7..9800da7 100644 Binary files a/recruitment/migrations/__pycache__/0001_initial.cpython-313.pyc and b/recruitment/migrations/__pycache__/0001_initial.cpython-313.pyc differ diff --git a/recruitment/models.py b/recruitment/models.py index 11ca53f..bf9b33a 100644 --- a/recruitment/models.py +++ b/recruitment/models.py @@ -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 "" diff --git a/recruitment/signals.py b/recruitment/signals.py index 0f08794..4865c3e 100644 --- a/recruitment/signals.py +++ b/recruitment/signals.py @@ -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() \ No newline at end of file diff --git a/recruitment/templatetags/file_filters.py b/recruitment/templatetags/file_filters.py new file mode 100644 index 0000000..4ed1701 --- /dev/null +++ b/recruitment/templatetags/file_filters.py @@ -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) diff --git a/recruitment/urls.py b/recruitment/urls.py index 90e379e..b1a0ccd 100644 --- a/recruitment/urls.py +++ b/recruitment/urls.py @@ -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//", views.message_detail, name="message_detail"), + path("messages//reply/", views.message_reply, name="message_reply"), + path("messages//mark-read/", views.message_mark_read, name="message_mark_read"), + path("messages//mark-unread/", views.message_mark_unread, name="message_mark_unread"), + path("messages//delete/", views.message_delete, name="message_delete"), + path("api/unread-count/", views.api_unread_count, name="api_unread_count"), + + # Documents + path("documents/upload//", views.document_upload, name="document_upload"), + path("documents//delete/", views.document_delete, name="document_delete"), + path("documents//download/", views.document_download, name="document_download"), ] diff --git a/recruitment/views.py b/recruitment/views.py index 4e3ef39..e333af2 100644 --- a/recruitment/views.py +++ b/recruitment/views.py @@ -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}) \ No newline at end of file diff --git a/recruitment/views_frontend.py b/recruitment/views_frontend.py index 59fc981..569a3fe 100644 --- a/recruitment/views_frontend.py +++ b/recruitment/views_frontend.py @@ -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' \ No newline at end of file + slug_url_kwarg = 'slug' diff --git a/templates/base.html b/templates/base.html index 8977d6b..98ac233 100644 --- a/templates/base.html +++ b/templates/base.html @@ -122,6 +122,11 @@ {% endif %} {% endcomment %} + + +
@@ -417,7 +423,7 @@
{% endif %} - + {% if candidate.get_interview_date %}
@@ -440,13 +446,13 @@

{% trans "Offer" %}

{{ candidate.offer_date|date:"M d, Y" }} - +
{% endif %} - + {% if candidate.hired_date %}
@@ -454,7 +460,7 @@

{% trans "Offer" %}

{{ candidate.hired_date|date:"M d, Y" }} - +
@@ -466,7 +472,14 @@ - {# TAB 4 CONTENT: PARSED SUMMARY #} + {# TAB 4 CONTENT: DOCUMENTS #} +
+ {% with documents=candidate.documents.all %} + {% include 'includes/document_list.html' %} + {% endwith %} +
+ + {# TAB 5 CONTENT: PARSED SUMMARY #} {% if candidate.parsed_summary %}
{% trans "AI Generated Summary" %}
@@ -666,7 +679,7 @@
{% 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 %} \ No newline at end of file +{% endblock %} diff --git a/templates/recruitment/candidate_list.html b/templates/recruitment/candidate_list.html index 270c7f9..e04b599 100644 --- a/templates/recruitment/candidate_list.html +++ b/templates/recruitment/candidate_list.html @@ -307,14 +307,14 @@ - {% if candidate.hiring_agency %} + {% if candidate.hiring_agency and candidate.hiring_source == 'Agency' %} - + {{ candidate.hiring_agency.name }} {% else %} - - + {{ candidate.hiring_source }} {% endif %} {{ candidate.created_at|date:"d-m-Y" }} diff --git a/templates/recruitment/candidate_signup.html b/templates/recruitment/candidate_signup.html new file mode 100644 index 0000000..e69de29