diff --git a/recruitment/__pycache__/admin.cpython-313.pyc b/recruitment/__pycache__/admin.cpython-313.pyc index d507901..7a4ff58 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 34ebd84..bc3e494 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 68ac5e0..0875971 100644 Binary files a/recruitment/__pycache__/models.cpython-313.pyc and b/recruitment/__pycache__/models.cpython-313.pyc differ diff --git a/recruitment/__pycache__/serializers.cpython-313.pyc b/recruitment/__pycache__/serializers.cpython-313.pyc index 72958ae..25faf10 100644 Binary files a/recruitment/__pycache__/serializers.cpython-313.pyc and b/recruitment/__pycache__/serializers.cpython-313.pyc differ diff --git a/recruitment/__pycache__/signals.cpython-313.pyc b/recruitment/__pycache__/signals.cpython-313.pyc index f8ef1b1..f5cb462 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 719d7d9..9aacf1e 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 43e05b3..13c3823 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/admin.py b/recruitment/admin.py index 1a35903..245a14d 100644 --- a/recruitment/admin.py +++ b/recruitment/admin.py @@ -3,7 +3,7 @@ from django.utils.html import format_html from django.urls import reverse from django.utils import timezone from .models import ( - JobPosting, Candidate, TrainingMaterial, ZoomMeeting, + JobPosting, Application, TrainingMaterial, ZoomMeeting, FormTemplate, FormStage, FormField, FormSubmission, FieldResponse, SharedFormTemplate, Source, HiringAgency, IntegrationLog,InterviewSchedule,Profile,JobPostingImage,MeetingComment, AgencyAccessLink, AgencyJobAssignment @@ -138,43 +138,6 @@ class JobPostingAdmin(admin.ModelAdmin): mark_as_closed.short_description = 'Mark selected jobs as closed' -@admin.register(Candidate) -class CandidateAdmin(admin.ModelAdmin): - list_display = ['full_name', 'job', 'email', 'phone', 'stage', 'applied','is_resume_parsed', 'created_at'] - list_filter = ['stage', 'applied', 'created_at', 'job__department'] - search_fields = ['first_name', 'last_name', 'email', 'phone'] - readonly_fields = ['slug', 'created_at', 'updated_at'] - fieldsets = ( - ('Personal Information', { - 'fields': ('first_name', 'last_name', 'email', 'phone', 'resume','user') - }), - ('Application Details', { - 'fields': ('job', 'applied', 'stage','is_resume_parsed') - }), - ('Interview Process', { - 'fields': ('exam_date', 'exam_status', 'interview_date', 'interview_status', 'offer_date', 'offer_status', 'join_date') - }), - ('Scoring', { - 'fields': ('ai_analysis_data',) - }), - ('Additional Information', { - 'fields': ('created_at', 'updated_at') - }), - ) - save_on_top = True - actions = ['mark_as_applied', 'mark_as_not_applied'] - - def mark_as_applied(self, request, queryset): - updated = queryset.update(applied=True) - self.message_user(request, f'{updated} candidates marked as applied.') - mark_as_applied.short_description = 'Mark selected candidates as applied' - - def mark_as_not_applied(self, request, queryset): - updated = queryset.update(applied=False) - self.message_user(request, f'{updated} candidates marked as not applied.') - mark_as_not_applied.short_description = 'Mark selected candidates as not applied' - - @admin.register(TrainingMaterial) class TrainingMaterialAdmin(admin.ModelAdmin): list_display = ['title', 'created_by', 'created_at'] @@ -275,6 +238,7 @@ class FormSubmissionAdmin(admin.ModelAdmin): # Register other models admin.site.register(FormStage) +admin.site.register(Application) admin.site.register(FormField) admin.site.register(FieldResponse) admin.site.register(InterviewSchedule) diff --git a/recruitment/decorators.py b/recruitment/decorators.py index b929bf2..06e68b1 100644 --- a/recruitment/decorators.py +++ b/recruitment/decorators.py @@ -1,17 +1,163 @@ from functools import wraps from datetime import date from django.shortcuts import redirect, get_object_or_404 -from django.http import HttpResponseNotFound +from django.http import HttpResponseNotFound, HttpResponseForbidden +from django.contrib.auth.decorators import login_required +from django.contrib.auth.mixins import AccessMixin +from django.core.exceptions import PermissionDenied +from django.contrib import messages def job_not_expired(view_func): @wraps(view_func) def _wrapped_view(request, job_id, *args, **kwargs): - + from .models import JobPosting job = get_object_or_404(JobPosting, pk=job_id) if job.expiration_date and job.application_deadline< date.today(): return redirect('expired_job_page') - + return view_func(request, job_id, *args, **kwargs) - return _wrapped_view \ No newline at end of file + return _wrapped_view + + +def user_type_required(allowed_types=None, login_url=None): + """ + Decorator to restrict view access based on user type. + + Args: + allowed_types (list): List of allowed user types ['staff', 'agency', 'candidate'] + login_url (str): URL to redirect to if user is not authenticated + """ + if allowed_types is None: + allowed_types = ['staff'] + + def decorator(view_func): + @wraps(view_func) + @login_required(login_url=login_url) + def _wrapped_view(request, *args, **kwargs): + user = request.user + + # Check if user has user_type attribute + if not hasattr(user, 'user_type') or not user.user_type: + messages.error(request, "User type not specified. Please contact administrator.") + return redirect('portal_login') + + # Check if user type is allowed + if user.user_type not in allowed_types: + # Log unauthorized access attempt + messages.error( + request, + f"Access denied. This page is restricted to {', '.join(allowed_types)} users." + ) + + # Redirect based on user type + if user.user_type == 'agency': + return redirect('agency_portal_dashboard') + elif user.user_type == 'candidate': + return redirect('candidate_portal_dashboard') + else: + return redirect('dashboard') + + return view_func(request, *args, **kwargs) + return _wrapped_view + return decorator + + +class UserTypeRequiredMixin(AccessMixin): + """ + Mixin for class-based views to restrict access based on user type. + """ + allowed_user_types = ['staff'] # Default to staff only + login_url = '/login/' + + def dispatch(self, request, *args, **kwargs): + if not request.user.is_authenticated: + return self.handle_no_permission() + + # Check if user has user_type attribute + if not hasattr(request.user, 'user_type') or not request.user.user_type: + messages.error(request, "User type not specified. Please contact administrator.") + return redirect('portal_login') + + # Check if user type is allowed + if request.user.user_type not in self.allowed_user_types: + # Log unauthorized access attempt + messages.error( + request, + f"Access denied. This page is restricted to {', '.join(self.allowed_user_types)} users." + ) + + # Redirect based on user type + if request.user.user_type == 'agency': + return redirect('agency_portal_dashboard') + elif request.user.user_type == 'candidate': + return redirect('candidate_portal_dashboard') + else: + return redirect('dashboard') + + return super().dispatch(request, *args, **kwargs) + + def handle_no_permission(self): + if self.request.user.is_authenticated: + # User is authenticated but doesn't have permission + messages.error( + self.request, + f"Access denied. This page is restricted to {', '.join(self.allowed_user_types)} users." + ) + return redirect('dashboard') + else: + # User is not authenticated + return super().handle_no_permission() + + +class StaffRequiredMixin(UserTypeRequiredMixin): + """Mixin to restrict access to staff users only.""" + allowed_user_types = ['staff'] + + +class AgencyRequiredMixin(UserTypeRequiredMixin): + """Mixin to restrict access to agency users only.""" + allowed_user_types = ['agency'] + login_url = '/portal/login/' + + +class CandidateRequiredMixin(UserTypeRequiredMixin): + """Mixin to restrict access to candidate users only.""" + allowed_user_types = ['candidate'] + login_url = '/portal/login/' + + +class StaffOrAgencyRequiredMixin(UserTypeRequiredMixin): + """Mixin to restrict access to staff and agency users.""" + allowed_user_types = ['staff', 'agency'] + + +class StaffOrCandidateRequiredMixin(UserTypeRequiredMixin): + """Mixin to restrict access to staff and candidate users.""" + allowed_user_types = ['staff', 'candidate'] + + +def agency_user_required(view_func): + """Decorator to restrict view to agency users only.""" + return user_type_required(['agency'], login_url='/portal/login/')(view_func) + + +def candidate_user_required(view_func): + """Decorator to restrict view to candidate users only.""" + return user_type_required(['candidate'], login_url='/portal/login/')(view_func) + + +def staff_user_required(view_func): + """Decorator to restrict view to staff users only.""" + return user_type_required(['staff'])(view_func) + + +def staff_or_agency_required(view_func): + """Decorator to restrict view to staff and agency users.""" + return user_type_required(['staff', 'agency'], login_url='/portal/login/')(view_func) + + +def staff_or_candidate_required(view_func): + """Decorator to restrict view to staff and candidate users.""" + return user_type_required(['staff', 'candidate'], login_url='/portal/login/')(view_func) diff --git a/recruitment/forms.py b/recruitment/forms.py index 80bb4e1..d64e2f7 100644 --- a/recruitment/forms.py +++ b/recruitment/forms.py @@ -11,7 +11,7 @@ User = get_user_model() import re from .models import ( ZoomMeeting, - Candidate, + Application, TrainingMaterial, JobPosting, FormTemplate, @@ -27,6 +27,7 @@ from .models import ( AgencyAccessLink, Participants, Message, + Person ) # from django_summernote.widgets import SummernoteWidget @@ -262,42 +263,38 @@ class SourceAdvancedForm(forms.ModelForm): return cleaned_data -class CandidateForm(forms.ModelForm): +class PersonForm(forms.ModelForm): class Meta: - model = Candidate + model = Person + fields = ["first_name","middle_name", "last_name", "email", "phone","date_of_birth","nationality","address","gender"] + widgets = { + "first_name": forms.TextInput(attrs={'class': 'form-control'}), + "middle_name": forms.TextInput(attrs={'class': 'form-control'}), + "last_name": forms.TextInput(attrs={'class': 'form-control'}), + "email": forms.EmailInput(attrs={'class': 'form-control'}), + "phone": forms.TextInput(attrs={'class': 'form-control'}), + "gender": forms.Select(attrs={'class': 'form-control'}), + "date_of_birth": forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}), + "nationality": forms.Select(attrs={'class': 'form-control select2'}), + "address": forms.Textarea(attrs={'class': 'form-control', 'rows': 3}), + } +class ApplicationForm(forms.ModelForm): + + class Meta: + model = Application fields = [ + 'person', "job", - "first_name", - "last_name", - "phone", - "email", "hiring_source", "hiring_agency", "resume", ] labels = { - "first_name": _("First Name"), - "last_name": _("Last Name"), - "phone": _("Phone"), - "email": _("Email"), "resume": _("Resume"), "hiring_source": _("Hiring Type"), "hiring_agency": _("Hiring Agency"), } widgets = { - "first_name": forms.TextInput( - attrs={"class": "form-control", "placeholder": _("Enter first name")} - ), - "last_name": forms.TextInput( - attrs={"class": "form-control", "placeholder": _("Enter last name")} - ), - "phone": forms.TextInput( - attrs={"class": "form-control", "placeholder": _("Enter phone number")} - ), - "email": forms.EmailInput( - attrs={"class": "form-control", "placeholder": _("Enter email")} - ), - "stage": forms.Select(attrs={"class": "form-select"}), "hiring_source": forms.Select(attrs={"class": "form-select"}), "hiring_agency": forms.Select(attrs={"class": "form-select"}), } @@ -317,23 +314,43 @@ class CandidateForm(forms.ModelForm): self.helper.layout = Layout( Field("job", css_class="form-control"), - Field("first_name", css_class="form-control"), - Field("last_name", css_class="form-control"), - Field("phone", css_class="form-control"), - Field("email", css_class="form-control"), - Field("stage", css_class="form-control"), Field("hiring_source", css_class="form-control"), Field("hiring_agency", css_class="form-control"), Field("resume", css_class="form-control"), Submit("submit", _("Submit"), css_class="btn btn-primary"), ) + # def save(self, commit=True): + # """Override save to handle person creation/update""" + # instance = super().save(commit=False) -class CandidateStageForm(forms.ModelForm): + # # Get or create person + # if instance.person: + # person = instance.person + # else: + # # Create new person + # from .models import Person + # person = Person() + + # # Update person fields + # person.first_name = self.cleaned_data['first_name'] + # person.last_name = self.cleaned_data['last_name'] + # person.email = self.cleaned_data['email'] + # person.phone = self.cleaned_data['phone'] + + # if commit: + # person.save() + # instance.person = person + # instance.save() + + # return instance + + +class ApplicationStageForm(forms.ModelForm): """Form specifically for updating candidate stage with validation""" class Meta: - model = Candidate + model = Application fields = ["stage"] labels = { "stage": _("New Application Stage"), @@ -648,8 +665,8 @@ BreakTimeFormSet = formset_factory(BreakTimeForm, extra=1, can_delete=True) class InterviewScheduleForm(forms.ModelForm): - candidates = forms.ModelMultipleChoiceField( - queryset=Candidate.objects.none(), + applications = forms.ModelMultipleChoiceField( + queryset=Application.objects.none(), widget=forms.CheckboxSelectMultiple, required=True, ) @@ -670,7 +687,7 @@ class InterviewScheduleForm(forms.ModelForm): class Meta: model = InterviewSchedule fields = [ - "candidates", + "applications", "start_date", "end_date", "working_days", @@ -706,7 +723,7 @@ class InterviewScheduleForm(forms.ModelForm): def __init__(self, slug, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields["candidates"].queryset = Candidate.objects.filter( + self.fields["applications"].queryset = Application.objects.filter( job__slug=slug, stage="Interview" ) @@ -750,7 +767,7 @@ class MeetingCommentForm(forms.ModelForm): class InterviewForm(forms.ModelForm): class Meta: model = ScheduledInterview - fields = ["job", "candidate"] + fields = ["job", "application"] class ProfileImageUploadForm(forms.ModelForm): @@ -832,7 +849,7 @@ class FormTemplateIsActiveForm(forms.ModelForm): class CandidateExamDateForm(forms.ModelForm): class Meta: - model = Candidate + model = Application fields = ["exam_date"] widgets = { "exam_date": forms.DateTimeInput( @@ -1164,41 +1181,50 @@ class AgencyAccessLinkForm(forms.ModelForm): # Agency messaging forms removed - AgencyMessage model has been deleted -class AgencyCandidateSubmissionForm(forms.ModelForm): +class AgencyApplicationSubmissionForm(forms.ModelForm): """Form for agencies to submit candidates (simplified - resume + basic info)""" + # Person fields for creating/updating person + # first_name = forms.CharField( + # max_length=255, + # widget=forms.TextInput(attrs={ + # "class": "form-control", + # "placeholder": "First Name", + # "required": True, + # }), + # label=_("First Name") + # ) + # last_name = forms.CharField( + # max_length=255, + # widget=forms.TextInput(attrs={ + # "class": "form-control", + # "placeholder": "Last Name", + # "required": True, + # }), + # label=_("Last Name") + # ) + # email = forms.EmailField( + # widget=forms.EmailInput(attrs={ + # "class": "form-control", + # "placeholder": "email@example.com", + # "required": True, + # }), + # label=_("Email Address") + # ) + # phone = forms.CharField( + # max_length=20, + # widget=forms.TextInput(attrs={ + # "class": "form-control", + # "placeholder": "+966 50 123 4567", + # "required": True, + # }), + # label=_("Phone Number") + # ) + class Meta: - model = Candidate - fields = ["first_name", "last_name", "email", "phone", "resume"] + model = Application + fields = ["person","resume"] widgets = { - "first_name": forms.TextInput( - attrs={ - "class": "form-control", - "placeholder": "First Name", - "required": True, - } - ), - "last_name": forms.TextInput( - attrs={ - "class": "form-control", - "placeholder": "Last Name", - "required": True, - } - ), - "email": forms.EmailInput( - attrs={ - "class": "form-control", - "placeholder": "email@example.com", - "required": True, - } - ), - "phone": forms.TextInput( - attrs={ - "class": "form-control", - "placeholder": "+966 50 123 4567", - "required": True, - } - ), "resume": forms.FileInput( attrs={ "class": "form-control", @@ -1208,10 +1234,6 @@ class AgencyCandidateSubmissionForm(forms.ModelForm): ), } labels = { - "first_name": _("First Name"), - "last_name": _("Last Name"), - "email": _("Email Address"), - "phone": _("Phone Number"), "resume": _("Resume"), } @@ -1223,39 +1245,46 @@ class AgencyCandidateSubmissionForm(forms.ModelForm): self.helper.form_class = "g-3" self.helper.enctype = "multipart/form-data" - self.helper.layout = Layout( - Row( - Column("first_name", css_class="col-md-6"), - Column("last_name", css_class="col-md-6"), - css_class="g-3 mb-3", - ), - Row( - Column("email", css_class="col-md-6"), - Column("phone", css_class="col-md-6"), - css_class="g-3 mb-3", - ), - Field("resume", css_class="form-control"), - Div( - Submit( - "submit", _("Submit Candidate"), css_class="btn btn-main-action" - ), - css_class="col-12 mt-4", - ), - ) + # self.helper.layout = Layout( + # Row( + # Column("first_name", css_class="col-md-6"), + # Column("last_name", css_class="col-md-6"), + # css_class="g-3 mb-3", + # ), + # Row( + # Column("email", css_class="col-md-6"), + # Column("phone", css_class="col-md-6"), + # css_class="g-3 mb-3", + # ), + # Field("resume", css_class="form-control"), + # Div( + # Submit( + # "submit", _("Submit Candidate"), css_class="btn btn-main-action" + # ), + # css_class="col-12 mt-4", + # ), + # ) def clean_email(self): """Validate email format and check for duplicates in the same job""" email = self.cleaned_data.get("email") if email: - # Check if candidate with this email already exists for this job - existing_candidate = Candidate.objects.filter( - email=email.lower().strip(), job=self.assignment.job + # Check if person with this email already exists for this job + from .models import Person + existing_person = Person.objects.filter( + email=email.lower().strip() ).first() - if existing_candidate: - raise ValidationError( - f"A candidate with this email has already applied for {self.assignment.job.title}." - ) + if existing_person: + # Check if this person already has an application for this job + existing_application = Application.objects.filter( + person=existing_person, job=self.assignment.job + ).first() + + if existing_application: + raise ValidationError( + f"A candidate with this email has already applied for {self.assignment.job.title}." + ) return email.lower().strip() if email else email def clean_resume(self): @@ -1277,11 +1306,30 @@ class AgencyCandidateSubmissionForm(forms.ModelForm): """Override save to set additional fields""" instance = super().save(commit=False) + # Create or get person + from .models import Person + person, created = Person.objects.get_or_create( + email=self.cleaned_data['email'].lower().strip(), + defaults={ + 'first_name': self.cleaned_data['first_name'], + 'last_name': self.cleaned_data['last_name'], + 'phone': self.cleaned_data['phone'], + } + ) + + if not created: + # Update existing person with new info + person.first_name = self.cleaned_data['first_name'] + person.last_name = self.cleaned_data['last_name'] + person.phone = self.cleaned_data['phone'] + person.save() + # Set required fields for agency submission + instance.person = person instance.job = self.assignment.job instance.hiring_agency = self.assignment.agency - instance.stage = Candidate.Stage.APPLIED - instance.applicant_status = Candidate.ApplicantType.CANDIDATE + instance.stage = Application.Stage.APPLIED + instance.applicant_status = Application.ApplicantType.CANDIDATE instance.applied = True if commit: @@ -1586,7 +1634,7 @@ class CandidateEmailForm(forms.Form): return list(set(email_addresses)) # Remove duplicates def get_formatted_message(self): - """Get the formatted message with optional additional information""" + """Get formatted message with optional additional information""" message = self.cleaned_data.get("message", "") # Add candidate information if requested @@ -1777,13 +1825,15 @@ class MessageForm(forms.ModelForm): ) 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) + """Form for candidate signup creating Person and Application""" + + first_name = forms.CharField(max_length=30, required=True, widget=forms.TextInput(attrs={"class": "form-control", "placeholder": "First Name"})) + middle_name = forms.CharField(max_length=30, required=False, widget=forms.TextInput(attrs={"class": "form-control", "placeholder": "Middle Name (optional)"})) + last_name = forms.CharField(max_length=30, required=True, widget=forms.TextInput(attrs={"class": "form-control", "placeholder": "Last Name"})) + email = forms.EmailField(max_length=254, required=True, widget=forms.EmailInput(attrs={"class": "form-control", "placeholder": "Email Address"})) + phone = forms.CharField(max_length=30, required=True, widget=forms.TextInput(attrs={"class": "form-control", "placeholder": "Phone Number"})) + password = forms.CharField(widget=forms.PasswordInput(attrs={"class": "form-control", "placeholder": "Password"}), required=True) + confirm_password = forms.CharField(widget=forms.PasswordInput(attrs={"class": "form-control", "placeholder": "Confirm Password"}), required=True) def clean(self): cleaned_data = super().clean() @@ -1793,4 +1843,43 @@ class CandidateSignupForm(forms.Form): if password != confirm_password: raise forms.ValidationError("Passwords do not match.") - return cleaned_data \ No newline at end of file + return cleaned_data + + def save(self, job): + """Create Person and Application objects""" + from .models import Person, Application + from django.contrib.auth.hashers import make_password + + # Create Person first + person = Person.objects.create( + first_name=self.cleaned_data['first_name'], + middle_name=self.cleaned_data.get('middle_name', ''), + last_name=self.cleaned_data['last_name'], + email=self.cleaned_data['email'], + phone=self.cleaned_data['phone'], + ) + + # Create User account + user = User.objects.create_user( + username=self.cleaned_data['email'], # Use email as username + email=self.cleaned_data['email'], + password=make_password(self.cleaned_data['password']), + first_name=self.cleaned_data['first_name'], + last_name=self.cleaned_data['last_name'], + user_type='candidate' + ) + + # Link User to Person + person.user = user + person.save() + + # Create Application + application = Application.objects.create( + person=person, + job=job, + stage=Application.Stage.APPLIED, + applicant_status=Application.ApplicantType.CANDIDATE, + applied=True, + ) + + return application diff --git a/recruitment/migrations/0001_initial.py b/recruitment/migrations/0001_initial.py index bb16241..68d8fc2 100644 --- a/recruitment/migrations/0001_initial.py +++ b/recruitment/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.6 on 2025-11-09 15:04 +# Generated by Django 5.2.6 on 2025-11-10 14:13 import django.contrib.auth.models import django.contrib.auth.validators @@ -145,6 +145,51 @@ class Migration(migrations.Migration): ('objects', django.contrib.auth.models.UserManager()), ], ), + migrations.CreateModel( + name='Application', + 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')), + ('resume', models.FileField(upload_to='resumes/', verbose_name='Resume')), + ('cover_letter', models.FileField(blank=True, null=True, upload_to='cover_letters/', verbose_name='Cover Letter')), + ('is_resume_parsed', models.BooleanField(default=False, verbose_name='Resume Parsed')), + ('parsed_summary', models.TextField(blank=True, verbose_name='Parsed Summary')), + ('applied', models.BooleanField(default=False, verbose_name='Applied')), + ('stage', models.CharField(choices=[('Applied', 'Applied'), ('Exam', 'Exam'), ('Interview', 'Interview'), ('Offer', 'Offer'), ('Hired', 'Hired'), ('Rejected', 'Rejected')], db_index=True, default='Applied', max_length=20, verbose_name='Stage')), + ('applicant_status', models.CharField(blank=True, choices=[('Applicant', 'Applicant'), ('Candidate', 'Candidate')], default='Applicant', max_length=20, null=True, verbose_name='Applicant Status')), + ('exam_date', models.DateTimeField(blank=True, null=True, verbose_name='Exam Date')), + ('exam_status', models.CharField(blank=True, choices=[('Passed', 'Passed'), ('Failed', 'Failed')], max_length=20, null=True, verbose_name='Exam Status')), + ('interview_date', models.DateTimeField(blank=True, null=True, verbose_name='Interview Date')), + ('interview_status', models.CharField(blank=True, choices=[('Passed', 'Passed'), ('Failed', 'Failed')], max_length=20, null=True, verbose_name='Interview Status')), + ('offer_date', models.DateField(blank=True, null=True, verbose_name='Offer Date')), + ('offer_status', models.CharField(blank=True, choices=[('Accepted', 'Accepted'), ('Rejected', 'Rejected'), ('Pending', 'Pending')], max_length=20, 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(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.SET_NULL, related_name='application_profile', to=settings.AUTH_USER_MODEL, verbose_name='User Account')), + ], + options={ + 'verbose_name': 'Application', + 'verbose_name_plural': 'Applications', + }, + ), + migrations.CreateModel( + name='Candidate', + fields=[ + ], + options={ + 'verbose_name': 'Candidate (Legacy)', + 'verbose_name_plural': 'Candidates (Legacy)', + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('recruitment.application',), + ), migrations.CreateModel( name='FormField', fields=[ @@ -236,43 +281,10 @@ class Migration(migrations.Migration): 'ordering': ['name'], }, ), - migrations.CreateModel( - name='Candidate', - 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')), - ('first_name', models.CharField(max_length=255, verbose_name='First Name')), - ('last_name', models.CharField(max_length=255, verbose_name='Last Name')), - ('email', models.EmailField(db_index=True, max_length=254, verbose_name='Email')), - ('phone', models.CharField(max_length=20, verbose_name='Phone')), - ('address', models.TextField(max_length=200, verbose_name='Address')), - ('resume', models.FileField(upload_to='resumes/', verbose_name='Resume')), - ('is_resume_parsed', models.BooleanField(default=False, verbose_name='Resume Parsed')), - ('is_potential_candidate', models.BooleanField(default=False, verbose_name='Potential Candidate')), - ('parsed_summary', models.TextField(blank=True, verbose_name='Parsed Summary')), - ('applied', models.BooleanField(default=False, verbose_name='Applied')), - ('stage', models.CharField(choices=[('Applied', 'Applied'), ('Exam', 'Exam'), ('Interview', 'Interview'), ('Offer', 'Offer'), ('Hired', 'Hired')], db_index=True, default='Applied', max_length=100, verbose_name='Stage')), - ('applicant_status', models.CharField(blank=True, choices=[('Applicant', 'Applicant'), ('Candidate', 'Candidate')], default='Applicant', max_length=100, null=True, verbose_name='Applicant Status')), - ('exam_date', models.DateTimeField(blank=True, null=True, verbose_name='Exam Date')), - ('exam_status', models.CharField(blank=True, choices=[('Passed', 'Passed'), ('Failed', 'Failed')], max_length=100, null=True, verbose_name='Exam Status')), - ('interview_date', models.DateTimeField(blank=True, null=True, verbose_name='Interview Date')), - ('interview_status', models.CharField(blank=True, choices=[('Passed', 'Passed'), ('Failed', 'Failed')], max_length=100, null=True, verbose_name='Interview Status')), - ('offer_date', models.DateField(blank=True, null=True, verbose_name='Offer Date')), - ('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(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={ - 'verbose_name': 'Candidate', - 'verbose_name_plural': 'Candidates', - }, + migrations.AddField( + model_name='application', + name='hiring_agency', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='applications', to='recruitment.hiringagency', verbose_name='Hiring Agency'), ), migrations.CreateModel( name='JobPosting', @@ -341,7 +353,7 @@ class Migration(migrations.Migration): ('break_end_time', models.TimeField(blank=True, null=True, verbose_name='Break End Time')), ('interview_duration', models.PositiveIntegerField(verbose_name='Interview Duration (minutes)')), ('buffer_time', models.PositiveIntegerField(default=0, verbose_name='Buffer Time (minutes)')), - ('candidates', models.ManyToManyField(blank=True, null=True, related_name='interview_schedules', to='recruitment.candidate')), + ('candidates', models.ManyToManyField(blank=True, null=True, related_name='interview_schedules', to='recruitment.application')), ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interview_schedules', to='recruitment.jobposting')), ], @@ -352,9 +364,9 @@ class Migration(migrations.Migration): field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='form_template', to='recruitment.jobposting'), ), migrations.AddField( - model_name='candidate', + model_name='application', name='job', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='candidates', to='recruitment.jobposting', verbose_name='Job'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='applications', to='recruitment.jobposting', verbose_name='Job'), ), migrations.CreateModel( name='AgencyJobAssignment', @@ -411,6 +423,55 @@ class Migration(migrations.Migration): 'ordering': ['-created_at'], }, ), + migrations.CreateModel( + name='Person', + 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')), + ('first_name', models.CharField(max_length=255, verbose_name='First Name')), + ('last_name', models.CharField(max_length=255, verbose_name='Last Name')), + ('middle_name', models.CharField(blank=True, max_length=255, null=True, verbose_name='Middle Name')), + ('email', models.EmailField(db_index=True, help_text='Unique email address for the person', max_length=254, unique=True, verbose_name='Email')), + ('phone', models.CharField(blank=True, max_length=20, null=True, verbose_name='Phone')), + ('date_of_birth', models.DateField(blank=True, null=True, verbose_name='Date of Birth')), + ('gender', models.CharField(blank=True, choices=[('M', 'Male'), ('F', 'Female'), ('O', 'Other'), ('P', 'Prefer not to say')], max_length=1, null=True, verbose_name='Gender')), + ('nationality', django_countries.fields.CountryField(blank=True, max_length=2, null=True, verbose_name='Nationality')), + ('address', models.TextField(blank=True, null=True, verbose_name='Address')), + ('profile_image', models.ImageField(blank=True, null=True, upload_to='profile_pic/', validators=[recruitment.validators.validate_image_size], verbose_name='Profile Image')), + ('linkedin_profile', models.URLField(blank=True, null=True, verbose_name='LinkedIn Profile URL')), + ('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='person_profile', to=settings.AUTH_USER_MODEL, verbose_name='User Account')), + ], + options={ + 'verbose_name': 'Person', + 'verbose_name_plural': 'People', + }, + ), + 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')), + ('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')), + ('person', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='recruitment.person', verbose_name='Person')), + ], + options={ + 'verbose_name': 'Document', + 'verbose_name_plural': 'Documents', + 'ordering': ['-created_at'], + }, + ), + migrations.AddField( + model_name='application', + name='person', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='applications', to='recruitment.person', verbose_name='Person'), + ), migrations.CreateModel( name='Profile', fields=[ @@ -490,7 +551,7 @@ class Migration(migrations.Migration): ('status', models.CharField(choices=[('scheduled', 'Scheduled'), ('confirmed', 'Confirmed'), ('cancelled', 'Cancelled'), ('completed', 'Completed')], db_index=True, default='scheduled', max_length=20)), ('created_at', models.DateTimeField(auto_now_add=True)), ('updated_at', models.DateTimeField(auto_now=True)), - ('candidate', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.candidate')), + ('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.application')), ('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.jobposting')), ('schedule', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interviews', to='recruitment.interviewschedule')), ('zoom_meeting', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='interview', to='recruitment.zoommeeting')), @@ -598,14 +659,6 @@ class Migration(migrations.Migration): model_name='formtemplate', index=models.Index(fields=['is_active'], name='recruitment_is_acti_ae5efb_idx'), ), - migrations.AddIndex( - model_name='candidate', - index=models.Index(fields=['stage'], name='recruitment_stage_f1c6eb_idx'), - ), - migrations.AddIndex( - model_name='candidate', - index=models.Index(fields=['created_at'], name='recruitment_created_73590f_idx'), - ), migrations.AddIndex( model_name='agencyjobassignment', index=models.Index(fields=['agency', 'status'], name='recruitment_agency__491a54_idx'), @@ -642,6 +695,42 @@ class Migration(migrations.Migration): model_name='message', index=models.Index(fields=['message_type', 'created_at'], name='recruitment_message_f25659_idx'), ), + migrations.AddIndex( + model_name='person', + index=models.Index(fields=['email'], name='recruitment_email_0b1ab1_idx'), + ), + migrations.AddIndex( + model_name='person', + index=models.Index(fields=['first_name', 'last_name'], name='recruitment_first_n_739de5_idx'), + ), + migrations.AddIndex( + model_name='person', + index=models.Index(fields=['created_at'], name='recruitment_created_33495a_idx'), + ), + migrations.AddIndex( + model_name='document', + index=models.Index(fields=['person', 'document_type', 'created_at'], name='recruitment_person__0a6844_idx'), + ), + migrations.AddIndex( + model_name='application', + index=models.Index(fields=['person', 'job'], name='recruitment_person__34355c_idx'), + ), + migrations.AddIndex( + model_name='application', + index=models.Index(fields=['stage'], name='recruitment_stage_52c2d1_idx'), + ), + migrations.AddIndex( + model_name='application', + index=models.Index(fields=['created_at'], name='recruitment_created_80633f_idx'), + ), + migrations.AddIndex( + model_name='application', + index=models.Index(fields=['person', 'stage', 'created_at'], name='recruitment_person__8715ec_idx'), + ), + migrations.AlterUniqueTogether( + name='application', + unique_together={('person', 'job')}, + ), migrations.AddIndex( model_name='jobposting', index=models.Index(fields=['status', 'created_at', 'title'], name='recruitment_status_8b77aa_idx'), @@ -660,7 +749,7 @@ class Migration(migrations.Migration): ), migrations.AddIndex( model_name='scheduledinterview', - index=models.Index(fields=['candidate', 'job'], name='recruitment_candida_43d5b0_idx'), + index=models.Index(fields=['application', 'job'], name='recruitment_applica_927561_idx'), ), migrations.AddIndex( model_name='notification', diff --git a/recruitment/migrations/0002_delete_candidate_and_more.py b/recruitment/migrations/0002_delete_candidate_and_more.py new file mode 100644 index 0000000..1d47a45 --- /dev/null +++ b/recruitment/migrations/0002_delete_candidate_and_more.py @@ -0,0 +1,25 @@ +# Generated by Django 5.2.6 on 2025-11-11 10:17 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0001_initial'), + ] + + operations = [ + migrations.DeleteModel( + name='Candidate', + ), + migrations.RenameField( + model_name='interviewschedule', + old_name='candidates', + new_name='applications', + ), + migrations.RemoveField( + model_name='application', + name='user', + ), + ] diff --git a/recruitment/migrations/0002_document.py b/recruitment/migrations/0002_document.py deleted file mode 100644 index cb62c62..0000000 --- a/recruitment/migrations/0002_document.py +++ /dev/null @@ -1,37 +0,0 @@ -# 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_convert_document_to_generic_fk.py b/recruitment/migrations/0003_convert_document_to_generic_fk.py new file mode 100644 index 0000000..2d3f90c --- /dev/null +++ b/recruitment/migrations/0003_convert_document_to_generic_fk.py @@ -0,0 +1,45 @@ +# Generated by Django 5.2.6 on 2025-11-11 12:13 + +import django.db.models.deletion +import recruitment.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('recruitment', '0002_delete_candidate_and_more'), + ] + + operations = [ + migrations.RemoveIndex( + model_name='document', + name='recruitment_person__0a6844_idx', + ), + migrations.RemoveField( + model_name='document', + name='person', + ), + migrations.AddField( + model_name='document', + name='content_type', + field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype', verbose_name='Content Type'), + preserve_default=False, + ), + migrations.AddField( + model_name='document', + name='object_id', + field=models.PositiveIntegerField(default=1, verbose_name='Object ID'), + preserve_default=False, + ), + migrations.AlterField( + model_name='document', + name='file', + field=models.FileField(upload_to='documents/%Y/%m/', validators=[recruitment.validators.validate_image_size], verbose_name='Document File'), + ), + migrations.AddIndex( + model_name='document', + index=models.Index(fields=['content_type', 'object_id', 'document_type', 'created_at'], name='recruitment_content_547650_idx'), + ), + ] diff --git a/recruitment/migrations/0004_person_agency.py b/recruitment/migrations/0004_person_agency.py new file mode 100644 index 0000000..24bd305 --- /dev/null +++ b/recruitment/migrations/0004_person_agency.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.6 on 2025-11-12 20:22 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0003_convert_document_to_generic_fk'), + ] + + operations = [ + migrations.AddField( + model_name='person', + name='agency', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='recruitment.hiringagency', verbose_name='Hiring Agency'), + ), + ] diff --git a/recruitment/migrations/__pycache__/0001_initial.cpython-313.pyc b/recruitment/migrations/__pycache__/0001_initial.cpython-313.pyc index 9800da7..7ee8741 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 bf9b33a..d7bd83e 100644 --- a/recruitment/models.py +++ b/recruitment/models.py @@ -16,6 +16,8 @@ from django.utils.translation import gettext_lazy as _ from django_extensions.db.fields import RandomCharField from .validators import validate_hash_tags, validate_image_size from django.contrib.auth.models import AbstractUser +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType class CustomUser(AbstractUser): @@ -355,7 +357,7 @@ class JobPosting(Base): @property def current_applications_count(self): """Returns the current number of candidates associated with this job.""" - return self.candidates.count() + return self.applications.count() @property def is_application_limit_reached(self): @@ -367,7 +369,7 @@ class JobPosting(Base): @property def all_candidates(self): - return self.candidates.annotate( + return self.applications.annotate( sortable_score=Coalesce( Cast( "ai_analysis_data__analysis_data__match_score", @@ -439,7 +441,7 @@ class JobPosting(Base): def vacancy_fill_rate(self): total_positions = self.open_positions - no_of_positions_filled = self.candidates.filter(stage__in=["HIRED"]).count() + no_of_positions_filled = self.applications.filter(stage__in=["HIRED"]).count() if total_positions > 0: vacancy_fill_rate = no_of_positions_filled / total_positions @@ -456,21 +458,121 @@ class JobPostingImage(models.Model): post_image = models.ImageField(upload_to="post/", validators=[validate_image_size]) -class Candidate(Base): +class Person(Base): + """Model to store personal information that can be reused across multiple applications""" + + GENDER_CHOICES = [ + ("M", _("Male")), + ("F", _("Female")), + ("O", _("Other")), + ("P", _("Prefer not to say")), + ] + + # Personal Information + first_name = models.CharField(max_length=255, verbose_name=_("First Name")) + last_name = models.CharField(max_length=255, verbose_name=_("Last Name")) + middle_name = models.CharField(max_length=255, blank=True, null=True, verbose_name=_("Middle Name")) + email = models.EmailField( + unique=True, + db_index=True, + verbose_name=_("Email"), + help_text=_("Unique email address for the person") + ) + phone = models.CharField(max_length=20, blank=True, null=True, verbose_name=_("Phone")) + date_of_birth = models.DateField(null=True, blank=True, verbose_name=_("Date of Birth")) + gender = models.CharField( + max_length=1, + choices=GENDER_CHOICES, + blank=True, + null=True, + verbose_name=_("Gender") + ) + nationality = CountryField(blank=True, null=True, verbose_name=_("Nationality")) + address = models.TextField(blank=True, null=True, verbose_name=_("Address")) + + # Optional linking to user account + user = models.OneToOneField( + User, + on_delete=models.SET_NULL, + related_name="person_profile", + verbose_name=_("User Account"), + null=True, + blank=True, + ) + + # Profile information + profile_image = models.ImageField( + null=True, + blank=True, + upload_to="profile_pic/", + validators=[validate_image_size], + verbose_name=_("Profile Image") + ) + linkedin_profile = models.URLField( + blank=True, + null=True, + verbose_name=_("LinkedIn Profile URL") + ) + agency = models.ForeignKey( + "HiringAgency", + on_delete=models.SET_NULL, + null=True, + blank=True, + verbose_name=_("Hiring Agency") + ) + class Meta: + verbose_name = _("Person") + verbose_name_plural = _("People") + indexes = [ + models.Index(fields=["email"]), + models.Index(fields=["first_name", "last_name"]), + models.Index(fields=["created_at"]), + ] + + def __str__(self): + return f"{self.first_name} {self.last_name}" + + @property + def full_name(self): + return f"{self.first_name} {self.last_name}" + + @property + def age(self): + """Calculate age from date of birth""" + if self.date_of_birth: + today = timezone.now().date() + return today.year - self.date_of_birth.year - ( + (today.month, today.day) < (self.date_of_birth.month, self.date_of_birth.day) + ) + return None + + @property + def documents(self): + """Return all documents associated with this Person""" + from django.contrib.contenttypes.models import ContentType + content_type = ContentType.objects.get_for_model(self.__class__) + return Document.objects.filter(content_type=content_type, object_id=self.id) + + +class Application(Base): + """Model to store job-specific application data""" + class Stage(models.TextChoices): APPLIED = "Applied", _("Applied") EXAM = "Exam", _("Exam") INTERVIEW = "Interview", _("Interview") OFFER = "Offer", _("Offer") HIRED = "Hired", _("Hired") + REJECTED = "Rejected", _("Rejected") class ExamStatus(models.TextChoices): PASSED = "Passed", _("Passed") FAILED = "Failed", _("Failed") - class Status(models.TextChoices): + class OfferStatus(models.TextChoices): ACCEPTED = "Accepted", _("Accepted") REJECTED = "Rejected", _("Rejected") + PENDING = "Pending", _("Pending") class ApplicantType(models.TextChoices): APPLICANT = "Applicant", _("Applicant") @@ -478,43 +580,50 @@ class Candidate(Base): # Stage transition validation constants STAGE_SEQUENCE = { - "Applied": ["Exam", "Interview", "Offer"], - "Exam": ["Interview", "Offer"], - "Interview": ["Offer"], - "Offer": [], # Final stage - no further transitions + "Applied": ["Exam", "Interview", "Offer", "Rejected"], + "Exam": ["Interview", "Offer", "Rejected"], + "Interview": ["Offer", "Rejected"], + "Offer": ["Hired", "Rejected"], + "Rejected": [], # Final stage - no further transitions + "Hired": [], # Final stage - no further transitions } - user = models.OneToOneField( - User, + # Core relationships + person = models.ForeignKey( + Person, on_delete=models.CASCADE, - related_name="candidate_profile", - verbose_name=_("User"), - null=True, - blank=True, + related_name="applications", + verbose_name=_("Person"), ) job = models.ForeignKey( JobPosting, on_delete=models.CASCADE, - related_name="candidates", + related_name="applications", verbose_name=_("Job"), ) - first_name = models.CharField(max_length=255, verbose_name=_("First Name")) - last_name = models.CharField(max_length=255, verbose_name=_("Last Name")) - email = models.EmailField(db_index=True, verbose_name=_("Email")) # Added index - phone = models.CharField(max_length=20, verbose_name=_("Phone")) - address = models.TextField(max_length=200, verbose_name=_("Address")) + + # Application-specific data resume = models.FileField(upload_to="resumes/", verbose_name=_("Resume")) + cover_letter = models.FileField( + upload_to="cover_letters/", + blank=True, + null=True, + verbose_name=_("Cover Letter") + ) is_resume_parsed = models.BooleanField( - default=False, verbose_name=_("Resume Parsed") + default=False, + verbose_name=_("Resume Parsed") ) - is_potential_candidate = models.BooleanField( - default=False, verbose_name=_("Potential Candidate") + parsed_summary = models.TextField( + blank=True, + verbose_name=_("Parsed Summary") ) - parsed_summary = models.TextField(blank=True, verbose_name=_("Parsed Summary")) + + # Workflow fields applied = models.BooleanField(default=False, verbose_name=_("Applied")) stage = models.CharField( db_index=True, - max_length=100, # Added index + max_length=20, default="Applied", choices=Stage.choices, verbose_name=_("Stage"), @@ -522,15 +631,17 @@ class Candidate(Base): applicant_status = models.CharField( choices=ApplicantType.choices, default="Applicant", - max_length=100, + max_length=20, null=True, blank=True, verbose_name=_("Applicant Status"), ) + + # Timeline fields exam_date = models.DateTimeField(null=True, blank=True, verbose_name=_("Exam Date")) exam_status = models.CharField( choices=ExamStatus.choices, - max_length=100, + max_length=20, null=True, blank=True, verbose_name=_("Exam Status"), @@ -540,30 +651,36 @@ class Candidate(Base): ) interview_status = models.CharField( choices=ExamStatus.choices, - max_length=100, + max_length=20, null=True, blank=True, verbose_name=_("Interview Status"), ) offer_date = models.DateField(null=True, blank=True, verbose_name=_("Offer Date")) offer_status = models.CharField( - choices=Status.choices, - max_length=100, + choices=OfferStatus.choices, + max_length=20, null=True, blank=True, verbose_name=_("Offer Status"), ) hired_date = models.DateField(null=True, blank=True, verbose_name=_("Hired Date")) join_date = models.DateField(null=True, blank=True, verbose_name=_("Join Date")) + + # AI Analysis ai_analysis_data = models.JSONField( verbose_name="AI Analysis Data", default=dict, help_text="Full JSON output from the resume scoring model.", null=True, blank=True, - ) # {'resume_data': {}, 'analysis_data': {}} + ) + retry = models.SmallIntegerField( + verbose_name="Resume Parsing Retry", + default=3 + ) - retry = models.SmallIntegerField(verbose_name="Resume Parsing Retry", default=3) + # Source tracking hiring_source = models.CharField( max_length=255, null=True, @@ -581,27 +698,36 @@ class Candidate(Base): on_delete=models.SET_NULL, null=True, blank=True, - related_name="candidates", + related_name="applications", verbose_name=_("Hiring Agency"), ) + # Optional linking to user account (for candidate portal access) + # user = models.OneToOneField( + # User, + # on_delete=models.SET_NULL, + # related_name="application_profile", + # verbose_name=_("User Account"), + # null=True, + # blank=True, + # ) + class Meta: - verbose_name = _("Candidate") - verbose_name_plural = _("Candidates") + verbose_name = _("Application") + verbose_name_plural = _("Applications") indexes = [ + models.Index(fields=["person", "job"]), models.Index(fields=["stage"]), models.Index(fields=["created_at"]), + models.Index(fields=["person", "stage", "created_at"]), ] + unique_together = [["person", "job"]] # Prevent duplicate applications - def set_field(self, key: str, value: Any): - """ - Generic method to set any single key-value pair and save. - """ - self.ai_analysis_data[key] = value - # self.save(update_fields=['ai_analysis_data']) + def __str__(self): + return f"{self.person.full_name} - {self.job.title}" # ==================================================================== - # ✨ PROPERTIES (GETTERS) + # ✨ PROPERTIES (GETTERS) - Migrated from Candidate # ==================================================================== @property def resume_data(self): @@ -629,11 +755,8 @@ class Candidate(Base): @property def industry_match_score(self) -> int: """16. A score (0-100) for the relevance of the candidate's industry experience.""" - # Renamed to clarify: experience_industry_match return self.analysis_data.get("experience_industry_match", 0) - # --- Properties for Funnel & Screening Efficiency --- - @property def min_requirements_met(self) -> bool: """14. Boolean (true/false) indicating if all non-negotiable minimum requirements are met.""" @@ -654,8 +777,6 @@ class Candidate(Base): """8. The candidate's most recent or current professional job title.""" return self.analysis_data.get("most_recent_job_title", "N/A") - # --- Properties for Structured Detail --- - @property def criteria_checklist(self) -> Dict[str, str]: """5 & 6. An object rating the candidate's match for each specific criterion.""" @@ -671,8 +792,6 @@ class Candidate(Base): """12. A list of languages and their fluency levels mentioned.""" return self.analysis_data.get("language_fluency", []) - # --- Properties for Summaries and Narrative --- - @property def strengths(self) -> str: """2. A brief summary of why the candidate is a strong fit.""" @@ -691,55 +810,94 @@ class Candidate(Base): @property def recommendation(self) -> str: """9. Provide a detailed final recommendation for the candidate.""" - # Using a more descriptive name to avoid conflict with potential built-in methods return self.analysis_data.get("recommendation", "") - @property - def name(self): - return f"{self.first_name} {self.last_name}" - - @property - def full_name(self): - return self.name - - @property - def get_file_size(self): - if self.resume: - return self.resume.size - return 0 - - def save(self, *args, **kwargs): - """Override save to ensure validation is called""" - self.clean() # Call validation before saving - super().save(*args, **kwargs) + # ==================================================================== + # 🔄 HELPER METHODS + # ==================================================================== + def set_field(self, key: str, value: Any): + """Generic method to set any single key-value pair and save.""" + self.ai_analysis_data[key] = value def get_available_stages(self): - """Get list of stages this candidate can transition to""" + """Get list of stages this application can transition to""" if not self.pk: # New record return ["Applied"] old_stage = self.__class__.objects.get(pk=self.pk).stage return self.STAGE_SEQUENCE.get(old_stage, []) + def save(self, *args, **kwargs): + """Override save to ensure validation is called""" + self.clean() # Call validation before saving + super().save(*args, **kwargs) + + # ==================================================================== + # 📋 LEGACY COMPATIBILITY PROPERTIES + # ==================================================================== + # These properties maintain compatibility with existing code that expects Candidate model + @property + def first_name(self): + """Legacy compatibility - delegates to person.first_name""" + return self.person.first_name + + @property + def last_name(self): + """Legacy compatibility - delegates to person.last_name""" + return self.person.last_name + + @property + def email(self): + """Legacy compatibility - delegates to person.email""" + return self.person.email + + @property + def phone(self): + """Legacy compatibility - delegates to person.phone if available""" + return self.person.phone or "" + + @property + def address(self): + """Legacy compatibility - delegates to person.address if available""" + return self.person.address or "" + + @property + def name(self): + """Legacy compatibility - delegates to person.full_name""" + return self.person.full_name + + @property + def full_name(self): + """Legacy compatibility - delegates to person.full_name""" + return self.person.full_name + + @property + def get_file_size(self): + """Legacy compatibility - returns resume file size""" + if self.resume: + return self.resume.size + return 0 + @property def submission(self): + """Legacy compatibility - get form submission for this application""" return FormSubmission.objects.filter(template__job=self.job).first() @property def responses(self): + """Legacy compatibility - get form responses for this application""" if self.submission: return self.submission.responses.all() return [] - def __str__(self): - return self.full_name - @property def get_meetings(self): + """Legacy compatibility - get scheduled interviews for this application""" return self.scheduled_interviews.all() @property def get_latest_meeting(self): + """Legacy compatibility - get latest meeting for this application""" schedule = self.scheduled_interviews.order_by("-created_at").first() if schedule: return schedule.zoom_meeting @@ -747,49 +905,71 @@ class Candidate(Base): @property def has_future_meeting(self): - """ - Checks if the candidate has any scheduled interviews for a future date/time. - """ - # Ensure timezone.now() is used for comparison + """Legacy compatibility - check for future meetings""" now = timezone.now() - # Check if any related ScheduledInterview has a future interview_date and interview_time - # We need to combine date and time for a proper datetime comparison if they are separate fields future_meetings = ( self.scheduled_interviews.filter(interview_date__gt=now.date()) .filter(interview_time__gte=now.time()) .exists() ) - - # Also check for interviews happening later today today_future_meetings = self.scheduled_interviews.filter( interview_date=now.date(), interview_time__gte=now.time() ).exists() - return future_meetings or today_future_meetings @property def scoring_timeout(self): + """Legacy compatibility - check scoring timeout""" return timezone.now() <= (self.created_at + timezone.timedelta(minutes=5)) @property def get_interview_date(self): + """Legacy compatibility - get interview date""" if hasattr(self, "scheduled_interview") and self.scheduled_interview: return self.scheduled_interviews.first().interview_date return None @property def get_interview_time(self): + """Legacy compatibility - get interview time""" if hasattr(self, "scheduled_interview") and self.scheduled_interview: return self.scheduled_interviews.first().interview_time return None @property def time_to_hire_days(self): + """Legacy compatibility - calculate time to hire""" if self.hired_date and self.created_at: time_to_hire = self.hired_date - self.created_at.date() return time_to_hire.days return 0 + @property + def documents(self): + """Return all documents associated with this Application""" + from django.contrib.contenttypes.models import ContentType + content_type = ContentType.objects.get_for_model(self.__class__) + return Document.objects.filter(content_type=content_type, object_id=self.id) + + +# ============================================================================ +# 🔄 BACKWARD COMPATIBILITY - Keep Candidate model for transition period +# ============================================================================ +# class Candidate(Application): +# """ +# DEPRECATED: Legacy Candidate model for backward compatibility. + +# This model extends Application to maintain compatibility with existing code +# during the migration period. All new code should use Application model. + +# TODO: Remove this model after migration is complete and all code is updated. +# """ + +# class Meta: +# proxy = True +# verbose_name = _("Candidate (Legacy)") +# verbose_name_plural = _("Candidates (Legacy)") + class TrainingMaterial(Base): title = models.CharField(max_length=255, verbose_name=_("Title")) @@ -865,14 +1045,19 @@ class ZoomMeeting(Base): # Timestamps def __str__(self): - return self.topic @ property + return self.topic + + @property def get_job(self): return self.interview.job @property def get_candidate(self): - return self.interview.candidate + return self.interview.application.person + @property + def candidate_full_name(self): + return self.interview.application.person.full_name @property def get_participants(self): @@ -1724,8 +1909,8 @@ class InterviewSchedule(Base): related_name="interview_schedules", db_index=True, ) - candidates = models.ManyToManyField( - Candidate, related_name="interview_schedules", blank=True, null=True + applications = models.ManyToManyField( + Application, related_name="interview_schedules", blank=True, null=True ) start_date = models.DateField( db_index=True, verbose_name=_("Start Date") @@ -1770,8 +1955,8 @@ class InterviewSchedule(Base): class ScheduledInterview(Base): """Stores individual scheduled interviews""" - candidate = models.ForeignKey( - Candidate, + application = models.ForeignKey( + Application, on_delete=models.CASCADE, related_name="scheduled_interviews", db_index=True, @@ -1814,13 +1999,13 @@ class ScheduledInterview(Base): updated_at = models.DateTimeField(auto_now=True) def __str__(self): - return f"Interview with {self.candidate.name} for {self.job.title}" + return f"Interview with {self.application.person.full_name} for {self.job.title}" class Meta: indexes = [ models.Index(fields=["job", "status"]), models.Index(fields=["interview_date", "interview_time"]), - models.Index(fields=["candidate", "job"]), + models.Index(fields=["application", "job"]), ] @@ -2042,7 +2227,7 @@ class Message(Base): class Document(Base): - """Model for storing candidate documents""" + """Model for storing documents using Generic Foreign Key""" class DocumentType(models.TextChoices): RESUME = "resume", _("Resume") @@ -2054,14 +2239,19 @@ class Document(Base): EXPERIENCE = "experience", _("Experience Letter") OTHER = "other", _("Other") - candidate = models.ForeignKey( - Candidate, + # Generic Foreign Key fields + content_type = models.ForeignKey( + ContentType, on_delete=models.CASCADE, - related_name="documents", - verbose_name=_("Candidate"), + verbose_name=_("Content Type"), ) + object_id = models.PositiveIntegerField( + verbose_name=_("Object ID"), + ) + content_object = GenericForeignKey('content_type', 'object_id') + file = models.FileField( - upload_to="candidate_documents/%Y/%m/", + upload_to="documents/%Y/%m/", verbose_name=_("Document File"), validators=[validate_image_size], ) @@ -2089,11 +2279,22 @@ class Document(Base): verbose_name_plural = _("Documents") ordering = ["-created_at"] indexes = [ - models.Index(fields=["candidate", "document_type", "created_at"]), + models.Index(fields=["content_type", "object_id", "document_type", "created_at"]), ] def __str__(self): - return f"{self.get_document_type_display()} - {self.candidate.name}" + try: + if hasattr(self.content_object, 'full_name'): + object_name = self.content_object.full_name + elif hasattr(self.content_object, 'title'): + object_name = self.content_object.title + elif hasattr(self.content_object, '__str__'): + object_name = str(self.content_object) + else: + object_name = f"Object {self.object_id}" + return f"{self.get_document_type_display()} - {object_name}" + except: + return f"{self.get_document_type_display()} - {self.object_id}" @property def file_size(self): diff --git a/recruitment/serializers.py b/recruitment/serializers.py index ea52220..6387523 100644 --- a/recruitment/serializers.py +++ b/recruitment/serializers.py @@ -1,14 +1,14 @@ from rest_framework import serializers -from .models import JobPosting, Candidate +from .models import JobPosting, Application class JobPostingSerializer(serializers.ModelSerializer): class Meta: model = JobPosting fields = '__all__' -class CandidateSerializer(serializers.ModelSerializer): +class ApplicationSerializer(serializers.ModelSerializer): job_title = serializers.CharField(source='job.title', read_only=True) class Meta: - model = Candidate + model = Application fields = '__all__' diff --git a/recruitment/signals.py b/recruitment/signals.py index 4865c3e..1881f43 100644 --- a/recruitment/signals.py +++ b/recruitment/signals.py @@ -8,8 +8,9 @@ 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,HiringAgency +from .models import FormField,FormStage,FormTemplate,Application,JobPosting,Notification,HiringAgency,Person from django.contrib.auth import get_user_model + logger = logging.getLogger(__name__) User = get_user_model() @@ -57,7 +58,7 @@ def format_job(sender, instance, created, **kwargs): # instance.form_template.is_active = False # instance.save() -@receiver(post_save, sender=Candidate) +@receiver(post_save, sender=Application) def score_candidate_resume(sender, instance, created, **kwargs): if instance.resume and not instance.is_resume_parsed: logger.info(f"Scoring resume for candidate {instance.pk}") @@ -415,10 +416,10 @@ def hiring_agency_created(sender, instance, created, **kwargs): user.save() instance.user = user instance.save() -@receiver(post_save, sender=Candidate) -def candidate_created(sender, instance, created, **kwargs): +@receiver(post_save, sender=Person) +def person_created(sender, instance, created, **kwargs): if created: - logger.info(f"New candidate created: {instance.pk} - {instance.email}") + logger.info(f"New Person created: {instance.pk} - {instance.email}") user = User.objects.create_user( username=instance.slug, first_name=instance.first_name, diff --git a/recruitment/tasks.py b/recruitment/tasks.py index 06cb795..c3ef8a0 100644 --- a/recruitment/tasks.py +++ b/recruitment/tasks.py @@ -7,7 +7,7 @@ from PyPDF2 import PdfReader from datetime import datetime from django.db import transaction from .utils import create_zoom_meeting -from recruitment.models import Candidate +from recruitment.models import Application from . linkedin_service import LinkedInService from django.shortcuts import get_object_or_404 from . models import JobPosting @@ -244,8 +244,8 @@ def handle_reume_parsing_and_scoring(pk): # --- 1. Robust Object Retrieval (Prevents looping on DoesNotExist) --- try: - instance = Candidate.objects.get(pk=pk) - except Candidate.DoesNotExist: + instance = Application.objects.get(pk=pk) + except Application.DoesNotExist: # Exit gracefully if the candidate was deleted after the task was queued logger.warning(f"Candidate matching query does not exist for pk={pk}. Exiting task.") print(f"Candidate matching query does not exist for pk={pk}. Exiting task.") @@ -453,7 +453,7 @@ def create_interview_and_meeting( Synchronous task for a single interview slot, dispatched by django-q. """ try: - candidate = Candidate.objects.get(pk=candidate_id) + candidate = Application.objects.get(pk=candidate_id) job = JobPosting.objects.get(pk=job_id) schedule = InterviewSchedule.objects.get(pk=schedule_id) @@ -476,7 +476,7 @@ def create_interview_and_meeting( password=result["meeting_details"]["password"] ) ScheduledInterview.objects.create( - candidate=candidate, + application=Application, job=job, zoom_meeting=zoom_meeting, schedule=schedule, @@ -484,11 +484,11 @@ def create_interview_and_meeting( interview_time=slot_time ) # Log success or use Django-Q result system for monitoring - logger.info(f"Successfully scheduled interview for {candidate.name}") + logger.info(f"Successfully scheduled interview for {Application.name}") return True # Task succeeded else: # Handle Zoom API failure (e.g., log it or notify administrator) - logger.error(f"Zoom API failed for {candidate.name}: {result['message']}") + logger.error(f"Zoom API failed for {Application.name}: {result['message']}") return False # Task failed except Exception as e: @@ -703,14 +703,14 @@ def sync_candidate_to_source_task(candidate_id, source_id): try: # Get the candidate and source - candidate = Candidate.objects.get(pk=candidate_id) + application = Application.objects.get(pk=candidate_id) source = Source.objects.get(pk=source_id) # Initialize sync service sync_service = CandidateSyncService() # Perform the sync operation - result = sync_service.sync_candidate_to_source(candidate, source) + result = sync_service.sync_candidate_to_source(application, source) # Log the operation IntegrationLog.objects.create( @@ -718,7 +718,7 @@ def sync_candidate_to_source_task(candidate_id, source_id): action=IntegrationLog.ActionChoices.SYNC, endpoint=source.sync_endpoint or "unknown", method=source.sync_method or "POST", - request_data={"candidate_id": candidate_id, "candidate_name": candidate.name}, + request_data={"candidate_id": candidate_id, "application_name": application.name}, response_data=result, status_code="SUCCESS" if result.get('success') else "ERROR", error_message=result.get('error') if not result.get('success') else None, @@ -730,8 +730,8 @@ def sync_candidate_to_source_task(candidate_id, source_id): logger.info(f"Sync completed for candidate {candidate_id} to source {source_id}: {result}") return result - except Candidate.DoesNotExist: - error_msg = f"Candidate not found: {candidate_id}" + except Application.DoesNotExist: + error_msg = f"Application not found: {candidate_id}" logger.error(error_msg) return {"success": False, "error": error_msg} diff --git a/recruitment/tests.py b/recruitment/tests.py index 20feb89..afadf48 100644 --- a/recruitment/tests.py +++ b/recruitment/tests.py @@ -1,5 +1,5 @@ from django.test import TestCase, Client -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.urls import reverse from django.utils import timezone from django.core.files.uploadedfile import SimpleUploadedFile @@ -7,6 +7,8 @@ from datetime import datetime, time, timedelta import json from unittest.mock import patch, MagicMock +User = get_user_model() + from .models import ( JobPosting, Candidate, ZoomMeeting, FormTemplate, FormStage, FormField, FormSubmission, FieldResponse, InterviewSchedule, ScheduledInterview, @@ -14,11 +16,11 @@ from .models import ( ) from .forms import ( JobPostingForm, CandidateForm, ZoomMeetingForm, MeetingCommentForm, - CandidateStageForm, InterviewScheduleForm + CandidateStageForm, InterviewScheduleForm, CandidateSignupForm ) from .views import ( ZoomMeetingListView, ZoomMeetingCreateView, job_detail, candidate_screening_view, - candidate_exam_view, candidate_interview_view, submit_form, api_schedule_candidate_meeting + candidate_exam_view, candidate_interview_view, api_schedule_candidate_meeting ) from .views_frontend import CandidateListView, JobListView from .utils import create_zoom_meeting, get_candidates_from_request @@ -46,14 +48,21 @@ class BaseTestCase(TestCase): location_country='Saudi Arabia', description='Job description', qualifications='Job qualifications', + application_deadline=timezone.now() + timedelta(days=30), created_by=self.user ) - self.candidate = Candidate.objects.create( + # Create a person first + from .models import Person + person = Person.objects.create( first_name='John', last_name='Doe', email='john@example.com', - phone='1234567890', + phone='1234567890' + ) + + self.candidate = Candidate.objects.create( + person=person, resume=SimpleUploadedFile('resume.pdf', b'file_content', content_type='application/pdf'), job=self.job, stage='Applied' @@ -231,28 +240,6 @@ class ViewTests(BaseTestCase): self.assertEqual(response.status_code, 200) self.assertContains(response, 'success') - def test_submit_form(self): - """Test submit_form view""" - # Create a form template first - template = FormTemplate.objects.create( - job=self.job, - name='Test Template', - created_by=self.user, - is_active=True - ) - - data = { - 'field_1': 'John', # Assuming field ID 1 corresponds to First Name - 'field_2': 'Doe', # Assuming field ID 2 corresponds to Last Name - 'field_3': 'john@example.com', # Email - } - - response = self.client.post( - reverse('application_submit', kwargs={'template_id': template.id}), - data - ) - # After successful submission, should redirect to success page - self.assertEqual(response.status_code, 302) class FormTests(BaseTestCase): @@ -268,13 +255,13 @@ class FormTests(BaseTestCase): 'location_city': 'Riyadh', 'location_state': 'Riyadh', 'location_country': 'Saudi Arabia', - 'description': 'Job description', + 'description': 'Job description with at least 20 characters to meet validation requirements', 'qualifications': 'Job qualifications', 'salary_range': '5000-7000', 'application_deadline': '2025-12-31', 'max_applications': '100', 'open_positions': '2', - 'hash_tags': '#hiring, #jobopening' + 'hash_tags': '#hiring,#jobopening' } form = JobPostingForm(data=form_data) self.assertTrue(form.is_valid()) @@ -315,24 +302,51 @@ class FormTests(BaseTestCase): form_data = { 'stage': 'Exam' } - form = CandidateStageForm(data=form_data, candidate=self.candidate) + form = CandidateStageForm(data=form_data, instance=self.candidate) self.assertTrue(form.is_valid()) def test_interview_schedule_form(self): """Test InterviewScheduleForm""" + # Update candidate to Interview stage first + self.candidate.stage = 'Interview' + self.candidate.save() + form_data = { 'candidates': [self.candidate.id], 'start_date': (timezone.now() + timedelta(days=1)).date(), 'end_date': (timezone.now() + timedelta(days=7)).date(), 'working_days': [0, 1, 2, 3, 4], # Monday to Friday - 'start_time': '09:00', - 'end_time': '17:00', - 'interview_duration': 60, - 'buffer_time': 15 } form = InterviewScheduleForm(slug=self.job.slug, data=form_data) self.assertTrue(form.is_valid()) + def test_candidate_signup_form_valid(self): + """Test CandidateSignupForm with valid data""" + form_data = { + 'first_name': 'John', + 'last_name': 'Doe', + 'email': 'john.doe@example.com', + 'phone': '+1234567890', + 'password': 'SecurePass123', + 'confirm_password': 'SecurePass123' + } + form = CandidateSignupForm(data=form_data) + self.assertTrue(form.is_valid()) + + def test_candidate_signup_form_password_mismatch(self): + """Test CandidateSignupForm with password mismatch""" + form_data = { + 'first_name': 'John', + 'last_name': 'Doe', + 'email': 'john.doe@example.com', + 'phone': '+1234567890', + 'password': 'SecurePass123', + 'confirm_password': 'DifferentPass123' + } + form = CandidateSignupForm(data=form_data) + self.assertFalse(form.is_valid()) + self.assertIn('Passwords do not match', str(form.errors)) + class IntegrationTests(BaseTestCase): """Integration tests for multiple components""" @@ -340,11 +354,14 @@ class IntegrationTests(BaseTestCase): def test_candidate_journey(self): """Test the complete candidate journey from application to interview""" # 1. Create candidate - candidate = Candidate.objects.create( + person = Person.objects.create( first_name='Jane', last_name='Smith', email='jane@example.com', - phone='9876543210', + phone='9876543210' + ) + candidate = Candidate.objects.create( + person=person, resume=SimpleUploadedFile('resume.pdf', b'file_content', content_type='application/pdf'), job=self.job, stage='Applied' @@ -449,11 +466,15 @@ class PerformanceTests(BaseTestCase): """Test pagination with large datasets""" # Create many candidates for i in range(100): - Candidate.objects.create( + person = Person.objects.create( first_name=f'Candidate{i}', last_name=f'Test{i}', email=f'candidate{i}@example.com', - phone=f'123456789{i}', + phone=f'123456789{i}' + ) + Candidate.objects.create( + person=person, + resume=SimpleUploadedFile(f'resume{i}.pdf', b'file_content', content_type='application/pdf'), job=self.job, stage='Applied' ) @@ -594,13 +615,17 @@ class TestFactories: @staticmethod def create_candidate(**kwargs): job = TestFactories.create_job_posting() + person = Person.objects.create( + first_name='Test', + last_name='Candidate', + email='test@example.com', + phone='1234567890' + ) defaults = { - 'first_name': 'Test', - 'last_name': 'Candidate', - 'email': 'test@example.com', - 'phone': '1234567890', + 'person': person, 'job': job, - 'stage': 'Applied' + 'stage': 'Applied', + 'resume': SimpleUploadedFile('resume.pdf', b'file_content', content_type='application/pdf') } defaults.update(kwargs) return Candidate.objects.create(**defaults) diff --git a/recruitment/urls.py b/recruitment/urls.py index b1a0ccd..d8083d1 100644 --- a/recruitment/urls.py +++ b/recruitment/urls.py @@ -7,6 +7,12 @@ from . import views_source urlpatterns = [ path("", views_frontend.dashboard_view, name="dashboard"), # Job URLs (using JobPosting model) + path("persons/", views.PersonListView.as_view(), name="person_list"), + path("persons/create/", views.PersonCreateView.as_view(), name="person_create"), + path("persons//", views.PersonDetailView.as_view(), name="person_detail"), + path("persons//update/", views.PersonUpdateView.as_view(), name="person_update"), + path("persons//delete/", views.PersonDeleteView.as_view(), name="person_delete"), + path("jobs/", views_frontend.JobListView.as_view(), name="job_list"), path("jobs/create/", views.create_job, name="job_create"), path( @@ -38,31 +44,31 @@ urlpatterns = [ ), # Candidate URLs path( - "candidates/", views_frontend.CandidateListView.as_view(), name="candidate_list" + "candidates/", views_frontend.ApplicationListView.as_view(), name="candidate_list" ), path( "candidates/create/", - views_frontend.CandidateCreateView.as_view(), + views_frontend.ApplicationCreateView.as_view(), name="candidate_create", ), path( "candidates/create//", - views_frontend.CandidateCreateView.as_view(), + views_frontend.ApplicationCreateView.as_view(), name="candidate_create_for_job", ), path( "jobs//candidates/", - views_frontend.JobCandidatesListView.as_view(), + views_frontend.JobApplicationListView.as_view(), name="job_candidates_list", ), path( "candidates//update/", - views_frontend.CandidateUpdateView.as_view(), + views_frontend.ApplicationUpdateView.as_view(), name="candidate_update", ), path( "candidates//delete/", - views_frontend.CandidateDeleteView.as_view(), + views_frontend.ApplicationDeleteView.as_view(), name="candidate_delete", ), path( @@ -478,6 +484,16 @@ urlpatterns = [ views.candidate_portal_dashboard, name="candidate_portal_dashboard", ), + path( + "portal/dashboard/", + views.agency_portal_dashboard, + name="agency_portal_dashboard", + ), + path( + "portal/persons/", + views.agency_portal_persons_list, + name="agency_portal_persons_list", + ), path( "portal/assignment//", views.agency_portal_assignment_detail, @@ -571,7 +587,7 @@ urlpatterns = [ path("api/unread-count/", views.api_unread_count, name="api_unread_count"), # Documents - path("documents/upload//", views.document_upload, name="document_upload"), + 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 e333af2..feedbda 100644 --- a/recruitment/views.py +++ b/recruitment/views.py @@ -1,12 +1,22 @@ 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 .forms import StaffUserCreationForm,ToggleAccountForm, JobPostingStatusForm,LinkedPostContentForm,CandidateEmailForm +from .decorators import ( + agency_user_required, + candidate_user_required, + staff_user_required, + staff_or_agency_required, + staff_or_candidate_required, + AgencyRequiredMixin, + CandidateRequiredMixin, + StaffRequiredMixin, + StaffOrAgencyRequiredMixin, + StaffOrCandidateRequiredMixin +) +from .forms import StaffUserCreationForm,ToggleAccountForm, JobPostingStatusForm,LinkedPostContentForm,CandidateEmailForm,InterviewForm,ProfileImageUploadForm,ParticipantsSelectForm from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods from django.http import HttpResponse, JsonResponse @@ -42,19 +52,20 @@ from .forms import ( HiringAgencyForm, AgencyJobAssignmentForm, AgencyAccessLinkForm, - AgencyCandidateSubmissionForm, + AgencyApplicationSubmissionForm, AgencyLoginForm, PortalLoginForm, MessageForm, + PersonForm ) from easyaudit.models import CRUDEvent, LoginEvent, RequestEvent from rest_framework import viewsets from django.contrib import messages from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger from .linkedin_service import LinkedInService -from .serializers import JobPostingSerializer, CandidateSerializer +from .serializers import JobPostingSerializer, ApplicationSerializer from django.shortcuts import get_object_or_404, render, redirect -from django.views.generic import CreateView, UpdateView, DetailView, ListView +from django.views.generic import CreateView, UpdateView, DetailView, ListView,DeleteView from .utils import ( create_zoom_meeting, delete_zoom_meeting, @@ -76,7 +87,8 @@ from .models import ( InterviewSchedule, BreakTime, ZoomMeeting, - Candidate, + Application, + Person, JobPosting, ScheduledInterview, JobPostingImage, @@ -101,22 +113,70 @@ from django_q.tasks import async_task from django.db.models import Prefetch from django.db.models import Q, Count, Avg from django.db.models import FloatField +from django.urls import reverse_lazy logger = logging.getLogger(__name__) User = get_user_model() +class PersonListView(StaffRequiredMixin, ListView): + model = Person + template_name = "people/person_list.html" + context_object_name = "people_list" + + +class PersonCreateView(CreateView): + model = Person + template_name = "people/create_person.html" + form_class = PersonForm + # success_url = reverse_lazy("person_list") + + def form_valid(self, form): + if 'HX-Request' in self.request.headers: + instance = form.save() + view = self.request.POST.get("view") + if view == "portal": + slug = self.request.POST.get("agency") + if slug: + agency = HiringAgency.objects.get(slug=slug) + print(agency) + instance.agency = agency + instance.save() + return redirect("agency_portal_persons_list") + if view == "job": + return redirect("candidate_create") + return super().form_valid(form) + + +class PersonDetailView(DetailView): + model = Person + template_name = "people/person_detail.html" + context_object_name = "person" + + +class PersonUpdateView(StaffRequiredMixin, UpdateView): + model = Person + template_name = "people/update_person.html" + form_class = PersonForm + success_url = reverse_lazy("person_list") + + +class PersonDeleteView(StaffRequiredMixin, DeleteView): + model = Person + template_name = "people/delete_person.html" + success_url = reverse_lazy("person_list") + class JobPostingViewSet(viewsets.ModelViewSet): queryset = JobPosting.objects.all() serializer_class = JobPostingSerializer class CandidateViewSet(viewsets.ModelViewSet): - queryset = Candidate.objects.all() - serializer_class = CandidateSerializer + queryset = Application.objects.all() + serializer_class = ApplicationSerializer -class ZoomMeetingCreateView(LoginRequiredMixin, CreateView): +class ZoomMeetingCreateView(StaffRequiredMixin, CreateView): model = ZoomMeeting template_name = "meetings/create_meeting.html" form_class = ZoomMeetingForm @@ -156,7 +216,7 @@ class ZoomMeetingCreateView(LoginRequiredMixin, CreateView): return redirect(reverse("create_meeting", kwargs={"slug": instance.slug})) -class ZoomMeetingListView(LoginRequiredMixin, ListView): +class ZoomMeetingListView(StaffRequiredMixin, ListView): model = ZoomMeeting template_name = "meetings/list_meetings.html" context_object_name = "meetings" @@ -170,7 +230,7 @@ class ZoomMeetingListView(LoginRequiredMixin, ListView): queryset = queryset.prefetch_related( Prefetch( "interview", # related_name from ZoomMeeting to ScheduledInterview - queryset=ScheduledInterview.objects.select_related("candidate", "job"), + queryset=ScheduledInterview.objects.select_related("application", "job"), to_attr="interview_details", # Changed to not start with underscore ) ) @@ -194,8 +254,8 @@ class ZoomMeetingListView(LoginRequiredMixin, ListView): if candidate_name: # Filter based on the name of the candidate associated with the meeting's interview queryset = queryset.filter( - Q(interview__candidate__first_name__icontains=candidate_name) - | Q(interview__candidate__last_name__icontains=candidate_name) + Q(interview__application__first_name__icontains=candidate_name) + | Q(interview__application__last_name__icontains=candidate_name) ) return queryset @@ -208,13 +268,13 @@ class ZoomMeetingListView(LoginRequiredMixin, ListView): return context -class ZoomMeetingDetailsView(LoginRequiredMixin, DetailView): +class ZoomMeetingDetailsView(StaffRequiredMixin, DetailView): model = ZoomMeeting template_name = "meetings/meeting_details.html" context_object_name = "meeting" -class ZoomMeetingUpdateView(LoginRequiredMixin, UpdateView): +class ZoomMeetingUpdateView(StaffRequiredMixin, UpdateView): model = ZoomMeeting form_class = ZoomMeetingForm context_object_name = "meeting" @@ -309,6 +369,7 @@ def ZoomMeetingDeleteView(request, slug): @login_required +@staff_user_required def create_job(request): """Create a new job posting""" @@ -341,6 +402,7 @@ def create_job(request): @login_required +@staff_user_required def edit_job(request, slug): """Edit an existing job posting""" job = get_object_or_404(JobPosting, slug=slug) @@ -366,19 +428,19 @@ SCORE_PATH = "ai_analysis_data__analysis_data__match_score" HIGH_POTENTIAL_THRESHOLD = 75 -@login_required +@staff_user_required def job_detail(request, slug): """View details of a specific job""" job = get_object_or_404(JobPosting, slug=slug) - # Get all candidates for this job, ordered by most recent - applicants = job.candidates.all().order_by("-created_at") + # Get all applications for this job, ordered by most recent + applicants = job.applications.all().order_by("-created_at") - # Count candidates by stage for summary statistics + # Count applications by stage for summary statistics total_applicant = applicants.count() applied_count = applicants.filter(stage="Applied").count() - exam_count = applicants.filter(stage="Exam").count + exam_count = applicants.filter(stage="Exam").count() interview_count = applicants.filter(stage="Interview").count() @@ -529,6 +591,7 @@ def job_detail(request, slug): @login_required +@staff_user_required def job_image_upload(request, slug): # only for handling the post request job = get_object_or_404(JobPosting, slug=slug) @@ -570,6 +633,7 @@ def job_image_upload(request, slug): @login_required +@staff_user_required def edit_linkedin_post_content(request, slug): job = get_object_or_404(JobPosting, slug=slug) linkedin_content_form = LinkedPostContentForm(instance=job) @@ -602,10 +666,8 @@ def application_detail(request, slug): return render(request, "forms/application_detail.html", {"job": job}) -from django_q.tasks import async_task - - @login_required +@staff_user_required def post_to_linkedin(request, slug): """Post a job to LinkedIn""" job = get_object_or_404(JobPosting, slug=slug) @@ -702,6 +764,7 @@ def application_success(request, slug): @ensure_csrf_cookie @login_required +@staff_user_required def form_builder(request, template_slug=None): """Render the form builder interface""" context = {} @@ -823,6 +886,7 @@ def load_form_template(request, template_slug): @login_required +@staff_user_required def form_templates_list(request): """List all form templates for the current user""" query = request.GET.get("q", "") @@ -844,6 +908,7 @@ def form_templates_list(request): @login_required +@staff_user_required def create_form_template(request): """Create a new form template""" if request.method == "POST": @@ -863,6 +928,7 @@ def create_form_template(request): @login_required +@staff_user_required @require_http_methods(["GET"]) def list_form_templates(request): """List all form templates for the current user""" @@ -873,6 +939,49 @@ def list_form_templates(request): @login_required +@staff_user_required +def form_submission_details(request, template_id, slug): + """Display detailed view of a specific form submission""" + # Get form template and verify ownership + template = get_object_or_404(FormTemplate, id=template_id) + # Get the specific submission + submission = get_object_or_404(FormSubmission, slug=slug, template=template) + + # Get all stages with their fields + stages = template.stages.prefetch_related("fields").order_by("order") + + # Get all responses for this submission, ordered by field order + responses = submission.responses.select_related("field").order_by("field__order") + + # Group responses by stage + stage_responses = {} + for stage in stages: + stage_responses[stage.id] = { + "stage": stage, + "responses": responses.filter(field__stage=stage), + } + + return render( + request, + "forms/form_submission_details.html", + { + "template": template, + "submission": submission, + "stages": stages, + "responses": responses, + "stage_responses": stage_responses, + }, + ) + # return redirect("application_detail", slug=job.slug) + + # return render( + # request, + # "forms/application_submit_form.html", + # {"template_slug": template_slug, "job_id": job_id}, + # ) + +@login_required +@staff_user_required @require_http_methods(["DELETE"]) def delete_form_template(request, template_id): """Delete a form template""" @@ -882,7 +991,8 @@ def delete_form_template(request, template_id): {"success": True, "message": "Form template deleted successfully!"} ) - +@login_required +@staff_user_required def application_submit_form(request, template_slug): """Display the form as a step-by-step wizard""" template = get_object_or_404(FormTemplate, slug=template_slug, is_active=True) @@ -913,6 +1023,7 @@ def application_submit_form(request, template_slug): ) + @csrf_exempt @require_POST def application_submit(request, template_slug): @@ -926,7 +1037,7 @@ def application_submit(request, template_slug): form_template=template ) - current_count = job_posting.candidates.count() + current_count = job_posting.applications.count() if current_count >= job_posting.max_applications: template.is_active = False template.save() @@ -983,7 +1094,7 @@ def application_submit(request, template_slug): submission.applicant_email = email.display_value submission.save() # time=timezone.now() - Candidate.objects.create( + Application.objects.create( first_name=first_name.display_value, last_name=last_name.display_value, email=email.display_value, @@ -1024,6 +1135,7 @@ def application_submit(request, template_slug): @login_required +@staff_user_required def form_template_submissions_list(request, slug): """List all submissions for a specific form template""" template = get_object_or_404(FormTemplate, slug=slug) @@ -1045,6 +1157,7 @@ def form_template_submissions_list(request, slug): @login_required +@staff_user_required def form_template_all_submissions(request, template_id): """Display all submissions for a form template in table format""" template = get_object_or_404(FormTemplate, id=template_id) @@ -1138,8 +1251,9 @@ def _handle_get_request(request, slug, job): # 3. Use the list of IDs to initialize the form if selected_ids: - candidates_to_load = Candidate.objects.filter(pk__in=selected_ids) - form.initial["candidates"] = candidates_to_load + candidates_to_load = Application.objects.filter(pk__in=selected_ids) + print(candidates_to_load) + form.initial["applications"] = candidates_to_load return render( request, @@ -1159,7 +1273,7 @@ def _handle_preview_submission(request, slug, job): if form.is_valid(): # Get the form data - candidates = form.cleaned_data["candidates"] + applications = form.cleaned_data["applications"] start_date = form.cleaned_data["start_date"] end_date = form.cleaned_data["end_date"] working_days = form.cleaned_data["working_days"] @@ -1191,18 +1305,18 @@ def _handle_preview_submission(request, slug, job): start_time=start_time, end_time=end_time, interview_duration=interview_duration, - buffer_time=buffer_time, - break_start_time=break_start_time, - break_end_time=break_end_time, + buffer_time=buffer_time or 5, + break_start_time=break_start_time or None, + break_end_time=break_end_time or None, ) # Get available slots (temp_breaks logic moved into get_available_time_slots if needed) available_slots = get_available_time_slots(temp_schedule) - if len(available_slots) < len(candidates): + if len(available_slots) < len(applications): messages.error( request, - f"Not enough available slots. Required: {len(candidates)}, Available: {len(available_slots)}", + f"Not enough available slots. Required: {len(applications)}, Available: {len(available_slots)}", ) return render( request, @@ -1212,10 +1326,10 @@ def _handle_preview_submission(request, slug, job): # Create a preview schedule preview_schedule = [] - for i, candidate in enumerate(candidates): + for i, candidate in enumerate(applications): slot = available_slots[i] preview_schedule.append( - {"candidate": candidate, "date": slot["date"], "time": slot["time"]} + {"applications": applications, "date": slot["date"], "time": slot["time"]} ) # Save the form data to session for later use @@ -1229,7 +1343,7 @@ def _handle_preview_submission(request, slug, job): "buffer_time": buffer_time, "break_start_time": break_start_time.isoformat(), "break_end_time": break_end_time.isoformat(), - "candidate_ids": [c.id for c in candidates], + "candidate_ids": [c.id for c in applications], } request.session[SESSION_DATA_KEY] = schedule_data @@ -1302,7 +1416,7 @@ def _handle_confirm_schedule(request, slug, job): return redirect("schedule_interviews", slug=slug) # 3. Setup candidates and get slots - candidates = Candidate.objects.filter(id__in=schedule_data["candidate_ids"]) + candidates = Application.objects.filter(id__in=schedule_data["candidate_ids"]) schedule.candidates.set(candidates) available_slots = get_available_time_slots( schedule @@ -1326,7 +1440,6 @@ def _handle_confirm_schedule(request, slug, job): ) queued_count += 1 - # 5. Success and Cleanup (IMMEDIATE RESPONSE) messages.success( request, f"Schedule successfully created. Queued {queued_count} interviews to be booked asynchronously. Check back in a moment!", @@ -1356,7 +1469,7 @@ def confirm_schedule_interviews_view(request, slug): return _handle_confirm_schedule(request, slug, job) -@login_required +@staff_user_required def candidate_screening_view(request, slug): """ Manage candidate tiers and stage transitions @@ -1425,7 +1538,7 @@ def candidate_screening_view(request, slug): return render(request, "recruitment/candidate_screening_view.html", context) -@login_required +@staff_user_required def candidate_exam_view(request, slug): """ Manage candidate tiers and stage transitions @@ -1435,9 +1548,9 @@ def candidate_exam_view(request, slug): return render(request, "recruitment/candidate_exam_view.html", context) -@login_required +@staff_user_required def update_candidate_exam_status(request, slug): - candidate = get_object_or_404(Candidate, slug=slug) + candidate = get_object_or_404(Application, slug=slug) if request.method == "POST": form = CandidateExamDateForm(request.POST, instance=candidate) if form.is_valid(): @@ -1452,7 +1565,7 @@ def update_candidate_exam_status(request, slug): ) -@login_required +@staff_user_required def bulk_update_candidate_exam_status(request, slug): job = get_object_or_404(JobPosting, slug=slug) status = request.headers.get("status") @@ -1472,15 +1585,15 @@ def bulk_update_candidate_exam_status(request, slug): def candidate_criteria_view_htmx(request, pk): - candidate = get_object_or_404(Candidate, pk=pk) + candidate = get_object_or_404(Application, pk=pk) return render( request, "includes/candidate_modal_body.html", {"candidate": candidate} ) -@login_required +@staff_user_required def candidate_set_exam_date(request, slug): - candidate = get_object_or_404(Candidate, slug=slug) + candidate = get_object_or_404(Application, slug=slug) candidate.exam_date = timezone.now() candidate.save() messages.success( @@ -1489,7 +1602,7 @@ def candidate_set_exam_date(request, slug): return redirect("candidate_screening_view", slug=candidate.job.slug) -@login_required +@staff_user_required def candidate_update_status(request, slug): job = get_object_or_404(JobPosting, slug=slug) mark_as = request.POST.get("mark_as") @@ -1497,7 +1610,7 @@ def candidate_update_status(request, slug): if mark_as != "----------": candidate_ids = request.POST.getlist("candidate_ids") print(candidate_ids) - if c := Candidate.objects.filter(pk__in=candidate_ids): + if c := Application.objects.filter(pk__in=candidate_ids): if mark_as == "Exam": c.update( exam_date=timezone.now(), @@ -1555,7 +1668,7 @@ def candidate_update_status(request, slug): return response -@login_required +@staff_user_required def candidate_interview_view(request, slug): job = get_object_or_404(JobPosting, slug=slug) @@ -1595,10 +1708,10 @@ def candidate_interview_view(request, slug): return render(request, "recruitment/candidate_interview_view.html", context) -@login_required +@staff_user_required def reschedule_meeting_for_candidate(request, slug, candidate_id, meeting_id): job = get_object_or_404(JobPosting, slug=slug) - candidate = get_object_or_404(Candidate, pk=candidate_id) + candidate = get_object_or_404(Application, pk=candidate_id) meeting = get_object_or_404(ZoomMeeting, pk=meeting_id) form = ZoomMeetingForm(instance=meeting) @@ -1634,10 +1747,10 @@ def reschedule_meeting_for_candidate(request, slug, candidate_id, meeting_id): return render(request, "meetings/reschedule_meeting.html", context) -@login_required +@staff_user_required def delete_meeting_for_candidate(request, slug, candidate_pk, meeting_id): job = get_object_or_404(JobPosting, slug=slug) - candidate = get_object_or_404(Candidate, pk=candidate_pk) + candidate = get_object_or_404(Application, pk=candidate_pk) meeting = get_object_or_404(ZoomMeeting, pk=meeting_id) if request.method == "POST": result = delete_zoom_meeting(meeting.meeting_id) @@ -1667,13 +1780,13 @@ def delete_meeting_for_candidate(request, slug, candidate_pk, meeting_id): return render(request, "meetings/delete_meeting_form.html", context) -@login_required +@staff_user_required def interview_calendar_view(request, slug): job = get_object_or_404(JobPosting, slug=slug) # Get all scheduled interviews for this job scheduled_interviews = ScheduledInterview.objects.filter(job=job).select_related( - "candidate", "zoom_meeting" + "applicaton", "zoom_meeting" ) # Convert interviews to calendar events @@ -1727,7 +1840,7 @@ def interview_calendar_view(request, slug): return render(request, "recruitment/interview_calendar.html", context) -@login_required +@staff_user_required def interview_detail_view(request, slug, interview_id): job = get_object_or_404(JobPosting, slug=slug) interview = get_object_or_404(ScheduledInterview, id=interview_id, job=job) @@ -1748,7 +1861,7 @@ def api_schedule_candidate_meeting(request, job_slug, candidate_pk): Returns JSON response for modal update. """ job = get_object_or_404(JobPosting, slug=job_slug) - candidate = get_object_or_404(Candidate, pk=candidate_pk, job=job) + candidate = get_object_or_404(Application, pk=candidate_pk, job=job) topic = f"Interview: {job.title} with {candidate.name}" start_time_str = request.POST.get("start_time") @@ -1828,7 +1941,7 @@ def schedule_candidate_meeting(request, job_slug, candidate_pk): POST: Handled by api_schedule_candidate_meeting. """ job = get_object_or_404(JobPosting, slug=job_slug) - candidate = get_object_or_404(Candidate, pk=candidate_pk, job=job) + candidate = get_object_or_404(Application, pk=candidate_pk, job=job) if request.method == "POST": return api_schedule_candidate_meeting(request, job_slug, candidate_pk) @@ -1853,7 +1966,7 @@ def api_schedule_candidate_meeting(request, job_slug, candidate_pk): Handles GET to render form and POST to process scheduling. """ job = get_object_or_404(JobPosting, slug=job_slug) - candidate = get_object_or_404(Candidate, pk=candidate_pk, job=job) + candidate = get_object_or_404(Application, pk=candidate_pk, job=job) if request.method == "GET": # This GET is for HTMX to fetch the form @@ -1940,7 +2053,7 @@ def api_reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_ scheduled_interview = get_object_or_404( ScheduledInterview.objects.select_related("zoom_meeting"), pk=interview_pk, - candidate__pk=candidate_pk, + application__pk=candidate_pk, job=job, ) zoom_meeting = scheduled_interview.zoom_meeting @@ -1954,7 +2067,7 @@ def api_reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_ } context = { "job": job, - "candidate": scheduled_interview.candidate, + "candidate": scheduled_interview.application, "scheduled_interview": scheduled_interview, # Pass for conditional logic in template "initial_data": initial_data, "action_url": reverse( @@ -2067,11 +2180,11 @@ def reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_pk): Handles POST to process the rescheduling of a meeting. """ job = get_object_or_404(JobPosting, slug=job_slug) - candidate = get_object_or_404(Candidate, pk=candidate_pk, job=job) + application = get_object_or_404(Application, pk=candidate_pk, job=job) scheduled_interview = get_object_or_404( ScheduledInterview.objects.select_related("zoom_meeting"), pk=interview_pk, - candidate=candidate, + application=application, job=job, ) zoom_meeting = scheduled_interview.zoom_meeting @@ -2082,7 +2195,7 @@ def reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_pk): # If candidate.has_future_meeting is True, it implies they have at least one other upcoming meeting, # or the specific meeting being rescheduled is itself in the future. # We can refine this logic if needed, e.g., check for meetings *other than* the current `interview_pk`. - has_other_future_meetings = candidate.has_future_meeting + has_other_future_meetings = application.has_future_meeting # More precise check: if the current meeting being rescheduled is in the future, then by definition # the candidate will have a future meeting (this one). The UI might want to know if there are *others*. # For now, `candidate.has_future_meeting` is a good general indicator. @@ -2096,7 +2209,7 @@ def reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_pk): # Use a default topic if not provided, keeping with the original structure if not new_topic: - new_topic = f"Interview: {job.title} with {candidate.name}" + new_topic = f"Interview: {job.title} with {application.name}" # Ensure new_start_time is in the future if new_start_time <= timezone.now(): @@ -2108,7 +2221,7 @@ def reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_pk): { # Reusing the same form template "form": form, "job": job, - "candidate": candidate, + "application": application, "scheduled_interview": scheduled_interview, "initial_topic": new_topic, "initial_start_time": new_start_time.strftime("%Y-%m-%dT%H:%M") @@ -2175,7 +2288,7 @@ def reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_pk): scheduled_interview.save() messages.success( request, - f"Meeting for {candidate.name} rescheduled successfully.", + f"Meeting for {application.name} rescheduled successfully.", ) else: # If fetching details fails, update with form data and log a warning @@ -2193,7 +2306,7 @@ def reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_pk): scheduled_interview.save() messages.success( request, - f"Meeting for {candidate.name} rescheduled. (Note: Could not refresh all details from Zoom.)", + f"Meeting for {application.name} rescheduled. (Note: Could not refresh all details from Zoom.)", ) return redirect("candidate_interview_view", slug=job.slug) @@ -2209,7 +2322,7 @@ def reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_pk): { "form": form, "job": job, - "candidate": candidate, + "application": application, "scheduled_interview": scheduled_interview, "initial_topic": new_topic, "initial_start_time": new_start_time.strftime("%Y-%m-%dT%H:%M") @@ -2235,7 +2348,7 @@ def reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_pk): { "form": form, "job": job, - "candidate": candidate, + "application": application, "scheduled_interview": scheduled_interview, "initial_topic": request.POST.get("topic", new_topic), "initial_start_time": request.POST.get( @@ -2270,7 +2383,7 @@ def reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_pk): { "form": form, "job": job, - "candidate": candidate, + "application": application, "scheduled_interview": scheduled_interview, # Pass to template for title/differentiation "action_url": reverse( "reschedule_candidate_meeting", @@ -2291,7 +2404,7 @@ def schedule_meeting_for_candidate(request, slug, candidate_pk): Handles POST to process the form, create a meeting, and redirect back. """ job = get_object_or_404(JobPosting, slug=slug) - candidate = get_object_or_404(Candidate, pk=candidate_pk, job=job) + candidate = get_object_or_404(Application, pk=candidate_pk, job=job) if request.method == "POST": form = ZoomMeetingForm(request.POST) @@ -2343,7 +2456,7 @@ def schedule_meeting_for_candidate(request, slug, candidate_pk): ) # Create a ScheduledInterview record ScheduledInterview.objects.create( - candidate=candidate, + application=candidate, job=job, zoom_meeting=zoom_meeting_instance, interview_date=start_time_val.date(), @@ -2506,7 +2619,7 @@ def is_superuser_check(user): return user.is_superuser -@user_passes_test(is_superuser_check) +@staff_user_required def create_staff_user(request): if request.method == "POST": form = StaffUserCreationForm(request.POST) @@ -2524,7 +2637,7 @@ def create_staff_user(request): return render(request, "user/create_staff.html", {"form": form}) -@user_passes_test(is_superuser_check) +@staff_user_required def admin_settings(request): staffs = User.objects.filter(is_superuser=False) form = ToggleAccountForm() @@ -2535,7 +2648,7 @@ def admin_settings(request): from django.contrib.auth.forms import SetPasswordForm -@user_passes_test(is_superuser_check) +@staff_user_required def set_staff_password(request, pk): user = get_object_or_404(User, pk=pk) print(request.POST) @@ -2557,7 +2670,7 @@ def set_staff_password(request, pk): ) -@user_passes_test(is_superuser_check) +@staff_user_required def account_toggle_status(request, pk): user = get_object_or_404(User, pk=pk) if request.method == "POST": @@ -2589,6 +2702,7 @@ def account_toggle_status(request, pk): @csrf_exempt +@staff_user_required def zoom_webhook_view(request): print(request.headers) print(settings.ZOOM_WEBHOOK_API_KEY) @@ -2605,7 +2719,7 @@ def zoom_webhook_view(request): # Meeting Comments Views -@login_required +@staff_user_required def add_meeting_comment(request, slug): """Add a comment to a meeting""" meeting = get_object_or_404(ZoomMeeting, slug=slug) @@ -2646,7 +2760,7 @@ def add_meeting_comment(request, slug): return redirect("meeting_details", slug=slug) -@login_required +@staff_user_required def edit_meeting_comment(request, slug, comment_id): """Edit a meeting comment""" meeting = get_object_or_404(ZoomMeeting, slug=slug) @@ -2682,7 +2796,7 @@ def edit_meeting_comment(request, slug, comment_id): return render(request, "includes/edit_comment_form.html", context) -@login_required +@staff_user_required def delete_meeting_comment(request, slug, comment_id): """Delete a meeting comment""" meeting = get_object_or_404(ZoomMeeting, slug=slug) @@ -2728,7 +2842,7 @@ def delete_meeting_comment(request, slug, comment_id): return redirect("meeting_details", slug=slug) -@login_required +@staff_user_required def set_meeting_candidate(request, slug): meeting = get_object_or_404(ZoomMeeting, slug=slug) if request.method == "POST" and "HX-Request" not in request.headers: @@ -2745,10 +2859,10 @@ def set_meeting_candidate(request, slug): form = InterviewForm() if job: - form.fields["candidate"].queryset = Candidate.objects.filter(job=job) + form.fields["candidate"].queryset = Application.objects.filter(job=job) else: - form.fields["candidate"].queryset = Candidate.objects.none() + form.fields["candidate"].queryset = Application.objects.none() form.fields["job"].widget.attrs.update( { "hx-get": reverse("set_meeting_candidate", kwargs={"slug": slug}), @@ -2762,7 +2876,7 @@ def set_meeting_candidate(request, slug): # Hiring Agency CRUD Views -@login_required +@staff_user_required def agency_list(request): """List all hiring agencies with search and pagination""" search_query = request.GET.get("q", "") @@ -2792,7 +2906,7 @@ def agency_list(request): return render(request, "recruitment/agency_list.html", context) -@login_required +@staff_user_required def agency_create(request): """Create a new hiring agency""" if request.method == "POST": @@ -2814,13 +2928,13 @@ def agency_create(request): return render(request, "recruitment/agency_form.html", context) -@login_required +@staff_user_required def agency_detail(request, slug): """View details of a specific hiring agency""" agency = get_object_or_404(HiringAgency, slug=slug) # Get candidates associated with this agency - candidates = Candidate.objects.filter(hiring_agency=agency).order_by("-created_at") + candidates = Application.objects.filter(hiring_agency=agency).order_by("-created_at") # Statistics total_candidates = candidates.count() @@ -2841,7 +2955,7 @@ def agency_detail(request, slug): return render(request, "recruitment/agency_detail.html", context) -@login_required +@staff_user_required def agency_update(request, slug): """Update an existing hiring agency""" agency = get_object_or_404(HiringAgency, slug=slug) @@ -2866,7 +2980,7 @@ def agency_update(request, slug): return render(request, "recruitment/agency_form.html", context) -@login_required +@staff_user_required def agency_delete(request, slug): """Delete a hiring agency""" agency = get_object_or_404(HiringAgency, slug=slug) @@ -2887,7 +3001,7 @@ def agency_delete(request, slug): # Notification Views -# @login_required +# @staff_user_required # def notification_list(request): # """List all notifications for the current user""" # # Get filter parameters @@ -2933,7 +3047,7 @@ def agency_delete(request, slug): # return render(request, 'recruitment/notification_list.html', context) -# @login_required +# @staff_user_required # def notification_detail(request, notification_id): # """View details of a specific notification""" # notification = get_object_or_404(Notification, id=notification_id, recipient=request.user) @@ -2949,7 +3063,7 @@ def agency_delete(request, slug): # return render(request, 'recruitment/notification_detail.html', context) -# @login_required +# @staff_user_required # def notification_mark_read(request, notification_id): # """Mark a notification as read""" # notification = get_object_or_404(Notification, id=notification_id, recipient=request.user) @@ -2964,7 +3078,7 @@ def agency_delete(request, slug): # return redirect('notification_list') -# @login_required +# @staff_user_required # def notification_mark_unread(request, notification_id): # """Mark a notification as unread""" # notification = get_object_or_404(Notification, id=notification_id, recipient=request.user) @@ -2979,7 +3093,7 @@ def agency_delete(request, slug): # return redirect('notification_list') -# @login_required +# @staff_user_required # def notification_delete(request, notification_id): # """Delete a notification""" # notification = get_object_or_404(Notification, id=notification_id, recipient=request.user) @@ -2999,7 +3113,7 @@ def agency_delete(request, slug): # return render(request, 'recruitment/notification_confirm_delete.html', context) -# @login_required +# @staff_user_required # def notification_mark_all_read(request): # """Mark all notifications as read for the current user""" # if request.method == 'POST': @@ -3026,7 +3140,7 @@ def agency_delete(request, slug): # return render(request, 'recruitment/notification_confirm_all_read.html', context) -# @login_required +# @staff_user_required # def api_notification_count(request): # """API endpoint to get unread notification count and recent notifications""" # # Get unread notifications @@ -3075,7 +3189,7 @@ def agency_delete(request, slug): # }) -# @login_required +# @staff_user_required # def notification_stream(request): # """SSE endpoint for real-time notifications""" # from django.http import StreamingHttpResponse @@ -3197,11 +3311,11 @@ def agency_delete(request, slug): # return render(request, 'recruitment/agency_candidates.html', context) -@login_required +@staff_user_required def agency_candidates(request, slug): """View all candidates from a specific agency""" agency = get_object_or_404(HiringAgency, slug=slug) - candidates = Candidate.objects.filter(hiring_agency=agency).order_by("-created_at") + candidates = Application.objects.filter(hiring_agency=agency).order_by("-created_at") # Filter by stage if provided stage_filter = request.GET.get("stage") @@ -3226,7 +3340,7 @@ def agency_candidates(request, slug): # Agency Portal Management Views -@login_required +@staff_user_required def agency_assignment_list(request): """List all agency job assignments""" search_query = request.GET.get("q", "") @@ -3259,7 +3373,7 @@ def agency_assignment_list(request): return render(request, "recruitment/agency_assignment_list.html", context) -@login_required +@staff_user_required def agency_assignment_create(request, slug=None): """Create a new agency job assignment""" agency = HiringAgency.objects.get(slug=slug) if slug else None @@ -3297,7 +3411,7 @@ def agency_assignment_create(request, slug=None): return render(request, "recruitment/agency_assignment_form.html", context) -@login_required +@staff_user_required def agency_assignment_detail(request, slug): """View details of a specific agency assignment""" assignment = get_object_or_404( @@ -3305,7 +3419,7 @@ def agency_assignment_detail(request, slug): ) # Get candidates submitted by this agency for this job - candidates = Candidate.objects.filter( + candidates = Application.objects.filter( hiring_agency=assignment.agency, job=assignment.job ).order_by("-created_at") @@ -3334,7 +3448,7 @@ def agency_assignment_detail(request, slug): return render(request, "recruitment/agency_assignment_detail.html", context) -@login_required +@staff_user_required def agency_assignment_update(request, slug): """Update an existing agency assignment""" assignment = get_object_or_404(AgencyJobAssignment, slug=slug) @@ -3359,7 +3473,7 @@ def agency_assignment_update(request, slug): return render(request, "recruitment/agency_assignment_form.html", context) -@login_required +@staff_user_required def agency_access_link_create(request): """Create access link for agency assignment""" if request.method == "POST": @@ -3386,7 +3500,7 @@ def agency_access_link_create(request): return render(request, "recruitment/agency_access_link_form.html", context) -@login_required +@staff_user_required def agency_access_link_detail(request, slug): """View details of an access link""" access_link = get_object_or_404( @@ -3402,7 +3516,7 @@ def agency_access_link_detail(request, slug): return render(request, "recruitment/agency_access_link_detail.html", context) -@login_required +@staff_user_required def agency_assignment_extend_deadline(request, slug): """Extend deadline for an agency assignment""" assignment = get_object_or_404(AgencyJobAssignment, slug=slug) @@ -3438,23 +3552,24 @@ def agency_assignment_extend_deadline(request, slug): # Agency Portal Views (for external agencies) +@agency_user_required def agency_portal_login(request): """Agency login page""" - if request.session.get("agency_assignment_id"): - return redirect("agency_portal_dashboard") + # if request.session.get("agency_assignment_id"): + # return redirect("agency_portal_dashboard") if request.method == "POST": form = AgencyLoginForm(request.POST) if form.is_valid(): # Check if validated_access_link attribute exists - if hasattr(form, "validated_access_link"): - access_link = form.validated_access_link - access_link.record_access() + # if hasattr(form, "validated_access_link"): + # access_link = form.validated_access_link + # access_link.record_access() # Store assignment in session - request.session["agency_assignment_id"] = access_link.assignment.id - request.session["agency_name"] = access_link.assignment.agency.name + # request.session["agency_assignment_id"] = access_link.assignment.id + # request.session["agency_name"] = access_link.assignment.agency.name messages.success(request, f"Welcome, {access_link.assignment.agency.name}!") return redirect("agency_portal_dashboard") @@ -3471,6 +3586,12 @@ def agency_portal_login(request): def portal_login(request): """Unified portal login for agency and candidate""" + if request.user.is_authenticated: + if request.user.user_type == "agency": + return redirect("agency_portal_dashboard") + if request.user.user_type == "candidate": + return redirect("candidate_portal_dashboard") + if request.method == "POST": form = PortalLoginForm(request.POST) @@ -3486,6 +3607,7 @@ def portal_login(request): print(user.user_type) if hasattr(user, "user_type") and user.user_type == user_type: login(request, user) + return redirect("agency_portal_dashboard") # if user_type == "agency": # # Check if user has agency profile @@ -3531,6 +3653,7 @@ def portal_login(request): return render(request, "recruitment/portal_login.html", context) +@candidate_user_required def candidate_portal_dashboard(request): """Candidate portal dashboard""" if not request.user.is_authenticated: @@ -3549,7 +3672,61 @@ def candidate_portal_dashboard(request): return render(request, "recruitment/candidate_portal_dashboard.html", context) -@login_required +@agency_user_required +def agency_portal_persons_list(request): + """Agency portal page showing all persons who come through this agency""" + try: + agency = request.user.agency_profile + except Exception as e: + print(e) + messages.error(request, "No agency profile found.") + return redirect("portal_login") + + # Get all applications for this agency + persons = Person.objects.filter(agency=agency) + # persons = Application.objects.filter( + # hiring_agency=agency + # ).select_related("job").order_by("-created_at") + + # Search functionality + search_query = request.GET.get("q", "") + if search_query: + persons = persons.filter( + Q(first_name__icontains=search_query) | + Q(last_name__icontains=search_query) | + Q(email__icontains=search_query) | + Q(phone__icontains=search_query) | + Q(job__title__icontains=search_query) + ) + + # Filter by stage if provided + stage_filter = request.GET.get("stage", "") + if stage_filter: + persons = persons.filter(stage=stage_filter) + + # Pagination + paginator = Paginator(persons, 20) # Show 20 persons per page + page_number = request.GET.get("page") + page_obj = paginator.get_page(page_number) + + # Get stage choices for filter dropdown + stage_choices = Application.Stage.choices + person_form = PersonForm() + person_form.initial['agency'] = agency + + context = { + "agency": agency, + "page_obj": page_obj, + "search_query": search_query, + "stage_filter": stage_filter, + "stage_choices": stage_choices, + "total_persons": persons.count(), + "person_form": person_form, + } + return render(request, "recruitment/agency_portal_persons_list.html", context) + + +@agency_user_required def agency_portal_dashboard(request): """Agency portal dashboard showing all assignments for the agency""" # Get the current assignment to determine the agency @@ -3571,7 +3748,7 @@ def agency_portal_dashboard(request): # Calculate statistics for each assignment assignment_stats = [] for assignment in assignments: - candidates = Candidate.objects.filter( + candidates = Application.objects.filter( hiring_agency=agency, job=assignment.job ).order_by("-created_at") @@ -3606,16 +3783,17 @@ def agency_portal_dashboard(request): return render(request, "recruitment/agency_portal_dashboard.html", context) +@agency_user_required def agency_portal_submit_candidate_page(request, slug): """Dedicated page for submitting a candidate""" - assignment_id = request.session.get("agency_assignment_id") - if not assignment_id: - return redirect("agency_portal_login") + # assignment_id = request.session.get("agency_assignment_id") + # if not assignment_id: + # return redirect("agency_portal_login") # Get the specific assignment by slug and verify it belongs to the same agency - current_assignment = get_object_or_404( - AgencyJobAssignment.objects.select_related("agency"), id=assignment_id - ) + # current_assignment = get_object_or_404( + # AgencyJobAssignment.objects.select_related("agency"), slug=slug + # ) assignment = get_object_or_404( AgencyJobAssignment.objects.select_related("agency", "job"), slug=slug @@ -3625,7 +3803,7 @@ def agency_portal_submit_candidate_page(request, slug): messages.error(request, "Maximum candidate limit reached for this assignment.") return redirect("agency_portal_assignment_detail", slug=assignment.slug) # Verify this assignment belongs to the same agency as the logged-in session - if assignment.agency.id != current_assignment.agency.id: + if assignment.agency.id != assignment.agency.id: messages.error( request, "Access denied: This assignment does not belong to your agency." ) @@ -3640,12 +3818,12 @@ def agency_portal_submit_candidate_page(request, slug): return redirect("agency_portal_assignment_detail", slug=assignment.slug) # Get total submitted candidates for this assignment - total_submitted = Candidate.objects.filter( + total_submitted = Application.objects.filter( hiring_agency=assignment.agency, job=assignment.job ).count() if request.method == "POST": - form = AgencyCandidateSubmissionForm(assignment, request.POST, request.FILES) + form = AgencyApplicationSubmissionForm(assignment, request.POST, request.FILES) if form.is_valid(): candidate = form.save(commit=False) candidate.hiring_source = "AGENCY" @@ -3684,7 +3862,7 @@ def agency_portal_submit_candidate_page(request, slug): else: messages.error(request, "Please correct errors below.") else: - form = AgencyCandidateSubmissionForm(assignment) + form = AgencyApplicationSubmissionForm(assignment) context = { "form": form, @@ -3694,6 +3872,7 @@ def agency_portal_submit_candidate_page(request, slug): return render(request, "recruitment/agency_portal_submit_candidate.html", context) +@agency_user_required def agency_portal_submit_candidate(request): """Handle candidate submission via AJAX (for embedded form)""" assignment_id = request.session.get("agency_assignment_id") @@ -3716,7 +3895,7 @@ def agency_portal_submit_candidate(request): return redirect("agency_portal_dashboard") if request.method == "POST": - form = AgencyCandidateSubmissionForm(assignment, request.POST, request.FILES) + form = AgencyApplicationSubmissionForm(assignment, request.POST, request.FILES) if form.is_valid(): candidate = form.save(commit=False) candidate.hiring_source = "AGENCY" @@ -3746,7 +3925,7 @@ def agency_portal_submit_candidate(request): else: messages.error(request, "Please correct errors below.") else: - form = AgencyCandidateSubmissionForm(assignment) + form = AgencyApplicationSubmissionForm(assignment) context = { "form": form, @@ -3759,18 +3938,21 @@ def agency_portal_submit_candidate(request): def agency_portal_assignment_detail(request, slug): """View details of a specific assignment - routes to admin or agency template""" - print(slug) # Check if this is an agency portal user (via session) - assignment_id = request.session.get("agency_assignment_id") - is_agency_user = bool(assignment_id) - return agency_assignment_detail_agency(request, slug, assignment_id) + # assignment_id = request.session.get("agency_assignment_id") + # is_agency_user = bool(assignment_id) + # return agency_assignment_detail_agency(request, slug, assignment_id) # if is_agency_user: # # Agency Portal User - Route to agency-specific template # else: # # Admin User - Route to admin template # return agency_assignment_detail_admin(request, slug) + assignment = get_object_or_404( + AgencyJobAssignment.objects.select_related("agency", "job"), slug=slug + ) +@agency_user_required def agency_assignment_detail_agency(request, slug, assignment_id): """Handle agency portal assignment detail view""" # Get the assignment by slug and verify it belongs to same agency @@ -3790,7 +3972,7 @@ def agency_assignment_detail_agency(request, slug, assignment_id): return redirect("agency_portal_dashboard") # Get candidates submitted by this agency for this job - candidates = Candidate.objects.filter( + candidates = Application.objects.filter( hiring_agency=assignment.agency, job=assignment.job ).order_by("-created_at") @@ -3831,6 +4013,7 @@ def agency_assignment_detail_agency(request, slug, assignment_id): return render(request, "recruitment/agency_portal_assignment_detail.html", context) +@staff_user_required def agency_assignment_detail_admin(request, slug): """Handle admin assignment detail view""" assignment = get_object_or_404( @@ -3838,7 +4021,7 @@ def agency_assignment_detail_admin(request, slug): ) # Get candidates submitted by this agency for this job - candidates = Candidate.objects.filter( + candidates = Application.objects.filter( hiring_agency=assignment.agency, job=assignment.job ).order_by("-created_at") @@ -3857,6 +4040,7 @@ def agency_assignment_detail_admin(request, slug): return render(request, "recruitment/agency_assignment_detail.html", context) +@agency_user_required def agency_portal_edit_candidate(request, candidate_id): """Edit a candidate for agency portal""" assignment_id = request.session.get("agency_assignment_id") @@ -3871,7 +4055,7 @@ def agency_portal_edit_candidate(request, candidate_id): agency = current_assignment.agency # Get candidate and verify it belongs to this agency - candidate = get_object_or_404(Candidate, id=candidate_id, hiring_agency=agency) + candidate = get_object_or_404(Application, id=candidate_id, hiring_agency=agency) if request.method == "POST": # Handle form submission @@ -3917,6 +4101,7 @@ def agency_portal_edit_candidate(request, candidate_id): return redirect("agency_portal_dashboard") +@agency_user_required def agency_portal_delete_candidate(request, candidate_id): """Delete a candidate for agency portal""" assignment_id = request.session.get("agency_assignment_id") @@ -3931,7 +4116,7 @@ def agency_portal_delete_candidate(request, candidate_id): agency = current_assignment.agency # Get candidate and verify it belongs to this agency - candidate = get_object_or_404(Candidate, id=candidate_id, hiring_agency=agency) + candidate = get_object_or_404(Application, id=candidate_id, hiring_agency=agency) if request.method == "POST": try: @@ -3954,7 +4139,7 @@ def agency_portal_delete_candidate(request, candidate_id): # Message Views -@login_required +@staff_user_required def message_list(request): """List all messages for the current user""" # Get filter parameters @@ -4194,31 +4379,21 @@ def api_unread_count(request): # 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) +def document_upload(request, application_id): + """Upload a document for an application""" + application = get_object_or_404(Application, pk=application_id) if request.method == "POST": if request.FILES.get('file'): document = Document.objects.create( - candidate=candidate, + content_object=application, # Use Generic Foreign Key to link to Application 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) + return redirect('candidate_detail', slug=application.job.slug) @login_required @@ -4226,8 +4401,14 @@ 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: + # Check permission - document is now linked to Application via Generic Foreign Key + if hasattr(document.content_object, 'job'): + if document.content_object.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'}) + job_slug = document.content_object.job.slug + else: + # Handle other content object types messages.error(request, "You don't have permission to delete this document.") return JsonResponse({'success': False, 'error': 'Permission denied'}) @@ -4240,7 +4421,7 @@ def document_delete(request, document_id): 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 redirect('candidate_detail', slug=job_slug) return JsonResponse({'success': False, 'error': 'Method not allowed'}) @@ -4250,8 +4431,13 @@ 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: + # Check permission - document is now linked to Application via Generic Foreign Key + if hasattr(document.content_object, 'job'): + if document.content_object.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'}) + else: + # Handle other content object types messages.error(request, "You don't have permission to download this document.") return JsonResponse({'success': False, 'error': 'Permission denied'}) @@ -4263,6 +4449,7 @@ def document_download(request, document_id): return JsonResponse({'success': False, 'error': 'File not found'}) +@login_required def portal_logout(request): """Logout from portal""" logout(request) @@ -4345,6 +4532,7 @@ def agency_access_link_reactivate(request, slug): return render(request, "recruitment/agency_access_link_confirm.html", context) +@agency_user_required def api_candidate_detail(request, candidate_id): """API endpoint to get candidate details for agency portal""" try: @@ -4361,7 +4549,7 @@ def api_candidate_detail(request, candidate_id): agency = current_assignment.agency # Get candidate and verify it belongs to this agency - candidate = get_object_or_404(Candidate, id=candidate_id, hiring_agency=agency) + candidate = get_object_or_404(Application, id=candidate_id, hiring_agency=agency) # Return candidate data response_data = { @@ -4380,13 +4568,13 @@ def api_candidate_detail(request, candidate_id): return JsonResponse({"success": False, "error": str(e)}) -@login_required +@staff_user_required def compose_candidate_email(request, job_slug, candidate_slug): """Compose email to participants about a candidate""" from .email_service import send_bulk_email job = get_object_or_404(JobPosting, slug=job_slug) - candidate = get_object_or_404(Candidate, slug=candidate_slug, job=job) + candidate = get_object_or_404(Application, slug=candidate_slug, job=job) if request.method == "POST": form = CandidateEmailForm(job, candidate, request.POST) if form.is_valid(): @@ -4515,7 +4703,7 @@ def compose_candidate_email(request, job_slug, candidate_slug): request, "includes/email_compose_form.html", {"form": form, "job": job, "candidate": candidate}, - ) + ) else: # GET request - show the form @@ -4529,7 +4717,7 @@ def compose_candidate_email(request, job_slug, candidate_slug): # Source CRUD Views -@login_required +@staff_user_required def source_list(request): """List all sources with search and pagination""" search_query = request.GET.get("q", "") @@ -4558,7 +4746,7 @@ def source_list(request): return render(request, "recruitment/source_list.html", context) -@login_required +@staff_user_required def source_create(request): """Create a new source""" if request.method == "POST": @@ -4580,7 +4768,7 @@ def source_create(request): return render(request, "recruitment/source_form.html", context) -@login_required +@staff_user_required def source_detail(request, slug): """View details of a specific source""" source = get_object_or_404(Source, slug=slug) @@ -4607,7 +4795,7 @@ def source_detail(request, slug): return render(request, "recruitment/source_detail.html", context) -@login_required +@staff_user_required def source_update(request, slug): """Update an existing source""" source = get_object_or_404(Source, slug=slug) @@ -4632,7 +4820,7 @@ def source_update(request, slug): return render(request, "recruitment/source_form.html", context) -@login_required +@staff_user_required def source_delete(request, slug): """Delete a source""" source = get_object_or_404(Source, slug=slug) @@ -4707,10 +4895,12 @@ def candidate_signup(request,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) + try: + application = form.save(job) + return redirect("application_success", slug=job.slug) + except Exception as e: + messages.error(request, f"Error creating application: {str(e)}") + return render(request, "recruitment/candidate_signup.html", {"form": form, "job": job}) form = CandidateSignupForm() - return render(request, "recruitment/candidate_signup.html", {"form": form, "job": job}) \ No newline at end of file + return render(request, "recruitment/candidate_signup.html", {"form": form, "job": job}) diff --git a/recruitment/views_frontend.py b/recruitment/views_frontend.py index 569a3fe..519592f 100644 --- a/recruitment/views_frontend.py +++ b/recruitment/views_frontend.py @@ -30,6 +30,9 @@ from django.utils import timezone from datetime import timedelta import json +# Add imports for user type restrictions +from recruitment.decorators import StaffRequiredMixin, staff_user_required + from datastar_py.django import ( DatastarResponse, @@ -39,7 +42,7 @@ from datastar_py.django import ( # from rich import print from rich.markdown import CodeBlock -class JobListView(LoginRequiredMixin, ListView): +class JobListView(LoginRequiredMixin, StaffRequiredMixin, ListView): model = models.JobPosting template_name = 'jobs/job_list.html' context_object_name = 'jobs' @@ -47,7 +50,6 @@ class JobListView(LoginRequiredMixin, ListView): def get_queryset(self): queryset = super().get_queryset().order_by('-created_at') - # Handle search search_query = self.request.GET.get('search', '') if search_query: @@ -58,24 +60,23 @@ class JobListView(LoginRequiredMixin, ListView): ) # Filter for non-staff users - if not self.request.user.is_staff: - queryset = queryset.filter(status='Published') + # if not self.request.user.is_staff: + # queryset = queryset.filter(status='Published') - status=self.request.GET.get('status') + status = self.request.GET.get('status') if status: - queryset=queryset.filter(status=status) + queryset = queryset.filter(status=status) return queryset def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) context['search_query'] = self.request.GET.get('search', '') context['lang'] = get_language() return context -class JobCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView): +class JobCreateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, CreateView): model = models.JobPosting form_class = forms.JobPostingForm template_name = 'jobs/create_job.html' @@ -83,7 +84,7 @@ class JobCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView): success_message = 'Job created successfully.' -class JobUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView): +class JobUpdateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, UpdateView): model = models.JobPosting form_class = forms.JobPostingForm template_name = 'jobs/edit_job.html' @@ -92,27 +93,25 @@ class JobUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView): slug_url_kwarg = 'slug' -class JobDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView): +class JobDeleteView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, DeleteView): model = models.JobPosting template_name = 'jobs/partials/delete_modal.html' success_url = reverse_lazy('job_list') success_message = 'Job deleted successfully.' slug_url_kwarg = 'slug' -class JobCandidatesListView(LoginRequiredMixin, ListView): - model = models.Candidate +class JobApplicationListView(LoginRequiredMixin, StaffRequiredMixin, ListView): + model = models.Application template_name = 'jobs/job_candidates_list.html' - context_object_name = 'candidates' + context_object_name = 'applications' paginate_by = 10 - - def get_queryset(self): # Get the job by slug self.job = get_object_or_404(models.JobPosting, slug=self.kwargs['slug']) # Filter candidates for this specific job - queryset = models.Candidate.objects.filter(job=self.job) + queryset = models.Application.objects.filter(job=self.job) if self.request.GET.get('stage'): stage=self.request.GET.get('stage') @@ -132,7 +131,7 @@ class JobCandidatesListView(LoginRequiredMixin, ListView): # Filter for non-staff users if not self.request.user.is_staff: - return models.Candidate.objects.none() # Restrict for non-staff + return models.Application.objects.none() # Restrict for non-staff return queryset.order_by('-created_at') @@ -143,10 +142,10 @@ class JobCandidatesListView(LoginRequiredMixin, ListView): return context -class CandidateListView(LoginRequiredMixin, ListView): - model = models.Candidate +class ApplicationListView(LoginRequiredMixin, StaffRequiredMixin, ListView): + model = models.Application template_name = 'recruitment/candidate_list.html' - context_object_name = 'candidates' + context_object_name = 'applications' paginate_by = 100 def get_queryset(self): @@ -156,22 +155,22 @@ class CandidateListView(LoginRequiredMixin, ListView): search_query = self.request.GET.get('search', '') job = self.request.GET.get('job', '') stage = self.request.GET.get('stage', '') - if search_query: - queryset = queryset.filter( - Q(first_name__icontains=search_query) | - Q(last_name__icontains=search_query) | - Q(email__icontains=search_query) | - Q(phone__icontains=search_query) | - Q(stage__icontains=search_query) | - Q(job__title__icontains=search_query) - ) + # if search_query: + # queryset = queryset.filter( + # Q(first_name__icontains=search_query) | + # Q(last_name__icontains=search_query) | + # Q(email__icontains=search_query) | + # Q(phone__icontains=search_query) | + # Q(stage__icontains=search_query) | + # Q(job__title__icontains=search_query) + # ) if job: queryset = queryset.filter(job__slug=job) if stage: queryset = queryset.filter(stage=stage) # Filter for non-staff users - if not self.request.user.is_staff: - return models.Candidate.objects.none() # Restrict for non-staff + # if not self.request.user.is_staff: + # return models.Application.objects.none() # Restrict for non-staff return queryset.order_by('-created_at') @@ -184,9 +183,9 @@ class CandidateListView(LoginRequiredMixin, ListView): return context -class CandidateCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView): - model = models.Candidate - form_class = forms.CandidateForm +class ApplicationCreateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, CreateView): + model = models.Application + form_class = forms.ApplicationForm template_name = 'recruitment/candidate_create.html' success_url = reverse_lazy('candidate_list') success_message = 'Candidate created successfully.' @@ -204,18 +203,23 @@ class CandidateCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView): form.instance.job = job return super().form_valid(form) + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + if self.request.method == 'GET': + context['person_form'] = forms.PersonForm() + return context -class CandidateUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView): - model = models.Candidate - form_class = forms.CandidateForm +class ApplicationUpdateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, UpdateView): + model = models.Application + form_class = forms.ApplicationForm template_name = 'recruitment/candidate_update.html' success_url = reverse_lazy('candidate_list') success_message = 'Candidate updated successfully.' slug_url_kwarg = 'slug' -class CandidateDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView): - model = models.Candidate +class ApplicationDeleteView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, DeleteView): + model = models.Application template_name = 'recruitment/candidate_delete.html' success_url = reverse_lazy('candidate_list') success_message = 'Candidate deleted successfully.' @@ -225,28 +229,30 @@ class CandidateDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView): def retry_scoring_view(request,slug): from django_q.tasks import async_task - candidate = get_object_or_404(models.Candidate, slug=slug) + application = get_object_or_404(models.Application, slug=slug) async_task( 'recruitment.tasks.handle_reume_parsing_and_scoring', - candidate.pk, + application.pk, hook='recruitment.hooks.callback_ai_parsing', sync=True, ) - return redirect('candidate_detail', slug=candidate.slug) + return redirect('candidate_detail', slug=application.slug) @login_required +@staff_user_required def training_list(request): materials = models.TrainingMaterial.objects.all().order_by('-created_at') return render(request, 'recruitment/training_list.html', {'materials': materials}) @login_required +@staff_user_required def candidate_detail(request, slug): from rich.json import JSON - candidate = get_object_or_404(models.Candidate, slug=slug) + candidate = get_object_or_404(models.Application, slug=slug) try: parsed = ast.literal_eval(candidate.parsed_summary) except: @@ -255,7 +261,7 @@ def candidate_detail(request, slug): # Create stage update form for staff users stage_form = None if request.user.is_staff: - stage_form = forms.CandidateStageForm() + stage_form = forms.ApplicationStageForm() @@ -269,31 +275,33 @@ def candidate_detail(request, slug): @login_required +@staff_user_required def candidate_resume_template_view(request, slug): """Display formatted resume template for a candidate""" - candidate = get_object_or_404(models.Candidate, slug=slug) + application = get_object_or_404(models.Application, slug=slug) if not request.user.is_staff: messages.error(request, _("You don't have permission to view this page.")) return redirect('candidate_list') return render(request, 'recruitment/candidate_resume_template.html', { - 'candidate': candidate + 'application': application }) @login_required +@staff_user_required def candidate_update_stage(request, slug): """Handle HTMX stage update requests""" - candidate = get_object_or_404(models.Candidate, slug=slug) - form = forms.CandidateStageForm(request.POST, instance=candidate) + application = get_object_or_404(models.Application, slug=slug) + form = forms.ApplicationStageForm(request.POST, instance=application) if form.is_valid(): stage_value = form.cleaned_data['stage'] - candidate.stage = stage_value - candidate.save(update_fields=['stage']) - messages.success(request,"Candidate Stage Updated") - return redirect("candidate_detail",slug=candidate.slug) + application.stage = stage_value + application.save(update_fields=['stage']) + messages.success(request,"application Stage Updated") + return redirect("candidate_detail",slug=application.slug) -class TrainingListView(LoginRequiredMixin, ListView): +class TrainingListView(LoginRequiredMixin, StaffRequiredMixin, ListView): model = models.TrainingMaterial template_name = 'recruitment/training_list.html' context_object_name = 'materials' @@ -321,7 +329,7 @@ class TrainingListView(LoginRequiredMixin, ListView): return context -class TrainingCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView): +class TrainingCreateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, CreateView): model = models.TrainingMaterial form_class = forms.TrainingMaterialForm template_name = 'recruitment/training_create.html' @@ -333,7 +341,7 @@ class TrainingCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView): return super().form_valid(form) -class TrainingUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView): +class TrainingUpdateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, UpdateView): model = models.TrainingMaterial form_class = forms.TrainingMaterialForm template_name = 'recruitment/training_update.html' @@ -342,13 +350,13 @@ class TrainingUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView): slug_url_kwarg = 'slug' -class TrainingDetailView(LoginRequiredMixin, DetailView): +class TrainingDetailView(LoginRequiredMixin, StaffRequiredMixin, DetailView): model = models.TrainingMaterial template_name = 'recruitment/training_detail.html' context_object_name = 'material' slug_url_kwarg = 'slug' -class TrainingDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView): +class TrainingDeleteView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, DeleteView): model = models.TrainingMaterial template_name = 'recruitment/training_delete.html' success_url = reverse_lazy('training_list') @@ -366,6 +374,7 @@ TARGET_TIME_TO_HIRE_DAYS = 45 # Used for the template visualization @login_required +@staff_user_required def dashboard_view(request): selected_job_pk = request.GET.get('selected_job_pk') @@ -374,7 +383,7 @@ def dashboard_view(request): # --- 1. BASE QUERYSETS & GLOBAL METRICS (UNFILTERED) --- all_jobs_queryset = models.JobPosting.objects.all().order_by('-created_at') - all_candidates_queryset = models.Candidate.objects.all() + all_candidates_queryset = models.Application.objects.all() # Global KPI Card Metrics total_jobs_global = all_jobs_queryset.count() @@ -383,7 +392,7 @@ def dashboard_view(request): # Data for Job App Count Chart (always for ALL jobs) job_titles = [job.title for job in all_jobs_queryset] - job_app_counts = [job.candidates.count() for job in all_jobs_queryset] + job_app_counts = [job.applications.count() for job in all_jobs_queryset] # --- 2. TIME SERIES: GLOBAL DAILY APPLICANTS --- @@ -453,7 +462,7 @@ def dashboard_view(request): open_positions_agg = job_scope_queryset.filter(status="ACTIVE").aggregate(total_open=Sum('open_positions')) total_open_positions = open_positions_agg['total_open'] or 0 average_applications_result = job_scope_queryset.annotate( - candidate_count=Count('candidates', distinct=True) + candidate_count=Count('applications', distinct=True) ).aggregate(avg_apps=Avg('candidate_count'))['avg_apps'] average_applications = round(average_applications_result or 0, 2) @@ -588,6 +597,7 @@ def dashboard_view(request): @login_required +@staff_user_required def candidate_offer_view(request, slug): """View for candidates in the Offer stage""" job = get_object_or_404(models.JobPosting, slug=slug) @@ -617,6 +627,7 @@ def candidate_offer_view(request, slug): @login_required +@staff_user_required def candidate_hired_view(request, slug): """View for hired candidates""" job = get_object_or_404(models.JobPosting, slug=slug) @@ -646,13 +657,15 @@ def candidate_hired_view(request, slug): @login_required +@staff_user_required def update_candidate_status(request, job_slug, candidate_slug, stage_type, status): """Handle exam/interview/offer status updates""" from django.utils import timezone job = get_object_or_404(models.JobPosting, slug=job_slug) - candidate = get_object_or_404(models.Candidate, slug=candidate_slug, job=job) + candidate = get_object_or_404(models.Application, slug=candidate_slug, job=job) print(stage_type) + print(status) print(request.method) if request.method == "POST": if stage_type == 'exam': @@ -711,6 +724,7 @@ STAGE_CONFIG = { @login_required +@staff_user_required def export_candidates_csv(request, job_slug, stage): """Export candidates for a specific stage as CSV""" job = get_object_or_404(models.JobPosting, slug=job_slug) @@ -724,9 +738,9 @@ def export_candidates_csv(request, job_slug, stage): # Filter candidates based on stage if stage == 'hired': - candidates = job.candidates.filter(**config['filter']) + candidates = job.applications.filter(**config['filter']) else: - candidates = job.candidates.filter(**config['filter']) + candidates = job.applications.filter(**config['filter']) # Handle search if provided search_query = request.GET.get('search', '') @@ -850,6 +864,7 @@ def export_candidates_csv(request, job_slug, stage): @login_required +@staff_user_required def sync_hired_candidates(request, job_slug): """Sync hired candidates to external sources using Django-Q""" from django_q.tasks import async_task @@ -888,6 +903,7 @@ def sync_hired_candidates(request, job_slug): @login_required +@staff_user_required def test_source_connection(request, source_id): """Test connection to an external source""" from .candidate_sync_service import CandidateSyncService @@ -922,6 +938,7 @@ def test_source_connection(request, source_id): @login_required +@staff_user_required def sync_task_status(request, task_id): """Check the status of a sync task""" from django_q.models import Task @@ -973,6 +990,7 @@ def sync_task_status(request, task_id): @login_required +@staff_user_required def sync_history(request, job_slug=None): """View sync history and logs""" from .models import IntegrationLog @@ -1007,7 +1025,7 @@ def sync_history(request, job_slug=None): #participants views -class ParticipantsListView(LoginRequiredMixin, ListView): +class ParticipantsListView(LoginRequiredMixin, StaffRequiredMixin, ListView): model = models.Participants template_name = 'participants/participants_list.html' context_object_name = 'participants' @@ -1036,13 +1054,13 @@ class ParticipantsListView(LoginRequiredMixin, ListView): context = super().get_context_data(**kwargs) context['search_query'] = self.request.GET.get('search', '') return context -class ParticipantsDetailView(LoginRequiredMixin, DetailView): +class ParticipantsDetailView(LoginRequiredMixin, StaffRequiredMixin, DetailView): model = models.Participants template_name = 'participants/participants_detail.html' context_object_name = 'participant' slug_url_kwarg = 'slug' -class ParticipantsCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView): +class ParticipantsCreateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, CreateView): model = models.Participants form_class = forms.ParticipantsForm template_name = 'participants/participants_create.html' @@ -1058,7 +1076,7 @@ class ParticipantsCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateVie -class ParticipantsUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView): +class ParticipantsUpdateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, UpdateView): model = models.Participants form_class = forms.ParticipantsForm template_name = 'participants/participants_create.html' @@ -1066,7 +1084,7 @@ class ParticipantsUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateVie success_message = 'Participant updated successfully.' slug_url_kwarg = 'slug' -class ParticipantsDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView): +class ParticipantsDeleteView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, DeleteView): model = models.Participants success_url = reverse_lazy('participants_list') # Redirect to the participants list after success diff --git a/templates/base.html b/templates/base.html index 98ac233..fc17266 100644 --- a/templates/base.html +++ b/templates/base.html @@ -238,7 +238,15 @@ {% include "icons/users.html" %} - {% trans "Applicants" %} + {% trans "Applications" %} + + + + @@ -330,6 +338,7 @@ + + + + + + + + +{% endblock %} diff --git a/templates/people/person_detail.html b/templates/people/person_detail.html new file mode 100644 index 0000000..79c115a --- /dev/null +++ b/templates/people/person_detail.html @@ -0,0 +1,607 @@ +{% extends "base.html" %} +{% load static i18n %} + +{% block title %}{{ person.get_full_name }} - {{ block.super }}{% endblock %} + +{% block customCSS %} + +{% endblock %} + +{% block content %} +
+ + + + +
+
+
+ {% if person.profile_image %} + {{ person.get_full_name }} + {% else %} +
+ +
+ {% endif %} +
+
+

{{ person.get_full_name }}

+ {% if person.email %} +

+ {{ person.email }} +

+ {% endif %} +
+ {% if person.nationality %} + + {{ person.nationality }} + + {% endif %} + {% if person.gender %} + + {% if person.gender == 'M' %}{% trans "Male" %}{% else %}{% trans "Female" %}{% endif %} + + {% endif %} + {% if person.user %} + + {% trans "User Account" %} + + {% endif %} +
+ {% if user.is_staff %} +
+ + {% trans "Edit Person" %} + + +
+ {% endif %} +
+
+
+ +
+ +
+
+
+
+
{% trans "Personal Information" %}
+ +
+ + {% trans "Full Name" %}: + {{ person.get_full_name }} +
+ + {% if person.first_name %} +
+ + {% trans "First Name" %}: + {{ person.first_name }} +
+ {% endif %} + + {% if person.middle_name %} +
+ + {% trans "Middle Name" %}: + {{ person.middle_name }} +
+ {% endif %} + + {% if person.last_name %} +
+ + {% trans "Last Name" %}: + {{ person.last_name }} +
+ {% endif %} + + {% if person.date_of_birth %} +
+ + {% trans "Date of Birth" %}: + {{ person.date_of_birth }} +
+ {% endif %} + + {% if person.gender %} +
+ + {% trans "Gender" %}: + + {% if person.gender == 'M' %}{% trans "Male" %}{% else %}{% trans "Female" %}{% endif %} + +
+ {% endif %} + + {% if person.nationality %} +
+ + {% trans "Nationality" %}: + {{ person.nationality }} +
+ {% endif %} +
+
+
+
+ + +
+
+
+
+
{% trans "Contact Information" %}
+ + {% if person.email %} +
+ + {% trans "Email" %}: + + + {{ person.email }} + + +
+ {% endif %} + + {% if person.phone %} +
+ + {% trans "Phone" %}: + + + {{ person.phone }} + + +
+ {% endif %} + + {% if person.address %} +
+ + {% trans "Address" %}: + {{ person.address|linebreaksbr }} +
+ {% endif %} + + {% if person.linkedin_profile %} +
+ + {% trans "LinkedIn" %}: + + + {% trans "View Profile" %} + + + +
+ {% endif %} +
+
+
+
+
+ + +
+ +
+
+
+
+ {% trans "Applications" %} + {{ person.applications.count }} +
+ + {% if person.applications %} + {% for application in person.applications.all %} + + {% endfor %} + {% else %} +
+ +

{% trans "No applications found" %}

+
+ {% endif %} +
+
+
+ + +
+
+
+
+ {% trans "Documents" %} + {{ person.documents.count }} +
+ + {% if person.documents %} + {% for document in person.documents %} + + {% endfor %} + {% else %} +
+ +

{% trans "No documents found" %}

+
+ {% endif %} +
+
+
+
+ + +
+
+
+
+
+ {% trans "System Information" %} +
+
+
+
+ + {% trans "Created" %}: + {{ person.created_at|date:"d M Y H:i" }} +
+
+
+
+ + {% trans "Last Updated" %}: + {{ person.updated_at|date:"d M Y H:i" }} +
+
+ {% if person.user %} +
+
+ + {% trans "User Account" %}: + + + {{ person.user.username }} + + +
+
+ {% endif %} +
+
+
+
+
+ + +
+
+
+ + {% trans "Back to People" %} + + {% if user.is_staff %} +
+ + {% trans "Edit Person" %} + + +
+ {% endif %} +
+
+
+
+{% endblock %} + +{% block customJS %} + +{% endblock %} diff --git a/templates/people/person_list.html b/templates/people/person_list.html new file mode 100644 index 0000000..a212326 --- /dev/null +++ b/templates/people/person_list.html @@ -0,0 +1,411 @@ +{% extends "base.html" %} +{% load static i18n %} + +{% block title %}People - {{ block.super }}{% endblock %} + +{% block customCSS %} + +{% endblock %} + +{% block content %} +
+ +
+

+ {% trans "People Directory" %} +

+ + {% trans "Add New Person" %} + +
+ + +
+
+
+
+ +
+
+ + +
+
+
+ +
+
+ {% if request.GET.q %}{% endif %} + +
+ + +
+ +
+ + +
+ +
+
+ + {% if request.GET.q or request.GET.nationality or request.GET.gender %} + + {% trans "Clear" %} + + {% endif %} +
+
+
+
+
+
+
+ + {% if people_list %} +
+ + {% include "includes/_list_view_switcher.html" with list_id="person-list" %} + + +
+
+ + + + + + + + + + + + + + + + {% for person in people_list %} + + + + + + + + + + + + {% endfor %} + +
{% trans "Photo" %}{% trans "Name" %}{% trans "Email" %}{% trans "Phone" %}{% trans "Nationality" %}{% trans "Gender" %}{% trans "Agency" %}{% trans "Created" %}{% trans "Actions" %}
+ {% if person.profile_image %} + {{ person.get_full_name }} + {% else %} +
+ +
+ {% endif %} +
+ + {{ person.full_name }} + + {{ person.email|default:"N/A" }}{{ person.phone|default:"N/A" }} + {% if person.nationality %} + {{ person.nationality }} + {% else %} + N/A + {% endif %} + + {% if person.gender %} + + {% if person.gender == 'M' %}{% trans "Male" %}{% else %}{% trans "Female" %}{% endif %} + + {% else %} + N/A + {% endif %} + {{ person.agency.name|default:"N/A" }}{{ person.created_at|date:"d-m-Y" }} +
+ + + + {% if user.is_staff %} + + + + + {% endif %} +
+
+
+
+ + +
+ {% for person in people_list %} +
+
+
+
+
+ {% if person.profile_image %} + {{ person.get_full_name }} + {% else %} +
+ +
+ {% endif %} +
+
+
+ + {{ person.get_full_name }} + +
+

{{ person.email|default:"N/A" }}

+
+
+ +
+ {% if person.phone %} +
+ {{ person.phone }} +
+ {% endif %} + {% if person.nationality %} +
+ + {{ person.nationality }} +
+ {% endif %} + {% if person.gender %} +
+ + + {% if person.gender == 'M' %}{% trans "Male" %}{% else %}{% trans "Female" %}{% endif %} + +
+ {% endif %} + {% if person.date_of_birth %} +
+ {{ person.date_of_birth }} +
+ {% endif %} +
+ +
+
+ + {% trans "View" %} + + {% if user.is_staff %} + + {% trans "Edit" %} + + + {% endif %} +
+
+
+
+
+ {% endfor %} +
+
+ + + {% include "includes/paginator.html" %} + {% else %} + +
+
+ +

{% trans "No people found" %}

+

{% trans "Create your first person record." %}

+ {% if user.is_staff %} + + {% trans "Add Person" %} + + {% endif %} +
+
+ {% endif %} +
+{% endblock %} diff --git a/templates/people/update_person.html b/templates/people/update_person.html new file mode 100644 index 0000000..788cb56 --- /dev/null +++ b/templates/people/update_person.html @@ -0,0 +1,572 @@ +{% extends "base.html" %} +{% load static i18n crispy_forms_tags %} + +{% block title %}Update {{ person.get_full_name }} - {{ block.super }}{% endblock %} + +{% block customCSS %} + +{% endblock %} + +{% block content %} +
+
+ + + + +
+

+ {% trans "Update Person" %} +

+ +
+ + +
+
+
+
{% trans "Currently Editing" %}
+
+ {% if person.profile_image %} + {{ person.get_full_name }} + {% else %} +
+ +
+ {% endif %} +
+
{{ person.get_full_name }}
+ {% if person.email %} +

{{ person.email }}

+ {% endif %} + + {% trans "Created" %}: {{ person.created_at|date:"d M Y" }} • + {% trans "Last Updated" %}: {{ person.updated_at|date:"d M Y" }} + +
+
+
+
+
+ + +
+
+ {% if form.non_field_errors %} + + {% endif %} + + {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} + +
+ {% csrf_token %} + + +
+
+
+
+ {% if person.profile_image %} + Current Profile +
{% trans "Click to change photo" %}
+

{% trans "Current photo will be replaced" %}

+ {% else %} + +
{% trans "Upload Profile Photo" %}
+

{% trans "Click to browse or drag and drop" %}

+ {% endif %} +
+ +
+ {% if person.profile_image %} +
+ + {% trans "Leave empty to keep current photo" %} + +
+ {% endif %} +
+
+ + +
+
+
+ {% trans "Personal Information" %} +
+
+
+ {{ form.first_name|as_crispy_field }} +
+
+ {{ form.middle_name|as_crispy_field }} +
+
+ {{ form.last_name|as_crispy_field }} +
+
+ + +
+
+
+ {% trans "Contact Information" %} +
+
+
+ {{ form.email|as_crispy_field }} +
+
+ {{ form.phone|as_crispy_field }} +
+
+ + +
+
+
+ {% trans "Additional Information" %} +
+
+
+ {{ form.date_of_birth|as_crispy_field }} +
+
+ {{ form.nationality|as_crispy_field }} +
+
+ {{ form.gender|as_crispy_field }} +
+
+ + +
+
+
+ {% trans "Address Information" %} +
+
+
+ {{ form.address|as_crispy_field }} +
+
+ + +
+
+
+ {% trans "Professional Profile" %} +
+
+
+
+ + + + {% trans "Optional: Add LinkedIn profile URL" %} + +
+
+
+ + +
+
+
+ +
+ + +
+
+
+
+
+
+
+
+
+{% endblock %} + +{% block customJS %} + +{% endblock %} diff --git a/templates/portal_base.html b/templates/portal_base.html index 0e1b28b..7386ebf 100644 --- a/templates/portal_base.html +++ b/templates/portal_base.html @@ -76,6 +76,24 @@ {% endif %} - +
@@ -210,10 +210,12 @@ {% trans "Share these credentials securely with the agency. They can use this information to log in and submit candidates." %}
-
- {% trans "View Access Links Details" %} - + {% if access_link %} + + {% trans "View Access Links Details" %} + + {% endif %} @@ -331,7 +333,7 @@ - +
@@ -488,14 +490,14 @@ function copyToClipboard(elementId) { function confirmDeactivate() { if (confirm('{% trans "Are you sure you want to deactivate this access link? Agencies will no longer be able to use it." %}')) { // Submit form to deactivate - window.location.href = '{% url "agency_access_link_deactivate" access_link.slug %}'; + window.location.href = ''; } } function confirmReactivate() { if (confirm('{% trans "Are you sure you want to reactivate this access link?" %}')) { // Submit form to reactivate - window.location.href = '{% url "agency_access_link_reactivate" access_link.slug %}'; + window.location.href = ''; } } diff --git a/templates/recruitment/agency_portal_persons_list.html b/templates/recruitment/agency_portal_persons_list.html new file mode 100644 index 0000000..8e26f28 --- /dev/null +++ b/templates/recruitment/agency_portal_persons_list.html @@ -0,0 +1,390 @@ +{% extends 'portal_base.html' %} +{% load static i18n crispy_forms_tags %} + +{% block title %}{% trans "Persons List" %} - ATS{% endblock %} +{% block customCSS %} + +{% endblock%} + +{% block content %} +
+
+
+

+ + {% trans "All Persons" %} +

+

+ {% trans "All persons who come through" %} {{ agency.name }} +

+
+
+ + +
+
+ + +
+
+
+
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+ + +
+
+
+
+
+ +
+

{{ total_persons }}

+

{% trans "Total Persons" %}

+
+
+
+
+
+
+
+ +
+

{{ page_obj|length }}

+

{% trans "Showing on this page" %}

+
+
+
+
+ + +
+
+ {% if page_obj %} +
+ + + + + + + + + + + + + + {% for person in page_obj %} + + + + + + + + + + {% endfor %} + +
{% trans "Name" %}{% trans "Email" %}{% trans "Phone" %}{% trans "Job" %}{% trans "Stage" %}{% trans "Applied Date" %}{% trans "Actions" %}
+
+
+ {{ person.first_name|first|upper }}{{ person.last_name|first|upper }} +
+
+
{{ person.first_name }} {{ person.last_name }}
+ {% if person.address %} + {{ person.address|truncatechars:50 }} + {% endif %} +
+
+
+ + {{ person.email }} + + {{ person.phone|default:"-" }} + + {{ person.job.title|truncatechars:30 }} + + + {% with stage_class=person.stage|lower %} + + {{ person.get_stage_display }} + + {% endwith %} + {{ person.created_at|date:"Y-m-d" }} +
+ + + + +
+
+
+ {% else %} +
+ +
{% trans "No persons found" %}
+

+ {% if search_query or stage_filter %} + {% trans "Try adjusting your search or filter criteria." %} + {% else %} + {% trans "No persons have been added yet." %} + {% endif %} +

+ {% if not search_query and not stage_filter and agency.assignments.exists %} + + {% trans "Add First Person" %} + + {% endif %} +
+ {% endif %} +
+
+ + + {% if page_obj.has_other_pages %} + + {% endif %} +
+ + + + + + +{% endblock %} diff --git a/templates/recruitment/candidate_create.html b/templates/recruitment/candidate_create.html index 185ea9f..c31161b 100644 --- a/templates/recruitment/candidate_create.html +++ b/templates/recruitment/candidate_create.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% load static i18n crispy_forms_tags %} -{% block title %}Create Candidate - {{ block.super }}{% endblock %} +{% block title %}Create Application - {{ block.super }}{% endblock %} {% block customCSS %}