diff --git a/NorahUniversity/settings.py b/NorahUniversity/settings.py index 8da31e5..5662002 100644 --- a/NorahUniversity/settings.py +++ b/NorahUniversity/settings.py @@ -62,6 +62,7 @@ INSTALLED_APPS = [ "django_q", "widget_tweaks", "easyaudit", + "encrypted_model_fields", ] @@ -491,4 +492,46 @@ 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": "{", + }, + } +} + + +FIELD_ENCRYPTION_KEY="PWQimxxcDjlRsSSof2gaj42a3frmrLt2xgCTa4R06pE=" \ No newline at end of file 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..fe5829f 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,6 +282,8 @@ 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}), } class ApplicationForm(forms.ModelForm): @@ -790,11 +793,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""" @@ -2292,18 +2320,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 +2907,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..ca93ff6 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-07 13:15 import django.contrib.auth.models import django.contrib.auth.validators @@ -8,6 +8,7 @@ import django.utils.timezone import django_ckeditor_5.fields import django_countries.fields import django_extensions.db.fields +import encrypted_model_fields.fields import recruitment.validators from django.conf import settings from django.db import migrations, models @@ -73,6 +74,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,10 +153,10 @@ 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', encrypted_model_fields.fields.EncryptedCharField(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)), + ('email', encrypted_model_fields.fields.EncryptedEmailField(error_messages={'unique': 'A user with this email already exists.'}, unique=True)), ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), ], @@ -484,13 +487,14 @@ class Migration(migrations.Migration): ('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, max_length=254, unique=True, verbose_name='Email')), - ('phone', models.CharField(blank=True, max_length=20, null=True, verbose_name='Phone')), + ('email', encrypted_model_fields.fields.EncryptedEmailField(db_index=True, unique=True, verbose_name='Email')), + ('phone', encrypted_model_fields.fields.EncryptedPositiveIntegerField(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', encrypted_model_fields.fields.EncryptedPositiveIntegerField(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')), + ('address', encrypted_model_fields.fields.EncryptedCharField(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')), ('agency', 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/0002_alter_person_address.py b/recruitment/migrations/0002_alter_person_address.py new file mode 100644 index 0000000..652cf32 --- /dev/null +++ b/recruitment/migrations/0002_alter_person_address.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.7 on 2025-12-07 13:38 + +import encrypted_model_fields.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='person', + name='address', + field=encrypted_model_fields.fields.EncryptedTextField(blank=True, null=True, verbose_name='Address'), + ), + ] diff --git a/recruitment/migrations/0003_alter_person_national_id_alter_person_phone.py b/recruitment/migrations/0003_alter_person_national_id_alter_person_phone.py new file mode 100644 index 0000000..059c9f8 --- /dev/null +++ b/recruitment/migrations/0003_alter_person_national_id_alter_person_phone.py @@ -0,0 +1,24 @@ +# Generated by Django 5.2.7 on 2025-12-07 13:43 + +import encrypted_model_fields.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0002_alter_person_address'), + ] + + operations = [ + migrations.AlterField( + model_name='person', + name='national_id', + field=encrypted_model_fields.fields.EncryptedCharField(help_text='Enter the national id or iqama number'), + ), + migrations.AlterField( + model_name='person', + name='phone', + field=encrypted_model_fields.fields.EncryptedCharField(blank=True, null=True, verbose_name='Phone'), + ), + ] diff --git a/recruitment/migrations/0004_alter_formsubmission_applicant_email_and_more.py b/recruitment/migrations/0004_alter_formsubmission_applicant_email_and_more.py new file mode 100644 index 0000000..b0fc78d --- /dev/null +++ b/recruitment/migrations/0004_alter_formsubmission_applicant_email_and_more.py @@ -0,0 +1,39 @@ +# Generated by Django 5.2.7 on 2025-12-07 13:59 + +import encrypted_model_fields.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0003_alter_person_national_id_alter_person_phone'), + ] + + operations = [ + migrations.AlterField( + model_name='formsubmission', + name='applicant_email', + field=encrypted_model_fields.fields.EncryptedEmailField(blank=True, db_index=True, help_text='Email of the applicant'), + ), + migrations.AlterField( + model_name='hiringagency', + name='email', + field=encrypted_model_fields.fields.EncryptedEmailField(blank=True), + ), + migrations.AlterField( + model_name='hiringagency', + name='phone', + field=encrypted_model_fields.fields.EncryptedCharField(blank=True), + ), + migrations.AlterField( + model_name='participants', + name='email', + field=encrypted_model_fields.fields.EncryptedEmailField(verbose_name='Email'), + ), + migrations.AlterField( + model_name='participants', + name='phone', + field=encrypted_model_fields.fields.EncryptedCharField(blank=True, null=True, verbose_name='Phone Number'), + ), + ] diff --git a/recruitment/models.py b/recruitment/models.py index 1da11ba..27c0181 100644 --- a/recruitment/models.py +++ b/recruitment/models.py @@ -18,9 +18,10 @@ 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 +from encrypted_model_fields.fields import EncryptedCharField,EncryptedEmailField,EncryptedTextField class EmailContent(models.Model): subject = models.CharField(max_length=255, verbose_name=_("Subject")) @@ -46,8 +47,8 @@ class CustomUser(AbstractUser): user_type = models.CharField( 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") + phone = EncryptedCharField( + blank=True, null=True, verbose_name=_("Phone") ) profile_image = models.ImageField( null=True, @@ -59,17 +60,26 @@ class CustomUser(AbstractUser): designation = models.CharField( max_length=100, blank=True, null=True, verbose_name=_("Designation") ) - email = models.EmailField( + email = EncryptedEmailField( unique=True, error_messages={ "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() @@ -507,13 +517,13 @@ class Person(Base): middle_name = models.CharField( max_length=255, blank=True, null=True, verbose_name=_("Middle Name") ) - email = models.EmailField( + email = EncryptedEmailField( unique=True, db_index=True, verbose_name=_("Email"), ) - phone = models.CharField( - max_length=20, blank=True, null=True, verbose_name=_("Phone") + phone = EncryptedCharField( + blank=True, null=True, verbose_name=_("Phone") ) date_of_birth = models.DateField( null=True, blank=True, verbose_name=_("Date of Birth") @@ -526,10 +536,13 @@ 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 = EncryptedCharField( + 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")) + address = EncryptedTextField(blank=True, null=True, verbose_name=_("Address")) # Optional linking to user account user = models.OneToOneField( @@ -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( @@ -1775,7 +1790,7 @@ class FormSubmission(Base): applicant_name = models.CharField( max_length=200, blank=True, help_text="Name of the applicant" ) - applicant_email = models.EmailField( + applicant_email = EncryptedEmailField( db_index=True, blank=True, help_text="Email of the applicant" ) # Added index @@ -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 = EncryptedEmailField(blank=True) + phone = EncryptedCharField(max_length=20, blank=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,8 +2503,8 @@ class Participants(Base): name = models.CharField( max_length=255, verbose_name=_("Participant Name"), null=True, blank=True ) - email = models.EmailField(verbose_name=_("Email")) - phone = models.CharField( + email =EncryptedEmailField(verbose_name=_("Email")) + phone = EncryptedCharField( max_length=12, verbose_name=_("Phone Number"), null=True, blank=True ) designation = models.CharField( diff --git a/recruitment/signals.py b/recruitment/signals.py index 8a6559a..3745585 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: 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..f856e7c 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,12 @@ logger = logging.getLogger(__name__) User = get_user_model() +@login_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 +204,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,13 +228,13 @@ 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 @@ -235,7 +246,7 @@ class PersonUpdateView( UpdateView): 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 +444,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 +495,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 +720,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 +745,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 +997,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 +1025,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 +1088,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 +1244,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 +1300,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 +1366,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 +1494,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 +1529,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 +1567,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 +1690,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 +1804,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 +1818,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 +1827,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 +1910,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 +1921,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 +1938,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 +1958,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 +1977,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 +2057,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 +2070,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 +2097,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 +2211,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 +2951,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 +2977,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 +2997,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 +3046,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 +3064,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 +3106,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 +3128,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 +3339,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 +3370,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 +3394,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 +3437,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 +3449,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 +3479,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 +3810,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 +3842,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 +3856,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 +3877,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 +3916,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 +3953,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 +3978,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 +4006,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 +4022,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 +4222,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 +4275,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 +4331,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 +4389,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 +4456,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 +4521,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 +4543,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 +4606,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 +4636,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 +4698,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 +4737,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 +4829,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 +5374,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 +5387,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 +5420,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 +5459,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 +5468,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 +5484,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 +5574,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 +5611,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 +5649,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 +5784,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 +5796,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 +5826,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 +5849,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 +5877,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 +5903,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 +5925,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 +5952,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) @@ -5853,6 +6034,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 +6073,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 +6773,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 +6803,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 +6830,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..6c77b46 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,49 @@ 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-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-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 +85,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 +138,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 +179,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 %} + -