diff --git a/NorahUniversity/settings.py b/NorahUniversity/settings.py index 8da31e5..66bd7bb 100644 --- a/NorahUniversity/settings.py +++ b/NorahUniversity/settings.py @@ -62,6 +62,7 @@ INSTALLED_APPS = [ "django_q", "widget_tweaks", "easyaudit", + "mathfilters" ] @@ -491,4 +492,45 @@ MESSAGE_TAGS = { AUTH_USER_MODEL = "recruitment.CustomUser" -ZOOM_WEBHOOK_API_KEY = "2GNDC5Rvyw9AHoGikHXsQB" \ No newline at end of file +ZOOM_WEBHOOK_API_KEY = "2GNDC5Rvyw9AHoGikHXsQB" + + + +#logger: +LOGGING={ + "version": 1, + "disable_existing_loggers": False, + "handlers": { + "file": { + "class": "logging.FileHandler", + "filename": os.path.join(BASE_DIR, "general.log"), + "level": "DEBUG", + "formatter": "verbose", + }, + "console":{ + "class": "logging.StreamHandler", + "level": "DEBUG", + "formatter": "simple" + } + + }, + "loggers": { + "": { + "handlers": ["file", "console"], + "level": "DEBUG", + "propagate": True, + }, + }, + "formatters": { + "verbose": { + "format": "[{asctime}] {levelname} [{name}:{lineno}] {message}", + "style": "{", + }, + "simple": { + "format": "{levelname} {message}", + "style": "{", + }, + } +} + + diff --git a/recruitment/admin.py b/recruitment/admin.py index 98620de..543b704 100644 --- a/recruitment/admin.py +++ b/recruitment/admin.py @@ -6,7 +6,7 @@ from .models import ( JobPosting, Application, TrainingMaterial, FormTemplate, FormStage, FormField, FormSubmission, FieldResponse, SharedFormTemplate, Source, HiringAgency, IntegrationLog,BulkInterviewTemplate,JobPostingImage,Note, - AgencyAccessLink, AgencyJobAssignment,Interview,ScheduledInterview + AgencyAccessLink, AgencyJobAssignment,Interview,ScheduledInterview,Person ) from django.contrib.auth import get_user_model @@ -250,4 +250,5 @@ admin.site.register(ScheduledInterview) admin.site.register(JobPostingImage) +admin.site.register(Person) # admin.site.register(User) diff --git a/recruitment/decorators.py b/recruitment/decorators.py index 7ea41c5..e482079 100644 --- a/recruitment/decorators.py +++ b/recruitment/decorators.py @@ -6,6 +6,7 @@ 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 +from django.contrib.auth.decorators import user_passes_test def job_not_expired(view_func): @wraps(view_func) @@ -162,3 +163,12 @@ def staff_or_agency_required(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='/accounts/login/')(view_func) + + +def is_superuser(user): + + return user.is_authenticated and user.is_superuser + + +def superuser_required(view_func): + return user_passes_test(is_superuser, login_url='/admin/login/?next=/', redirect_field_name=None)(view_func) \ No newline at end of file diff --git a/recruitment/forms.py b/recruitment/forms.py index c7a5bcc..4340ba3 100644 --- a/recruitment/forms.py +++ b/recruitment/forms.py @@ -28,7 +28,8 @@ from .models import ( Message, Person, Document, - CustomUser + CustomUser, + Interview ) # from django_summernote.widgets import SummernoteWidget @@ -270,7 +271,7 @@ class SourceAdvancedForm(forms.ModelForm): class PersonForm(forms.ModelForm): class Meta: model = Person - fields = ["first_name","middle_name", "last_name", "email", "phone","date_of_birth","nationality","gender","address"] + fields = ["first_name","middle_name", "last_name", "email", "phone","date_of_birth","gpa","national_id","nationality","gender","address"] widgets = { "first_name": forms.TextInput(attrs={'class': 'form-control'}), "middle_name": forms.TextInput(attrs={'class': 'form-control'}), @@ -281,7 +282,45 @@ class PersonForm(forms.ModelForm): "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}), + "gpa": forms.TextInput(attrs={'class': 'custom-decimal-input'}), + "national_id":forms.NumberInput(attrs={'min': 0, 'step': 1}), } + def clean_email(self): + email = self.cleaned_data.get('email') + + if not email: + + return email + + + if email: + instance = self.instance + qs = CustomUser.objects.filter(email=email) | CustomUser.objects.filter(username=email) + if not instance.pk: # Creating new instance + + + if qs.exists(): + raise ValidationError(_("A user account with this email address already exists. Please use a different email.")) + + else: # Editing existing instance + # if ( + # qs + # .exclude(pk=instance.user.pk) + # .exists() + # ): + + # raise ValidationError(_("An user with this email already exists.")) + pass + + return email.strip() + + + + + + + return email + class ApplicationForm(forms.ModelForm): class Meta: @@ -790,11 +829,36 @@ class BulkInterviewTemplateForm(forms.ModelForm): self.fields["applications"].queryset = Application.objects.filter( job__slug=slug, stage="Interview" ) + self.fields["topic"].initial = "Interview for " + str( + self.fields["applications"].queryset.first().job.title + ) + self.fields["start_date"].initial = timezone.now().date() + working_days_initial = [0, 1, 2, 3, 6] # Monday to Friday + self.fields["working_days"].initial = working_days_initial + self.fields["start_time"].initial = "08:00" + self.fields["end_time"].initial = "14:00" + self.fields["interview_duration"].initial = 30 + self.fields["buffer_time"].initial = 10 + self.fields["break_start_time"].initial = "11:30" + self.fields["break_end_time"].initial = "12:00" + self.fields["physical_address"].initial = "Airport Road, King Khalid International Airport, Riyadh 11564, Saudi Arabia" def clean_working_days(self): working_days = self.cleaned_data.get("working_days") return [int(day) for day in working_days] +class InterviewCancelForm(forms.ModelForm): + class Meta: + model = Interview + fields = ["cancelled_reason","cancelled_at"] + widgets = { + "cancelled_reason": forms.Textarea( + attrs={"class": "form-control", "rows": 3} + ), + "cancelled_at": forms.DateTimeInput( + attrs={"class": "form-control", "type": "datetime-local"} + ), + } class NoteForm(forms.ModelForm): """Form for creating and editing meeting comments""" @@ -959,7 +1023,7 @@ class HiringAgencyForm(forms.ModelForm): } ), "email": forms.EmailInput( - attrs={"class": "form-control"} + attrs={"class": "form-control","required": True} ), "phone": forms.TextInput( attrs={"class": "form-control"} @@ -1048,6 +1112,7 @@ class HiringAgencyForm(forms.ModelForm): # instance = self.instance email = email.lower().strip() if not instance.pk: # Creating new instance + print("created ....") if HiringAgency.objects.filter(email=email).exists(): raise ValidationError("An agency with this email already exists.") else: # Editing existing instance @@ -2292,18 +2357,19 @@ class ApplicantSignupForm(forms.ModelForm): class Meta: model = Person - fields = ["first_name","middle_name","last_name", "email","phone","gpa","nationality", "date_of_birth","gender","address"] + fields = ["first_name","middle_name","last_name", "email","phone","gpa","nationality","national_id", "date_of_birth","gender","address"] 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'}), - # 'gpa': forms.TextInput(attrs={'class': 'form-control'}), + 'gpa': forms.TextInput(attrs={'class': 'custom-decimal-input'}), "nationality": forms.Select(attrs={'class': 'form-control select2'}), 'date_of_birth': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}), 'gender': forms.Select(attrs={'class': 'form-control'}), 'address': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}), + 'national_id':forms.NumberInput(attrs={'min': 0, 'step': 1}), } def clean(self): @@ -2878,4 +2944,17 @@ class ScheduledInterviewUpdateStatusForm(forms.Form): ) class Meta: model = ScheduledInterview - fields = ['status'] \ No newline at end of file + fields = ['status'] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Filter the choices here + EXCLUDED_STATUS = ScheduledInterview.InterviewStatus.CANCELLED + filtered_choices = [ + choice for choice in ScheduledInterview.InterviewStatus.choices + if choice[0]!= EXCLUDED_STATUS + ] + + # Apply the filtered list back to the field + self.fields['status'].choices = filtered_choices \ No newline at end of file diff --git a/recruitment/migrations/0001_initial.py b/recruitment/migrations/0001_initial.py index 33e380b..17ce045 100644 --- a/recruitment/migrations/0001_initial.py +++ b/recruitment/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.7 on 2025-12-02 14:21 +# Generated by Django 5.2.7 on 2025-12-08 15:04 import django.contrib.auth.models import django.contrib.auth.validators @@ -73,6 +73,8 @@ class Migration(migrations.Migration): ('start_time', models.DateTimeField(db_index=True, verbose_name='Start Time')), ('duration', models.PositiveIntegerField(verbose_name='Duration (minutes)')), ('status', models.CharField(choices=[('waiting', 'Waiting'), ('started', 'Started'), ('ended', 'Ended'), ('cancelled', 'Cancelled')], db_index=True, default='waiting', max_length=20)), + ('cancelled_at', models.DateTimeField(blank=True, null=True, verbose_name='Cancelled At')), + ('cancelled_reason', models.TextField(blank=True, null=True, verbose_name='Cancellation Reason')), ('meeting_id', models.CharField(blank=True, max_length=50, null=True, unique=True, verbose_name='External Meeting ID')), ('password', models.CharField(blank=True, max_length=20, null=True)), ('zoom_gateway_response', models.JSONField(blank=True, null=True)), @@ -150,7 +152,7 @@ class Migration(migrations.Migration): ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), ('user_type', models.CharField(choices=[('staff', 'Staff'), ('agency', 'Agency'), ('candidate', 'Candidate')], default='staff', max_length=20, verbose_name='User Type')), - ('phone', models.CharField(blank=True, max_length=20, null=True, verbose_name='Phone')), + ('phone', models.CharField(blank=True, null=True, verbose_name='Phone')), ('profile_image', models.ImageField(blank=True, null=True, upload_to='profile_pic/', validators=[recruitment.validators.validate_image_size], verbose_name='Profile Image')), ('designation', models.CharField(blank=True, max_length=100, null=True, verbose_name='Designation')), ('email', models.EmailField(error_messages={'unique': 'A user with this email already exists.'}, max_length=254, unique=True)), @@ -242,8 +244,8 @@ class Migration(migrations.Migration): ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), ('name', models.CharField(max_length=200, unique=True, verbose_name='Agency Name')), ('contact_person', models.CharField(blank=True, max_length=150, verbose_name='Contact Person')), - ('email', models.EmailField(blank=True, max_length=254)), - ('phone', models.CharField(blank=True, max_length=20)), + ('email', models.EmailField(max_length=254, unique=True)), + ('phone', models.CharField(blank=True, max_length=20, null=True)), ('website', models.URLField(blank=True)), ('notes', models.TextField(blank=True, help_text='Internal notes about the agency')), ('country', django_countries.fields.CountryField(blank=True, max_length=2, null=True)), @@ -485,10 +487,11 @@ class Migration(migrations.Migration): ('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, max_length=254, unique=True, verbose_name='Email')), - ('phone', models.CharField(blank=True, max_length=20, null=True, verbose_name='Phone')), + ('phone', models.CharField(blank=True, 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')], max_length=1, null=True, verbose_name='Gender')), - ('gpa', models.DecimalField(blank=True, decimal_places=2, max_digits=3, null=True, verbose_name='GPA')), + ('gpa', models.DecimalField(decimal_places=2, help_text='GPA must be between 0 and 4.', max_digits=3, verbose_name='GPA')), + ('national_id', models.CharField(help_text='Enter the national id or iqama number')), ('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')), diff --git a/recruitment/models.py b/recruitment/models.py index 1da11ba..71c220a 100644 --- a/recruitment/models.py +++ b/recruitment/models.py @@ -18,10 +18,11 @@ 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 -from django.db.models import F, Value, IntegerField, CharField +from django.db.models import F, Value, IntegerField, CharField,Q from django.db.models.functions import Coalesce, Cast from django.db.models.fields.json import KeyTransform, KeyTextTransform + class EmailContent(models.Model): subject = models.CharField(max_length=255, verbose_name=_("Subject")) message = CKEditor5Field(verbose_name=_("Message Body")) @@ -47,7 +48,7 @@ class CustomUser(AbstractUser): max_length=20, choices=USER_TYPES, default="staff", verbose_name=_("User Type") ) phone = models.CharField( - max_length=20, blank=True, null=True, verbose_name=_("Phone") + blank=True, null=True, verbose_name=_("Phone") ) profile_image = models.ImageField( null=True, @@ -65,11 +66,20 @@ class CustomUser(AbstractUser): "unique": _("A user with this email already exists."), }, ) - + class Meta: verbose_name = _("User") verbose_name_plural = _("Users") + @property + def get_unread_message_count(self): + message_list = ( + Message.objects.filter(Q(recipient=self), is_read=False) + ) + return message_list.count() or 0 + + + User = get_user_model() @@ -513,7 +523,7 @@ class Person(Base): verbose_name=_("Email"), ) phone = models.CharField( - max_length=20, blank=True, null=True, verbose_name=_("Phone") + blank=True, null=True, verbose_name=_("Phone") ) date_of_birth = models.DateField( null=True, blank=True, verbose_name=_("Date of Birth") @@ -526,8 +536,11 @@ class Person(Base): verbose_name=_("Gender"), ) gpa = models.DecimalField( - max_digits=3, decimal_places=2, blank=True, null=True, verbose_name=_("GPA") + max_digits=3, decimal_places=2, verbose_name=_("GPA"),help_text=_("GPA must be between 0 and 4.") ) + national_id = models.CharField( + help_text=_("Enter the national id or iqama number") + ) nationality = CountryField(blank=True, null=True, verbose_name=_("Nationality")) address = models.TextField(blank=True, null=True, verbose_name=_("Address")) @@ -1327,6 +1340,8 @@ class Interview(Base): default=Status.WAITING, db_index=True ) + cancelled_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Cancelled At")) + cancelled_reason = models.TextField(blank=True, null=True, verbose_name=_("Cancellation Reason")) # Remote-specific (nullable) meeting_id = models.CharField( @@ -2077,8 +2092,8 @@ class HiringAgency(Base): contact_person = models.CharField( max_length=150, blank=True, verbose_name=_("Contact Person") ) - email = models.EmailField(blank=True) - phone = models.CharField(max_length=20, blank=True) + email = models.EmailField(unique=True) + phone = models.CharField(max_length=20, blank=True,null=True) website = models.URLField(blank=True) notes = models.TextField(blank=True, help_text=_("Internal notes about the agency")) country = CountryField(blank=True, null=True, blank_label=_("Select country")) @@ -2108,6 +2123,7 @@ class HiringAgency(Base): # 2. Call the original delete method for the Agency instance super().delete(*args, **kwargs) + class AgencyJobAssignment(Base): @@ -2267,6 +2283,15 @@ class AgencyJobAssignment(Base): # self.save(update_fields=['status']) return True return False + @property + def applications_submited_count(self): + """Return the number of applications submitted by the agency for this job""" + return Application.objects.filter( + hiring_agency=self.agency, + job=self.job + ).count() + + def extend_deadline(self, new_deadline): """Extend the deadline for this assignment""" @@ -2478,7 +2503,7 @@ class Participants(Base): name = models.CharField( max_length=255, verbose_name=_("Participant Name"), null=True, blank=True ) - email = models.EmailField(verbose_name=_("Email")) + email =models.EmailField(verbose_name=_("Email")) phone = models.CharField( max_length=12, verbose_name=_("Phone Number"), null=True, blank=True ) diff --git a/recruitment/signals.py b/recruitment/signals.py index 8a6559a..3e7f408 100644 --- a/recruitment/signals.py +++ b/recruitment/signals.py @@ -1,5 +1,5 @@ import logging -import random + from datetime import datetime, timedelta from django.db import transaction from django_q.models import Schedule @@ -437,12 +437,8 @@ def notification_created(sender, instance, created, **kwargs): logger.info(f"Notification cached for SSE: {notification_data}") -def generate_random_password(): - import string - - return "".join(random.choices(string.ascii_letters + string.digits, k=12)) - +from .utils import generate_random_password @receiver(post_save, sender=HiringAgency) def hiring_agency_created(sender, instance, created, **kwargs): if created: @@ -463,16 +459,19 @@ def hiring_agency_created(sender, instance, created, **kwargs): def person_created(sender, instance, created, **kwargs): if created and not instance.user: logger.info(f"New Person created: {instance.pk} - {instance.email}") - user = User.objects.create_user( - username=instance.email, - first_name=instance.first_name, - last_name=instance.last_name, - email=instance.email, - phone=instance.phone, - user_type="candidate", - ) - instance.user = user - instance.save() + try: + user = User.objects.create_user( + username=instance.email, + first_name=instance.first_name, + last_name=instance.last_name, + email=instance.email, + phone=instance.phone, + user_type="candidate", + ) + instance.user = user + instance.save() + except Exception as e: + print(e) @receiver(post_save, sender=Source) diff --git a/recruitment/urls.py b/recruitment/urls.py index 3c509a4..c5b4ecb 100644 --- a/recruitment/urls.py +++ b/recruitment/urls.py @@ -283,7 +283,8 @@ urlpatterns = [ name="user_profile_image_update", ), path("easy_logs/", views.easy_logs, name="easy_logs"), - path("settings/", views.admin_settings, name="admin_settings"), + path('settings/',views.settings,name="settings"), + path("settings/admin/", views.admin_settings, name="admin_settings"), path("staff/create", views.create_staff_user, name="create_staff_user"), path( "set_staff_password//", @@ -353,6 +354,8 @@ urlpatterns = [ # ), # Hiring Agency URLs path("agencies/", views.agency_list, name="agency_list"), + path("regenerate_agency_password//", views.regenerate_agency_password, name="regenerate_agency_password"), + path("deactivate_agency//", views.deactivate_agency, name="deactivate_agency"), path("agencies/create/", views.agency_create, name="agency_create"), path("agencies//", views.agency_detail, name="agency_detail"), path("agencies//update/", views.agency_update, name="agency_update"), diff --git a/recruitment/utils.py b/recruitment/utils.py index 3e6a09a..919c8a1 100644 --- a/recruitment/utils.py +++ b/recruitment/utils.py @@ -9,6 +9,7 @@ from django.utils import timezone from .models import ScheduledInterview from django.template.loader import render_to_string from django.core.mail import send_mail +import random # nlp = spacy.load("en_core_web_sm") # def extract_text_from_pdf(pdf_path): @@ -617,3 +618,8 @@ def update_meeting(instance, updated_data): + +def generate_random_password(): + import string + + return "".join(random.choices(string.ascii_letters + string.digits, k=12)) \ No newline at end of file diff --git a/recruitment/views.py b/recruitment/views.py index fe75795..7a42f3a 100644 --- a/recruitment/views.py +++ b/recruitment/views.py @@ -1,3 +1,7 @@ +#logger for recruitment views +import logging +logger = logging.getLogger(__name__) + import json import io import zipfile @@ -19,6 +23,7 @@ from .decorators import ( StaffRequiredMixin, StaffOrAgencyRequiredMixin, StaffOrCandidateRequiredMixin, + superuser_required ) from .forms import ( StaffUserCreationForm, @@ -34,8 +39,10 @@ from .forms import ( StaffAssignmentForm, RemoteInterviewForm, OnsiteInterviewForm, - BulkInterviewTemplateForm + BulkInterviewTemplateForm, + InterviewCancelForm ) +from .utils import generate_random_password from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods from django.http import HttpResponse, JsonResponse @@ -155,8 +162,13 @@ logger = logging.getLogger(__name__) User = get_user_model() +@login_required +@superuser_required +def settings(request): + return render(request,'user/settings.html') -class PersonListView(StaffRequiredMixin, ListView): + +class PersonListView(StaffRequiredMixin, ListView,LoginRequiredMixin): model = Person template_name = "people/person_list.html" context_object_name = "people_list" @@ -193,7 +205,7 @@ class PersonListView(StaffRequiredMixin, ListView): -class PersonCreateView(CreateView): +class PersonCreateView(CreateView,LoginRequiredMixin,StaffOrAgencyRequiredMixin): model = Person template_name = "people/create_person.html" form_class = PersonForm @@ -217,25 +229,26 @@ class PersonCreateView(CreateView): -class PersonDetailView(DetailView): +class PersonDetailView(DetailView,LoginRequiredMixin,StaffRequiredMixin): model = Person template_name = "people/person_detail.html" context_object_name = "person" -class PersonUpdateView( UpdateView): +class PersonUpdateView( UpdateView,LoginRequiredMixin,StaffOrAgencyRequiredMixin): model = Person template_name = "people/update_person.html" form_class = PersonForm success_url = reverse_lazy("person_list") def form_valid(self, form): + if self.request.POST.get("view") == "portal": form.save() return redirect("agency_portal_persons_list") return super().form_valid(form) -class PersonDeleteView(StaffRequiredMixin, DeleteView): +class PersonDeleteView(StaffRequiredMixin, DeleteView,LoginRequiredMixin): model = Person template_name = "people/delete_person.html" success_url = reverse_lazy("person_list") @@ -433,7 +446,7 @@ def create_job(request): job = form.save(commit=False) job.save() job_apply_url_relative = reverse( - "application_detail", kwargs={"slug": job.slug} + "job_application_detail", kwargs={"slug": job.slug} ) job_apply_url_absolute = request.build_absolute_uri( job_apply_url_relative @@ -484,7 +497,9 @@ from django.db.models.functions import Coalesce, Cast # Coalesce handles NULLs from django.db.models import Avg, IntegerField, Value # Value is used for the default '0' # These are essential for safely querying PostgreSQL JSONB fields from django.db.models.fields.json import KeyTransform, KeyTextTransform + @staff_user_required +@login_required def job_detail(request, slug): """View details of a specific job""" job = get_object_or_404(JobPosting, slug=slug) @@ -707,12 +722,17 @@ def job_detail(request, slug): # ) # return response - +@login_required +@staff_user_required def request_cvs_download(request, slug): """ View to initiate the background task. """ + job = get_object_or_404(JobPosting, slug=slug) + if job.status != 'CLOSED': + messages.info('request',_("You can request bulk CV dowload only if the job status is changed to CLOSED")) + return redirect('job_detail',kwargs={slug:job.slug}) job.zip_created = False job.save(update_fields=["zip_created"]) # Use async_task to run the function in the background @@ -727,11 +747,17 @@ def request_cvs_download(request, slug): messages.info(request, "The CV compilation has started in the background. It may take a few moments. Refresh this page to check status.") return redirect('job_detail', slug=slug) # Redirect back to the job detail page +@login_required +@staff_user_required def download_ready_cvs(request, slug): """ View to serve the file once it is ready. """ job = get_object_or_404(JobPosting, slug=slug) + if job.status != 'CLOSED': + messages.info('request',_("You can request bulk CV dowload only if the job status is changed to CLOSED")) + return redirect('job_detail',kwargs={slug:job.slug}) + if not job.applications.exists(): messages.warning(request, _("No applications found for this job. ZIP file download unavailable.")) return redirect('job_detail', slug=slug) @@ -973,12 +999,13 @@ def linkedin_callback(request): # applicant views -def applicant_job_detail(request, slug): - """View job details for applicants""" - job = get_object_or_404(JobPosting, slug=slug, status="ACTIVE") - return render(request, "jobs/applicant_job_detail.html", {"job": job}) - +# def applicant_job_detail(request, slug): +# """View job details for applicants""" +# job = get_object_or_404(JobPosting, slug=slug, status="ACTIVE") +# return render(request, "jobs/applicant_job_detail.html", {"job": job}) +@login_required +@candidate_user_required def application_success(request, slug): job = get_object_or_404(JobPosting, slug=slug) return render(request, "jobs/application_success.html", {"job": job}) @@ -1000,6 +1027,8 @@ def form_builder(request, template_slug=None): @csrf_exempt @require_http_methods(["POST"]) +@login_required +@staff_user_required def save_form_template(request): """Save a new or existing form template""" try: @@ -1061,6 +1090,8 @@ def save_form_template(request): @require_http_methods(["GET"]) +@login_required +@staff_user_required def load_form_template(request, template_slug): """Load an existing form template""" template = get_object_or_404(FormTemplate, slug=template_slug) @@ -1215,7 +1246,8 @@ def delete_form_template(request, template_id): ) - +@login_required +@staff_or_candidate_required def application_submit_form(request, template_slug): """Display the form as a step-by-step wizard""" if not request.user.is_authenticated: @@ -1270,7 +1302,10 @@ def application_submit_form(request, template_slug): @csrf_exempt @require_POST +@login_required +@candidate_user_required def application_submit(request, template_slug): + import re """Handle form submission""" if not request.user.is_authenticated :# or request.user.user_type != "candidate": return JsonResponse({"success": False, "message": "Unauthorized access."}) @@ -1333,6 +1368,29 @@ def application_submit(request, template_slug): # phone = submission.responses.get(field__label="Phone Number") # address = submission.responses.get(field__label="Address") gpa = submission.responses.get(field__label="GPA") + if gpa and gpa.value: + gpa_str = gpa.value.replace("/","").strip() + + if not re.match(r'^\d+(\.\d+)?$', gpa_str): + # --- FIX APPLIED HERE --- + return JsonResponse( + {"success": False, "message": _("GPA must be a numeric value.")} + ) + + try: + gpa_float = float(gpa_str) + except ValueError: + # --- FIX APPLIED HERE --- + return JsonResponse( + {"success": False, "message": _("GPA must be a numeric value.")} + ) + + if not (0.0 <= gpa_float <= 4.0): + # --- FIX APPLIED HERE --- + return JsonResponse( + {"success": False, "message": _("GPA must be between 0.0 and 4.0.")} + ) + resume = submission.responses.get(field__label="Resume Upload") @@ -1438,6 +1496,7 @@ def form_template_all_submissions(request, template_id): @login_required +@staff_user_required def form_submission_details(request, template_id, slug): """Display detailed view of a specific form submission""" # Get the form template and verify ownership @@ -1472,6 +1531,8 @@ def form_submission_details(request, template_id, slug): ) +@login_required +@staff_user_required def _handle_get_request(request, slug, job): """ Handles GET requests, setting up forms and restoring candidate selections @@ -1508,7 +1569,8 @@ def _handle_get_request(request, slug, job): ) - +@login_required +@staff_user_required def _handle_preview_submission(request, slug, job): """ Handles the initial POST request (Preview Schedule). @@ -1630,6 +1692,8 @@ def _handle_preview_submission(request, slug, job): ) +@login_required +@staff_user_required def _handle_confirm_schedule(request, slug, job): """ Handles the final POST request (Confirm Schedule). @@ -1742,6 +1806,8 @@ def _handle_confirm_schedule(request, slug, job): return redirect("schedule_interviews", slug=slug) +@login_required +@staff_user_required def schedule_interviews_view(request, slug): job = get_object_or_404(JobPosting, slug=slug) if request.method == "POST": @@ -1754,6 +1820,8 @@ def schedule_interviews_view(request, slug): # return redirect("applications_interview_view", slug=slug) +@login_required +@staff_user_required def confirm_schedule_interviews_view(request, slug): job = get_object_or_404(JobPosting, slug=slug) if request.method == "POST": @@ -1761,6 +1829,7 @@ def confirm_schedule_interviews_view(request, slug): return _handle_confirm_schedule(request, slug, job) +@login_required @staff_user_required def applications_screening_view(request, slug): """ @@ -1843,6 +1912,7 @@ def applications_screening_view(request, slug): return render(request, "recruitment/applications_screening_view.html", context) +@login_required @staff_user_required def applications_exam_view(request, slug): """ @@ -1853,6 +1923,7 @@ def applications_exam_view(request, slug): return render(request, "recruitment/applications_exam_view.html", context) +@login_required @staff_user_required def update_application_exam_status(request, slug): application = get_object_or_404(Application, slug=slug) @@ -1869,7 +1940,7 @@ def update_application_exam_status(request, slug): {"application": application, "form": form}, ) - +@login_required @staff_user_required def bulk_update_application_exam_status(request, slug): job = get_object_or_404(JobPosting, slug=slug) @@ -1889,13 +1960,15 @@ def bulk_update_application_exam_status(request, slug): return redirect("applications_exam_view", slug=job.slug) +@login_required +@staff_user_required def application_criteria_view_htmx(request, pk): application = get_object_or_404(Application, pk=pk) return render( request, "includes/application_modal_body.html", {"application": application} ) - +@login_required @staff_user_required def application_set_exam_date(request, slug): application = get_object_or_404(Application, slug=slug) @@ -1906,7 +1979,7 @@ def application_set_exam_date(request, slug): ) return redirect("applications_screening_view", slug=application.job.slug) - +@login_required @staff_user_required def application_update_status(request, slug): job = get_object_or_404(JobPosting, slug=slug) @@ -1986,6 +2059,7 @@ def application_update_status(request, slug): return response +@login_required @staff_user_required def applications_interview_view(request, slug): job = get_object_or_404(JobPosting, slug=slug) @@ -1998,6 +2072,7 @@ def applications_interview_view(request, slug): return render(request, "recruitment/applications_interview_view.html", context) +@login_required @staff_user_required def applications_document_review_view(request, slug): """ @@ -2024,7 +2099,7 @@ def applications_document_review_view(request, slug): } return render(request, "recruitment/applications_document_review_view.html", context) - +@login_required @require_POST @staff_user_required def reschedule_meeting_for_application(request, slug): @@ -2138,62 +2213,62 @@ def reschedule_meeting_for_application(request, slug): # @staff_user_required # def interview_calendar_view(request, slug): - job = get_object_or_404(JobPosting, slug=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( - "applicaton", "zoom_meeting" - ) + # # Get all scheduled interviews for this job + # scheduled_interviews = ScheduledInterview.objects.filter(job=job).select_related( + # "applicaton", "zoom_meeting" + # ) - # Convert interviews to calendar events - events = [] - for interview in scheduled_interviews: - # Create start datetime - start_datetime = datetime.combine( - interview.interview_date, interview.interview_time - ) + # # Convert interviews to calendar events + # events = [] + # for interview in scheduled_interviews: + # # Create start datetime + # start_datetime = datetime.combine( + # interview.interview_date, interview.interview_time + # ) - # Calculate end datetime based on interview duration - duration = interview.zoom_meeting.duration if interview.zoom_meeting else 60 - end_datetime = start_datetime + timedelta(minutes=duration) + # # Calculate end datetime based on interview duration + # duration = interview.zoom_meeting.duration if interview.zoom_meeting else 60 + # end_datetime = start_datetime + timedelta(minutes=duration) - # Determine event color based on status - color = "#00636e" # Default color - if interview.status == "confirmed": - color = "#00a86b" # Green for confirmed - elif interview.status == "cancelled": - color = "#e74c3c" # Red for cancelled - elif interview.status == "completed": - color = "#95a5a6" # Gray for completed + # # Determine event color based on status + # color = "#00636e" # Default color + # if interview.status == "confirmed": + # color = "#00a86b" # Green for confirmed + # elif interview.status == "cancelled": + # color = "#e74c3c" # Red for cancelled + # elif interview.status == "completed": + # color = "#95a5a6" # Gray for completed - events.append( - { - "title": f"Interview: {interview.candidate.name}", - "start": start_datetime.isoformat(), - "end": end_datetime.isoformat(), - "url": f"{request.path}interview/{interview.id}/", - "color": color, - "extendedProps": { - "candidate": interview.candidate.name, - "email": interview.candidate.email, - "status": interview.status, - "meeting_id": interview.zoom_meeting.meeting_id - if interview.zoom_meeting - else None, - "join_url": interview.zoom_meeting.join_url - if interview.zoom_meeting - else None, - }, - } - ) + # events.append( + # { + # "title": f"Interview: {interview.candidate.name}", + # "start": start_datetime.isoformat(), + # "end": end_datetime.isoformat(), + # "url": f"{request.path}interview/{interview.id}/", + # "color": color, + # "extendedProps": { + # "candidate": interview.candidate.name, + # "email": interview.candidate.email, + # "status": interview.status, + # "meeting_id": interview.zoom_meeting.meeting_id + # if interview.zoom_meeting + # else None, + # "join_url": interview.zoom_meeting.join_url + # if interview.zoom_meeting + # else None, + # }, + # } + # ) - context = { - "job": job, - "events": events, - "calendar_color": "#00636e", - } + # context = { + # "job": job, + # "events": events, + # "calendar_color": "#00636e", + # } - return render(request, "recruitment/interview_calendar.html", context) + # return render(request, "recruitment/interview_calendar.html", context) # @staff_user_required @@ -2878,7 +2953,7 @@ def reschedule_meeting_for_application(request, slug): from django.core.exceptions import ObjectDoesNotExist - +@login_required def user_profile_image_update(request, pk): user = get_object_or_404(User, pk=pk) @@ -2904,7 +2979,7 @@ def user_profile_image_update(request, pk): } return render(request, "user/profile.html", context) - +@login_required def user_detail(request, pk): user = get_object_or_404(User, pk=pk) @@ -2924,6 +2999,8 @@ def user_detail(request, pk): return render(request, "user/profile.html", context) +@login_required +@staff_user_required def easy_logs(request): """ Function-based view to display Django Easy Audit logs with tab switching and pagination. @@ -2971,7 +3048,8 @@ def is_superuser_check(user): return user.is_superuser -@staff_user_required +@login_required +@superuser_required def create_staff_user(request): if request.method == "POST": form = StaffUserCreationForm(request.POST) @@ -2988,13 +3066,15 @@ def create_staff_user(request): return render(request, "user/create_staff.html", {"form": form}) -@staff_user_required +@login_required +@superuser_required def admin_settings(request): staffs = User.objects.filter(user_type="staff",is_superuser=False) form = ToggleAccountForm() context = {"staffs": staffs, "form": form} return render(request, "user/admin_settings.html", context) +@login_required @staff_user_required def staff_assignment_view(request, slug): """ @@ -3028,8 +3108,8 @@ def staff_assignment_view(request, slug): from django.contrib.auth.forms import SetPasswordForm - -@staff_user_required +@login_required +@superuser_required def set_staff_password(request, pk): user = get_object_or_404(User, pk=pk) print(request.POST) @@ -3050,8 +3130,8 @@ def set_staff_password(request, pk): request, "user/staff_password_create.html", {"form": form, "user": user} ) - -@staff_user_required +@login_required +@superuser_required def account_toggle_status(request, pk): user = get_object_or_404(User, pk=pk) if request.method == "POST": @@ -3261,6 +3341,7 @@ def zoom_webhook_view(request): # Hiring Agency CRUD Views +@login_required @staff_user_required def agency_list(request): """List all hiring agencies with search and pagination""" @@ -3291,6 +3372,8 @@ 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""" @@ -3313,6 +3396,31 @@ def agency_create(request): return render(request, "recruitment/agency_form.html", context) + +@login_required +@staff_user_required +def regenerate_agency_password(request, slug): + agency=HiringAgency.objects.get(slug=slug) + new_password=generate_random_password() + agency.generated_password=new_password + agency.save() + user=agency.user + user.set_password(new_password) + user.save() + messages.success(request, f'New password generated for agency "{agency.name}" successfully!') + return redirect("agency_detail", slug=agency.slug) + + +@login_required +@staff_user_required +def deactivate_agency(request, slug): + agency = get_object_or_404(HiringAgency, slug=slug) + agency.is_active = False + agency.save() + messages.success(request, f'Agency "{agency.name}" deactivated successfully!') + return redirect("agency_detail", slug=agency.slug) + +@login_required @staff_user_required def agency_detail(request, slug): """View details of a specific hiring agency""" @@ -3331,6 +3439,7 @@ def agency_detail(request, slug): hired_applications = applications.filter(stage="Hired").count() rejected_applications = applications.filter(stage="Rejected").count() job_assignments=AgencyJobAssignment.objects.filter(agency=agency) + total_job_assignments=job_assignments.count() print(job_assignments) context = { "agency": agency, @@ -3342,11 +3451,12 @@ def agency_detail(request, slug): "generated_password": agency.generated_password if agency.generated_password else None, - "job_assignments":job_assignments + "job_assignments":job_assignments, + "total_job_assignments":total_job_assignments, } return render(request, "recruitment/agency_detail.html", context) - +@login_required @staff_user_required def agency_update(request, slug): """Update an existing hiring agency""" @@ -3371,7 +3481,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""" @@ -3702,7 +3812,7 @@ def agency_delete(request, slug): # } # return render(request, 'recruitment/agency_candidates.html', context) - +@login_required @staff_user_required def agency_applications(request, slug): """View all applications from a specific agency""" @@ -3734,6 +3844,7 @@ def agency_applications(request, slug): # Agency Portal Management Views +@login_required @staff_user_required def agency_assignment_list(request): """List all agency job assignments""" @@ -3747,7 +3858,8 @@ def agency_assignment_list(request): if search_query: assignments = assignments.filter( Q(agency__name__icontains=search_query) - | Q(job__title__icontains=search_query) + | Q(job__title__icontains=search_query)| + Q(agency__contact_person__icontains=search_query) ) if status_filter: @@ -3767,6 +3879,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""" @@ -3805,6 +3918,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""" @@ -3841,7 +3955,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""" @@ -3866,7 +3980,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""" @@ -3894,6 +4008,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""" @@ -3909,7 +4024,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""" @@ -4109,6 +4224,7 @@ def applicant_portal_dashboard(request): @login_required +@candidate_user_required def applicant_application_detail(request, slug): """View detailed information about a specific application""" if not request.user.is_authenticated: @@ -4161,6 +4277,7 @@ def applicant_application_detail(request, slug): return render(request, "recruitment/applicant_application_detail.html", context) +@login_required @agency_user_required def agency_portal_persons_list(request): """Agency portal page showing all persons who come through this agency""" @@ -4216,6 +4333,7 @@ def agency_portal_persons_list(request): return render(request, "recruitment/agency_portal_persons_list.html", context) +@login_required @agency_user_required def agency_portal_dashboard(request): """Agency portal dashboard showing all assignments for the agency""" @@ -4273,6 +4391,7 @@ def agency_portal_dashboard(request): return render(request, "recruitment/agency_portal_dashboard.html", context) +@login_required @agency_user_required def agency_portal_submit_application_page(request, slug): """Dedicated page for submitting a application """ @@ -4339,7 +4458,7 @@ def agency_portal_submit_application_page(request, slug): } return render(request, "recruitment/agency_portal_submit_application.html", context) - +@login_required @agency_user_required def agency_portal_submit_application(request): """Handle candidate submission via AJAX (for embedded form)""" @@ -4404,6 +4523,8 @@ def agency_portal_submit_application(request): return render(request, "recruitment/agency_portal_submit_application.html", context) +@login_required +@staff_or_agency_required def agency_portal_assignment_detail(request, slug): """View details of a specific assignment - routes to admin or agency template""" assignment = get_object_or_404( @@ -4424,6 +4545,7 @@ def agency_portal_assignment_detail(request, slug): return redirect("portal_login") +@login_required @agency_user_required def agency_assignment_detail_agency(request, slug, assignment_id): """Handle agency portal assignment detail view""" @@ -4486,6 +4608,7 @@ def agency_assignment_detail_agency(request, slug, assignment_id): return render(request, "recruitment/agency_portal_assignment_detail.html", context) +@login_required @staff_user_required def agency_assignment_detail_admin(request, slug): """Handle admin assignment detail view""" @@ -4515,6 +4638,7 @@ def agency_assignment_detail_admin(request, slug): #will check the changes application to appliaction in this function +@login_required @agency_user_required def agency_portal_edit_application(request, candidate_id): """Edit a candidate for agency portal""" @@ -4576,6 +4700,7 @@ def agency_portal_edit_application(request, candidate_id): return redirect("agency_portal_dashboard") +@login_required @agency_user_required def agency_portal_delete_application(request, candidate_id): """Delete a candidate for agency portal""" @@ -4614,7 +4739,7 @@ def agency_portal_delete_application(request, candidate_id): # Message Views - +@login_required def message_list(request): """List all messages for the current user""" # Get filter parameters @@ -4706,10 +4831,14 @@ def message_create(request): # Send email if message_type is 'email' and recipient has email if message.recipient and message.recipient.email: + if request.user.user_type != "staff": + message=message.content + else: + message=message.content.append(f"\n\n Sent by: {request.user.get_full_name()} ({request.user.email})") try: email_result = async_task('recruitment.tasks._task_send_individual_email', subject=message.subject, - body_message=message.content, + body_message=message, recipient=message.recipient.email, attachments=None, sender=False, @@ -5247,6 +5376,7 @@ def portal_logout(request): # Interview Creation Views +@login_required @staff_user_required def interview_create_type_selection(request, application_slug): """Show interview type selection page for a application""" @@ -5259,6 +5389,7 @@ def interview_create_type_selection(request, application_slug): return render(request, 'interviews/interview_create_type_selection.html', context) +@login_required @staff_user_required def interview_create_remote(request, application_slug): """Create remote interview for a candidate""" @@ -5291,6 +5422,7 @@ def interview_create_remote(request, application_slug): return render(request, 'interviews/interview_create_remote.html', context) +@login_required @staff_user_required def interview_create_onsite(request, application_slug): """Create onsite interview for a candidate""" @@ -5329,6 +5461,8 @@ def interview_create_onsite(request, application_slug): return render(request, 'interviews/interview_create_onsite.html', context) +@login_required +@staff_user_required def get_interview_list(request, job_slug): from .forms import ScheduledInterviewUpdateStatusForm application = Application.objects.get(slug=job_slug) @@ -5336,6 +5470,9 @@ def get_interview_list(request, job_slug): interview_status_form = ScheduledInterviewUpdateStatusForm() return render(request, 'interviews/partials/interview_list.html', {'interviews': interviews, 'application': application,'interview_status_form':interview_status_form}) + +@login_required +@staff_user_required @require_POST def update_interview_status(request,slug): from .forms import ScheduledInterviewUpdateStatusForm @@ -5349,22 +5486,59 @@ def update_interview_status(request,slug): messages.success(request, "Interview status updated successfully.") return redirect('interview_detail', slug=slug) -@require_POST -def cancel_interview_for_application(request,slug): - scheduled_interview = get_object_or_404(ScheduledInterview, slug=slug) - if request.method == 'POST': - if scheduled_interview.interview_type == 'REMOTE': - result = delete_zoom_meeting(scheduled_interview.interview.meeting_id) - if result["status"] != "success": - messages.error(request, f"Error cancelling Zoom meeting: {result.get('message', 'Unknown error')}") - return redirect('interview_detail', slug=slug) +# @require_POST +# def cancel_interview_for_application(request,slug): +# scheduled_interview = get_object_or_404(ScheduledInterview, slug=slug) +# if request.method == 'POST': +# if scheduled_interview.interview_type == 'REMOTE': +# result = delete_zoom_meeting(scheduled_interview.interview.meeting_id) +# if result["status"] != "success": +# messages.error(request, f"Error cancelling Zoom meeting: {result.get('message', 'Unknown error')}") +# return redirect('interview_detail', slug=slug) - scheduled_interview.delete() - messages.success(request, "Interview cancelled successfully.") - return redirect('interview_list') +# scheduled_interview.delete() +# messages.success(request, "Interview cancelled successfully.") +# return redirect('interview_list') +@require_POST +@login_required # Assuming this should be protected +@staff_user_required # Assuming only staff can cancel +def cancel_interview_for_application(request, slug): + """ + Handles POST request to cancel an interview, setting the status + and saving the form data (likely a reason for cancellation). + """ + interview = get_object_or_404(Interview, slug=slug) + scheduled_interview = get_object_or_404(ScheduledInterview, interview=interview) + form = InterviewCancelForm(request.POST, instance=interview) + + + + if form.is_valid(): + + interview.status = interview.Status.CANCELLED + scheduled_interview.status = scheduled_interview.InterviewStatus.CANCELLED + scheduled_interview.save(update_fields=['status']) + interview.save(update_fields=['status']) # Saves the new status + + form.save() # Saves form data + + + + messages.success(request, _("Interview cancelled successfully.")) + return redirect('interview_detail', slug=scheduled_interview.slug) + else: + + error_list = [f"{field}: {', '.join(errors)}" for field, errors in form.errors.items()] + error_message = _("Please correct the following errors: ") + " ".join(error_list) + messages.error(request, error_message) + + + return redirect('interview_detail', slug=scheduled_interview.slug) + @login_required +@staff_user_required def agency_access_link_deactivate(request, slug): """Deactivate an agency access link""" access_link = get_object_or_404( @@ -5402,6 +5576,7 @@ def agency_access_link_deactivate(request, slug): @login_required +@staff_user_required def agency_access_link_reactivate(request, slug): """Reactivate an agency access link""" access_link = get_object_or_404( @@ -5438,6 +5613,7 @@ def agency_access_link_reactivate(request, slug): return render(request, "recruitment/agency_access_link_confirm.html", context) + @agency_user_required def api_application_detail(request, candidate_id): """API endpoint to get candidate details for agency portal""" @@ -5475,7 +5651,7 @@ def api_application_detail(request, candidate_id): except Exception as e: return JsonResponse({"success": False, "error": str(e)}) - +@login_required @staff_user_required def compose_application_email(request, job_slug): """Compose email to participants about a candidate""" @@ -5610,7 +5786,7 @@ def compose_application_email(request, job_slug): else: # GET request - show the form - form = CandidateEmailForm(job, candidates) + form = CandidateEmailForm(job, candidates,request) return render( request, @@ -5622,6 +5798,7 @@ def compose_application_email(request, job_slug): # Source CRUD Views +@login_required @staff_user_required def source_list(request): """List all sources with search and pagination""" @@ -5651,6 +5828,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""" @@ -5673,6 +5851,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""" @@ -5700,6 +5879,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""" @@ -5725,6 +5905,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""" @@ -5746,6 +5927,7 @@ def source_delete(request, slug): @login_required +@staff_user_required def source_generate_keys(request, slug): """Generate new API keys for a source""" source = get_object_or_404(Source, slug=slug) @@ -5772,6 +5954,7 @@ def source_generate_keys(request, slug): @login_required +@staff_user_required def source_toggle_status(request, slug): """Toggle active status of a source""" source = get_object_or_404(Source, slug=slug) @@ -5812,9 +5995,12 @@ def application_signup(request, slug): address = form.cleaned_data["address"] # gpa = form.cleaned_data["gpa"] password = form.cleaned_data["password"] + gpa=form.cleaned_data["gpa"] + natiional_id=form.cleaned_data["national_id"] user = User.objects.create_user( - username = email,email=email,first_name=first_name,last_name=last_name,phone=phone,user_type="candidate" + username = email,email=email,first_name=first_name,last_name=last_name,phone=phone,user_type="candidate", + gpa=gpa,natiional_id=natiional_id ) user.set_password(password) user.save() @@ -5853,6 +6039,7 @@ def application_signup(request, slug): # Interview Views +@login_required @staff_user_required def interview_list(request): """List all interviews with filtering and pagination""" @@ -5891,20 +6078,23 @@ def interview_list(request): } return render(request, 'interviews/interview_list.html', context) - +@login_required @staff_user_required def interview_detail(request, slug): """View details of a specific interview""" from .forms import ScheduledInterviewUpdateStatusForm interview = get_object_or_404(ScheduledInterview, slug=slug) + reschedule_form = ScheduledInterviewForm() reschedule_form.initial['topic'] = interview.interview.topic + meeting=interview.interview context = { 'interview': interview, 'reschedule_form':reschedule_form, - 'interview_status_form':ScheduledInterviewUpdateStatusForm() + 'interview_status_form':ScheduledInterviewUpdateStatusForm(), + 'cancel_form':InterviewCancelForm(instance=meeting), } return render(request, 'interviews/interview_detail.html', context) @@ -6588,6 +6778,8 @@ def interview_detail(request, slug): # return redirect('meeting_details', slug=slug) +@login_required +@staff_user_required def application_add_note(request, slug): from .models import Note from .forms import NoteForm @@ -6616,6 +6808,8 @@ def application_add_note(request, slug): notes = Note.objects.filter(application=application).order_by('-created_at') return render(request, 'recruitment/partials/note_form.html', {'form': form,'instance':application,'notes':notes,'url':url}) +@login_required +@staff_user_required def interview_add_note(request, slug): from .models import Note from .forms import NoteForm @@ -6641,3 +6835,25 @@ def interview_add_note(request, slug): form.fields['author'].widget = HiddenInput() return render(request, 'recruitment/partials/note_form.html', {'form': form,'instance':interview,'notes':interview.notes.all()}) + + +# @login_required +# @staff_user_required +# def archieve_application_bank_view(request): +# """View to list all applications in the application bank""" +# applications = Application.objects.filter(stage="Applied").se +# lect_related('person', 'job_posting').all().order_by('-created_at') + +# paginator = Paginator(applications, 20) # Show 20 applications per page +# page_number = request.GET.get('page') +# page_obj = paginator.get_page(page_number) + +# context = { +# 'page_obj': page_obj, +# 'applications': applications, +# } +# return render(request, 'jobs/archieve_applications_bank.html', context) + + + +# In your views.py (or where the application views are defined) diff --git a/recruitment/views_frontend.py b/recruitment/views_frontend.py index 7148b34..03dd3f2 100644 --- a/recruitment/views_frontend.py +++ b/recruitment/views_frontend.py @@ -228,6 +228,8 @@ class ApplicationDeleteView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessa slug_url_kwarg = 'slug' +@login_required +@staff_user_required def retry_scoring_view(request,slug): from django_q.tasks import async_task @@ -302,6 +304,10 @@ def application_update_stage(request, slug): application.save(update_fields=['stage']) messages.success(request,_("application Stage Updated")) return redirect("application_detail",slug=application.slug) + + + + class TrainingListView(LoginRequiredMixin, StaffRequiredMixin, ListView): model = models.TrainingMaterial @@ -755,7 +761,7 @@ STAGE_CONFIG = { 'headers': ['Name', 'Email', 'Phone', 'Application Date', 'Offer Status', 'Offer Date', 'Match Score', 'Years Experience', 'Professional Category'] }, 'hired': { - 'filter': {'offer_status': 'Accepted'}, + 'filter': {'stage': 'Hired'}, 'fields': ['name', 'email', 'phone', 'created_at', 'offer_date', 'ai_score', 'years_experience', 'professional_category', 'join_date'], 'headers': ['Name', 'Email', 'Phone', 'Application Date', 'Hire Date', 'Match Score', 'Years Experience', 'Professional Category', 'Join Date'] } diff --git a/recruitment/views_source.py b/recruitment/views_source.py index 2f60d2e..bdc46d0 100644 --- a/recruitment/views_source.py +++ b/recruitment/views_source.py @@ -10,6 +10,7 @@ import secrets import string from .models import Source, IntegrationLog from .forms import SourceForm, generate_api_key, generate_api_secret +from .decorators import login_required, staff_user_required class SourceListView(LoginRequiredMixin, UserPassesTestMixin, ListView): """List all sources""" @@ -182,6 +183,8 @@ class SourceDeleteView(LoginRequiredMixin, UserPassesTestMixin, DeleteView): messages.success(request, f'Source "{self.object.name}" deleted successfully!') return super().delete(request, *args, **kwargs) +@login_required +@staff_user_required def generate_api_keys_view(request, pk): """Generate new API keys for a specific source""" if not request.user.is_staff: @@ -228,6 +231,8 @@ def generate_api_keys_view(request, pk): return JsonResponse({'error': 'Invalid request method'}, status=405) +@login_required +@staff_user_required def toggle_source_status_view(request, pk): """Toggle the active status of a source""" if not request.user.is_staff: @@ -267,7 +272,7 @@ def toggle_source_status_view(request, pk): # 'is_active': source.is_active, # 'message': f'Source "{source.name}" {status_text} successfully' # }) - +@login_required def copy_to_clipboard_view(request): """HTMX endpoint to copy text to clipboard""" if request.method == 'POST': diff --git a/requirements.tx b/requirements.tx new file mode 100644 index 0000000..6c77b46 --- /dev/null +++ b/requirements.tx @@ -0,0 +1,209 @@ +amqp==5.3.1 +annotated-types==0.7.0 +appdirs==1.4.4 +arrow==1.3.0 +asgiref==3.10.0 +asteval==1.0.6 +astunparse==1.6.3 +attrs==25.3.0 +billiard==4.2.2 +bleach==6.2.0 +blessed==1.22.0 +blinker==1.9.0 +blis==1.3.0 +boto3==1.40.45 +botocore==1.40.45 +bw-migrations==0.2 +bw2data==4.5 +bw2parameters==1.1.0 +bw_processing==1.0 +cached-property==2.0.1 +catalogue==2.0.10 +celery==5.5.3 +certifi==2025.10.5 +cffi==2.0.0 +channels==4.3.1 +chardet==5.2.0 +charset-normalizer==3.4.3 +click==8.3.0 +click-didyoumean==0.3.1 +click-plugins==1.1.1.2 +click-repl==0.3.0 +cloudpathlib==0.22.0 +confection==0.1.5 +constructive_geometries==1.0 +country_converter==1.3.1 +crispy-bootstrap5==2025.6 +cryptography==46.0.2 +cymem==2.0.11 +dataflows-tabulator==1.54.3 +datapackage==1.15.4 +datastar-py==0.6.5 +deepdiff==7.0.1 +Deprecated==1.2.18 +Django==5.2.7 +django-allauth==65.12.1 +django-ckeditor-5==0.2.18 +django-cors-headers==4.9.0 +django-countries==7.6.1 +django-crispy-forms==2.4 +django-easy-audit==1.3.7 +django-encrypted-model-fields==0.6.5 +django-extensions==4.1 +django-filter==25.1 +django-js-asset==3.1.2 +django-picklefield==3.3 +django-q2==1.8.0 +django-template-partials==25.2 +django-unfold==0.67.0 +django-widget-tweaks==1.5.0 +django_celery_results==2.6.0 +djangorestframework==3.16.1 +docopt==0.6.2 +dotenv==0.9.9 +en_core_web_sm @ https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl#sha256=1932429db727d4bff3deed6b34cfc05df17794f4a52eeb26cf8928f7c1a0fb85 +et_xmlfile==2.0.0 +Faker==37.8.0 +filelock==3.19.1 +flexcache==0.3 +flexparser==0.4 +fsspec==2025.9.0 +greenlet==3.2.4 +hf-xet==1.1.10 +huggingface-hub==0.35.3 +idna==3.10 +ijson==3.4.0 +isodate==0.7.2 +Jinja2==3.1.6 +jmespath==1.0.1 +joblib==1.5.2 +jsonlines==4.0.0 +jsonpointer==3.0.0 +jsonschema==4.25.1 +jsonschema-specifications==2025.9.1 +kombu==5.5.4 +langcodes==3.5.0 +language_data==1.3.0 +linear-tsv==1.1.0 +llvmlite==0.45.1 +loguru==0.7.3 +lxml==6.0.2 +marisa-trie==1.3.1 +markdown-it-py==4.0.0 +MarkupSafe==3.0.3 +matrix_utils==0.6.2 +mdurl==0.1.2 +morefs==0.2.2 +mpmath==1.3.0 +mrio-common-metadata==0.2.1 +murmurhash==1.0.13 +networkx==3.5 +numba==0.62.1 +numpy==2.3.3 +nvidia-cublas-cu12==12.8.4.1 +nvidia-cuda-cupti-cu12==12.8.90 +nvidia-cuda-nvrtc-cu12==12.8.93 +nvidia-cuda-runtime-cu12==12.8.90 +nvidia-cudnn-cu12==9.10.2.21 +nvidia-cufft-cu12==11.3.3.83 +nvidia-cufile-cu12==1.13.1.3 +nvidia-curand-cu12==10.3.9.90 +nvidia-cusolver-cu12==11.7.3.90 +nvidia-cusparse-cu12==12.5.8.93 +nvidia-cusparselt-cu12==0.7.1 +nvidia-nccl-cu12==2.27.3 +nvidia-nvjitlink-cu12==12.8.93 +nvidia-nvtx-cu12==12.8.90 +openpyxl==3.1.5 +ordered-set==4.1.0 +packaging==25.0 +pandas==2.3.3 +pdfminer.six==20250506 +pdfplumber==0.11.7 +peewee==3.18.2 +pillow==11.3.0 +Pint==0.25 +platformdirs==4.4.0 +preshed==3.0.10 +prettytable==3.16.0 +prompt_toolkit==3.0.52 +psycopg==3.2.11 +pycparser==2.23 +pydantic==2.11.10 +pydantic-settings==2.11.0 +pydantic_core==2.33.2 +pyecospold==4.0.0 +Pygments==2.19.2 +PyJWT==2.10.1 +PyMuPDF==1.26.4 +pyparsing==3.2.5 +PyPDF2==3.0.1 +pypdfium2==4.30.0 +PyPrind==2.11.3 +pytesseract==0.3.13 +python-dateutil==2.9.0.post0 +python-docx==1.2.0 +python-dotenv==1.1.1 +python-json-logger==3.3.0 +pytz==2025.2 +pyxlsb==1.0.10 +PyYAML==6.0.3 +randonneur==0.6.2 +randonneur_data==0.6.1 +RapidFuzz==3.14.1 +rdflib==7.2.1 +redis==3.5.3 +referencing==0.36.2 +regex==2025.9.18 +requests==2.32.5 +rfc3986==2.0.0 +rich==14.1.0 +rpds-py==0.27.1 +s3transfer==0.14.0 +safetensors==0.6.2 +scikit-learn==1.7.2 +scipy==1.16.2 +sentence-transformers==5.1.1 +setuptools==80.9.0 +shellingham==1.5.4 +six==1.17.0 +smart_open==7.3.1 +snowflake-id==1.0.2 +spacy==3.8.7 +spacy-legacy==3.0.12 +spacy-loggers==1.0.5 +SPARQLWrapper==2.0.0 +sparse==0.17.0 +SQLAlchemy==2.0.43 +sqlparse==0.5.3 +srsly==2.5.1 +stats_arrays==0.7 +structlog==25.4.0 +sympy==1.14.0 +tableschema==1.21.0 +thinc==8.3.6 +threadpoolctl==3.6.0 +tokenizers==0.22.1 +toolz==1.0.0 +torch==2.8.0 +tqdm==4.67.1 +transformers==4.57.0 +triton==3.4.0 +typer==0.19.2 +types-python-dateutil==2.9.0.20251008 +typing-inspection==0.4.2 +typing_extensions==4.15.0 +tzdata==2025.2 +unicodecsv==0.14.1 +urllib3==2.5.0 +vine==5.1.0 +voluptuous==0.15.2 +wasabi==1.1.3 +wcwidth==0.2.14 +weasel==0.4.1 +webencodings==0.5.1 +wheel==0.45.1 +wrapt==1.17.3 +wurst==0.4 +xlrd==2.0.2 +xlsxwriter==3.2.9 diff --git a/requirements.txt b/requirements.txt index 36e09c3..de2bc7a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,8 @@ amqp==5.3.1 annotated-types==0.7.0 -anthropic==0.63.0 -anyio==4.11.0 appdirs==1.4.4 arrow==1.3.0 -asgiref==3.9.2 +asgiref==3.10.0 asteval==1.0.6 astunparse==1.6.3 attrs==25.3.0 @@ -13,8 +11,8 @@ bleach==6.2.0 blessed==1.22.0 blinker==1.9.0 blis==1.3.0 -boto3==1.40.37 -botocore==1.40.37 +boto3==1.40.45 +botocore==1.40.45 bw-migrations==0.2 bw2data==4.5 bw2parameters==1.1.0 @@ -22,7 +20,8 @@ bw_processing==1.0 cached-property==2.0.1 catalogue==2.0.10 celery==5.5.3 -certifi==2025.8.3 +certifi==2025.10.5 +cffi==2.0.0 channels==4.3.1 chardet==5.2.0 charset-normalizer==3.4.3 @@ -35,50 +34,48 @@ confection==0.1.5 constructive_geometries==1.0 country_converter==1.3.1 crispy-bootstrap5==2025.6 +cryptography==46.0.2 cymem==2.0.11 dataflows-tabulator==1.54.3 datapackage==1.15.4 datastar-py==0.6.5 deepdiff==7.0.1 Deprecated==1.2.18 -distro==1.9.0 -Django==5.2.6 -django-allauth==65.11.2 +Django==5.2.7 +django-allauth==65.12.1 django-ckeditor-5==0.2.18 django-cors-headers==4.9.0 django-countries==7.6.1 django-crispy-forms==2.4 -django-easy-audit==1.3.7 +django-easy-audit==1.3.7s django-extensions==4.1 django-filter==25.1 +django-js-asset==3.1.2 django-picklefield==3.3 django-q2==1.8.0 -django-summernote==0.8.20.0 django-template-partials==25.2 -django-unfold==0.66.0 +django-unfold==0.67.0 django-widget-tweaks==1.5.0 django_celery_results==2.6.0 djangorestframework==3.16.1 docopt==0.6.2 +dotenv==0.9.9 en_core_web_sm @ https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl#sha256=1932429db727d4bff3deed6b34cfc05df17794f4a52eeb26cf8928f7c1a0fb85 et_xmlfile==2.0.0 Faker==37.8.0 +filelock==3.19.1 flexcache==0.3 flexparser==0.4 fsspec==2025.9.0 -gpt-po-translator==1.3.2 greenlet==3.2.4 -h11==0.16.0 -httpcore==1.0.9 -httpx==0.28.1 +hf-xet==1.1.10 +huggingface-hub==0.35.3 idna==3.10 ijson==3.4.0 -iniconfig==2.1.0 isodate==0.7.2 -isort==5.13.2 Jinja2==3.1.6 -jiter==0.11.1 jmespath==1.0.1 +joblib==1.5.2 jsonlines==4.0.0 jsonpointer==3.0.0 jsonschema==4.25.1 @@ -87,37 +84,52 @@ kombu==5.5.4 langcodes==3.5.0 language_data==1.3.0 linear-tsv==1.1.0 -llvmlite==0.45.0 +llvmlite==0.45.1 loguru==0.7.3 lxml==6.0.2 marisa-trie==1.3.1 markdown-it-py==4.0.0 -MarkupSafe==3.0.2 +MarkupSafe==3.0.3 matrix_utils==0.6.2 mdurl==0.1.2 morefs==0.2.2 +mpmath==1.3.0 mrio-common-metadata==0.2.1 murmurhash==1.0.13 -numba==0.62.0 +networkx==3.5 +numba==0.62.1 numpy==2.3.3 -openai==1.99.9 +nvidia-cublas-cu12==12.8.4.1 +nvidia-cuda-cupti-cu12==12.8.90 +nvidia-cuda-nvrtc-cu12==12.8.93 +nvidia-cuda-runtime-cu12==12.8.90 +nvidia-cudnn-cu12==9.10.2.21 +nvidia-cufft-cu12==11.3.3.83 +nvidia-cufile-cu12==1.13.1.3 +nvidia-curand-cu12==10.3.9.90 +nvidia-cusolver-cu12==11.7.3.90 +nvidia-cusparse-cu12==12.5.8.93 +nvidia-cusparselt-cu12==0.7.1 +nvidia-nccl-cu12==2.27.3 +nvidia-nvjitlink-cu12==12.8.93 +nvidia-nvtx-cu12==12.8.90 openpyxl==3.1.5 ordered-set==4.1.0 packaging==25.0 -pandas==2.3.2 +pandas==2.3.3 +pdfminer.six==20250506 +pdfplumber==0.11.7 peewee==3.18.2 pillow==11.3.0 Pint==0.25 platformdirs==4.4.0 -pluggy==1.6.0 -polib==1.2.0 preshed==3.0.10 prettytable==3.16.0 prompt_toolkit==3.0.52 -psycopg2-binary==2.9.11 -pycountry==24.6.1 -pydantic==2.11.9 -pydantic-settings==2.10.1 +psycopg==3.2.11 +pycparser==2.23 +pydantic==2.11.10 +pydantic-settings==2.11.0 pydantic_core==2.33.2 pyecospold==4.0.0 Pygments==2.19.2 @@ -125,35 +137,36 @@ PyJWT==2.10.1 PyMuPDF==1.26.4 pyparsing==3.2.5 PyPDF2==3.0.1 +pypdfium2==4.30.0 PyPrind==2.11.3 -pytest==8.3.4 -pytest-django==4.11.1 +pytesseract==0.3.13 python-dateutil==2.9.0.post0 python-docx==1.2.0 -python-dotenv==1.0.1 +python-dotenv==1.1.1 python-json-logger==3.3.0 pytz==2025.2 pyxlsb==1.0.10 -PyYAML==6.0.2 +PyYAML==6.0.3 randonneur==0.6.2 -randonneur_data==0.6 +randonneur_data==0.6.1 RapidFuzz==3.14.1 rdflib==7.2.1 redis==3.5.3 referencing==0.36.2 -requests==2.32.3 -responses==0.25.8 +regex==2025.9.18 +requests==2.32.5 rfc3986==2.0.0 rich==14.1.0 rpds-py==0.27.1 s3transfer==0.14.0 +safetensors==0.6.2 +scikit-learn==1.7.2 scipy==1.16.2 +sentence-transformers==5.1.1 setuptools==80.9.0 -setuptools-scm==8.1.0 shellingham==1.5.4 six==1.17.0 smart_open==7.3.1 -sniffio==1.3.1 snowflake-id==1.0.2 spacy==3.8.7 spacy-legacy==3.0.12 @@ -165,15 +178,19 @@ sqlparse==0.5.3 srsly==2.5.1 stats_arrays==0.7 structlog==25.4.0 +sympy==1.14.0 tableschema==1.21.0 -tenacity==9.0.0 thinc==8.3.6 -tomli==2.2.1 +threadpoolctl==3.6.0 +tokenizers==0.22.1 toolz==1.0.0 +torch==2.8.0 tqdm==4.67.1 +transformers==4.57.0 +triton==3.4.0 typer==0.19.2 types-python-dateutil==2.9.0.20251008 -typing-inspection==0.4.1 +typing-inspection==0.4.2 typing_extensions==4.15.0 tzdata==2025.2 unicodecsv==0.14.1 diff --git a/templates/applicant/application_submit_form.html b/templates/applicant/application_submit_form.html index 26137c6..83320b1 100644 --- a/templates/applicant/application_submit_form.html +++ b/templates/applicant/application_submit_form.html @@ -632,11 +632,15 @@ } break; case 'date': - if (value && !/^\d{4}-(0[1-9]|1[0-2])$/.test(value)) { - state.fieldErrors[field.id] = 'Please select a valid date'; - return false; - } - break; + // Regex for YYYY-MM-DD (ISO standard for output) + const yyyyMmDdRegex = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$/; + + if (value && !yyyyMmDdRegex.test(value)) { + // You might want to update the error message based on the input type + state.fieldErrors[field.id] = 'Please enter a valid date (e.g., YYYY-MM-DD).'; + return false; + } + break; } return true; @@ -1024,7 +1028,7 @@ } else if (field.type === 'date') { const input = document.createElement('input'); - input.type = 'month'; + input.type = 'date'; input.className = 'form-input'; input.placeholder = field.placeholder || 'Select date'; input.id = `field_${field.id}`; @@ -1265,5 +1269,8 @@ // Start the application document.addEventListener('DOMContentLoaded', init); + + + {% endblock content %} \ No newline at end of file diff --git a/templates/base.html b/templates/base.html index eeddbc3..9808e75 100644 --- a/templates/base.html +++ b/templates/base.html @@ -20,6 +20,7 @@ + {% block customCSS %}{% endblock %} @@ -115,6 +116,7 @@ {% endcomment %} + -