diff --git a/NorahUniversity/settings.py b/NorahUniversity/settings.py index 775dc13..ccb1a6a 100644 --- a/NorahUniversity/settings.py +++ b/NorahUniversity/settings.py @@ -67,6 +67,7 @@ INSTALLED_APPS = [ "widget_tweaks", "easyaudit", "secured_fields", + ] @@ -319,7 +320,7 @@ Q_CLUSTER = { "name": "KAAUH_CLUSTER", "workers": 2, "recycle": 500, - "timeout": 120, + "timeout": 360, "max_attempts": 1, "compress": True, "save_limit": 250, @@ -550,4 +551,16 @@ LOGGING = { } -SECURED_FIELDS_KEY="kvaCwxrIMtVRouBH5mzf9g-uelv7XUD840ncAiOXkt4=" \ No newline at end of file +SECURED_FIELDS_KEY="kvaCwxrIMtVRouBH5mzf9g-uelv7XUD840ncAiOXkt4=" + + + +CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": "redis://127.0.0.1:6379/1", + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + } + } +} \ No newline at end of file diff --git a/recruitment/forms.py b/recruitment/forms.py index b58f18c..db7989b 100644 --- a/recruitment/forms.py +++ b/recruitment/forms.py @@ -1889,12 +1889,16 @@ class RemoteInterviewForm(forms.Form): duration = forms.IntegerField( min_value=1, - required=False, + required=True, widget=forms.NumberInput(attrs={ 'class': 'form-control', 'placeholder': 'Duration in minutes' }), - label=_('Duration (minutes)') + label=_('Duration (minutes)'), + error_messages={ + 'required': _('Please enter how long the interview will last.'), + 'min_value': _('Duration must be at least 1 minute.') + } ) @@ -1947,12 +1951,16 @@ class OnsiteInterviewForm(forms.Form): ) duration = forms.IntegerField( min_value=1, - required=False, + required=True, widget=forms.NumberInput(attrs={ 'class': 'form-control', 'placeholder': 'Duration in minutes' }), - label=_('Duration (minutes)') + label=_('Duration (minutes)'), + error_messages={ + 'required': _('Please enter how long the interview will last.'), + 'min_value': _('Duration must be at least 1 minute.') + } ) class ScheduledInterviewForm(forms.Form): @@ -1975,12 +1983,16 @@ class ScheduledInterviewForm(forms.Form): ) duration = forms.IntegerField( min_value=1, - required=False, + required=True, widget=forms.NumberInput(attrs={ 'class': 'form-control', 'placeholder': 'Duration in minutes' }), - label=_('Duration (minutes)') + label=_('Duration (minutes)'), + error_messages={ + 'required': _('Please enter how long the interview will last.'), + 'min_value': _('Duration must be at least 1 minute.') + } ) def clean_start_time(self): @@ -2010,12 +2022,16 @@ class OnsiteScheduleInterviewUpdateForm(forms.Form): ) duration = forms.IntegerField( min_value=1, - required=False, + required=True, widget=forms.NumberInput(attrs={ 'class': 'form-control', 'placeholder': 'Duration in minutes' }), - label=_('Duration (minutes)') + label=_('Duration (minutes)'), + error_messages={ + 'required': _('Please enter how long the interview will last.'), + 'min_value': _('Duration must be at least 1 minute.') + } ) physical_address = forms.CharField( max_length=255, @@ -2204,7 +2220,10 @@ Job: {job.title} if interview.location_type == 'Remote': initial_message += f"Pease join using meeting link {interview.join_url} \n\n" else: - initial_message += "This is an onsite schedule. Please arrive 10 minutes early.\n\n" + initial_message += f""" +Location: {interview.physical_address} +Room No: {interview.room_number} +This is an onsite schedule. Please arrive 10 minutes early.\n\n""" @@ -2212,20 +2231,48 @@ Job: {job.title} -class InterviewResultForm(forms.ModelForm): - class Meta: - model = Interview +# class InterviewResultForm(forms.ModelForm): +# class Meta: +# model = Interview - fields = ['interview_result', 'result_comments'] - widgets = { - 'interview_result': forms.Select(attrs={ - 'class': 'form-select', # Standard Bootstrap class - 'required': 'required' - }), - 'result_comments': forms.Textarea(attrs={ - 'class': 'form-control', - 'rows': 3, - 'placeholder': 'Enter setting value', - 'required': True - }), - } \ No newline at end of file +# fields = ['interview_result', 'result_comments'] +# widgets = { +# 'interview_result': forms.Select(attrs={ +# 'class': 'form-select', # Standard Bootstrap class +# 'required': True +# }), +# 'result_comments': forms.Textarea(attrs={ +# 'class': 'form-control', +# 'rows': 3, +# 'placeholder': 'Enter setting value', +# 'required': True +# }), +# } + + + + + +RESULT_CHOICES = ( + ('passed', 'Passed'), + ('failed', 'Failed'), + ('on_hold', 'On Hold'), +) + +class InterviewResultForm(forms.Form): + + interview_result = forms.ChoiceField( + choices=RESULT_CHOICES, + widget=forms.Select(attrs={ + 'class': 'form-select', + }) + ) + + + result_comments = forms.CharField( + widget=forms.Textarea(attrs={ + 'class': 'form-control', + 'rows': 3, + 'placeholder': 'Enter result comment', + }) + ) \ No newline at end of file diff --git a/recruitment/middleware.py b/recruitment/middleware.py new file mode 100644 index 0000000..139597f --- /dev/null +++ b/recruitment/middleware.py @@ -0,0 +1,2 @@ + + diff --git a/recruitment/services/ollama_service.py b/recruitment/services/ollama_service.py new file mode 100644 index 0000000..62f1f2b --- /dev/null +++ b/recruitment/services/ollama_service.py @@ -0,0 +1,44 @@ +import ollama +import re +# def clean_json_response(raw_string): +# """ +# Removes Markdown code blocks and extra whitespace from AI responses. +# """ +# # Use regex to find content between ```json and ``` or just ``` +# match = re.search(r'```(?:json)?\s*([\s\S]*?)\s*```', raw_string) +# if match: +# return match.group(1).strip() +# return raw_string.strip() + +import json +import re + +def robust_json_parser(raw_output): + # 1. Strip Markdown blocks + clean = re.sub(r'```(?:json)?|```', '', raw_output).strip() + + # 2. Fix trailing commas before closing braces/brackets + clean = re.sub(r',\s*([\]}])', r'\1', clean) + + try: + return json.loads(clean) + except json.JSONDecodeError: + # 3. Last resort: try to find the first '{' and last '}' + start_idx = clean.find('{') + end_idx = clean.rfind('}') + if start_idx != -1 and end_idx != -1: + try: + return json.loads(clean[start_idx:end_idx+1]) + except: + pass + raise + +def get_model_reponse(prompt): + response=ollama.chat( + model='alibayram/smollm3:latest', + messages=[{'role': 'user', 'content': prompt}], + stream=False # Set to True for real-time streaming + ) + # print(response['message']['content']) + response=robust_json_parser(response['message']['content']) + return response diff --git a/recruitment/tasks.py b/recruitment/tasks.py index 0d47fea..23f37a3 100644 --- a/recruitment/tasks.py +++ b/recruitment/tasks.py @@ -1654,9 +1654,13 @@ def generate_interview_questions(schedule_id: int) -> dict: if not questions: return {"status": "error", "message": "No questions generated"} - - schedule.interview_questions.update(questions) + + if schedule.interview_questions is None: + schedule.interview_questions={} + + schedule.interview_questions=questions schedule.save(update_fields=["interview_questions"]) + # schedule.save(update_fields=["interview_questions"]) logger.info(f"Successfully generated questions for schedule {schedule_id}") diff --git a/recruitment/views.py b/recruitment/views.py index 5fce1f0..0221b4a 100644 --- a/recruitment/views.py +++ b/recruitment/views.py @@ -246,7 +246,34 @@ class PersonCreateView(CreateView, LoginRequiredMixin, StaffOrAgencyRequiredMixi if view == "job": return redirect("application_create") return super().form_valid(form) + def form_invalid(self, form): + """ + Re-renders the form with error messages while maintaining the UI state. + """ + messages.error(self.request, "There was an error saving the applicant. Please check the details below.") + + # Optional: Add specific field errors as messages + for field, errors in form.errors.items(): + for error in errors: + messages.error(self.request, f"{field.title()}: {error}") + view = self.request.POST.get("view") + agency_slug = self.request.POST.get("agency") + + + context = self.get_context_data(form=form) + + + context['view_type'] = view + context['agency_slug'] = agency_slug + + if view == "portal": + + return redirect('agency_portal_dashboard') + + + return self.render_to_response(context) + class PersonDetailView(DetailView, LoginRequiredMixin, StaffRequiredMixin): model = Person @@ -310,7 +337,7 @@ def create_job(request): logger.error(f"Error creating job: {e}") messages.error(request, f"Error creating job: {e}") else: - messages.error(request, f"Please correct the errors below.{form.errors}") + messages.error(request, f"Please correct the errors below.") else: form = JobPostingForm() return render(request, "jobs/create_job.html", {"form": form}) @@ -4149,6 +4176,8 @@ def interview_create_onsite(request, application_slug): form.initial["topic"] = ( f"Interview for {application.job.title} - {application.name}" ) + messages.error(request, "Please fix the highlighted errors below.") + form = OnsiteInterviewForm() form.initial["topic"] = ( @@ -4253,14 +4282,15 @@ def cancel_interview_for_application(request, slug): def update_interview_result(request,slug): interview = get_object_or_404(Interview,slug=slug) schedule=interview.scheduled_interview - form = InterviewResultForm(request.POST, instance=interview) + form = InterviewResultForm(request.POST) if form.is_valid(): - + interview_result=form.cleaned_data.get("interview_result") + result_comments=form.cleaned_data.get("result_comments") + interview.interview_result=interview_result + interview.result_comments=result_comments interview.save(update_fields=['interview_result', 'result_comments']) - form.save() # Saves form data - messages.success(request, _(f"Interview result updated successfully to {interview.interview_result}.")) return redirect("interview_detail", slug=schedule.slug) else: @@ -4765,10 +4795,14 @@ def interview_list(request): "search_query": search_query, "interviews": page_obj, "jobs": jobs, + "interview_type":interview_type, + } return render(request, "interviews/interview_list.html", context) +from django_ratelimit.decorators import ratelimit +@ratelimit(key='user_or_ip', rate='1/m', block=True) @login_required @staff_user_required def generate_ai_questions(request, slug): @@ -4776,14 +4810,17 @@ def generate_ai_questions(request, slug): from django_q.tasks import async_task schedule = get_object_or_404(ScheduledInterview, slug=slug) + messages.info(request,_("Generating interview questions.")) if request.method == "POST": # Queue the AI question generation task + task_id = async_task( "recruitment.tasks.generate_interview_questions", schedule.id, - sync=True + sync=False ) + # if request.headers.get("X-Requested-With") == "XMLHttpRequest": # return JsonResponse({ @@ -4830,7 +4867,7 @@ def interview_detail(request, slug): ) schedule = get_object_or_404(ScheduledInterview, slug=slug) interview = schedule.interview - interview_result_form=InterviewResultForm(instance=interview) + interview_result_form=InterviewResultForm() application = schedule.application job = schedule.job if interview.location_type == "Remote": diff --git a/requirements.txt b/requirements.txt index 30147be..a5534dc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -47,12 +47,16 @@ 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.7s +django-easy-audit==1.3.7 django-extensions==4.1 +django-fernet-encrypted-fields==0.3.1 django-filter==25.1 django-js-asset==3.1.2 +django-mathfilters==1.0.0 django-picklefield==3.3 django-q2==1.8.0 +django-ratelimit==4.1.0 +django-secured-fields==0.4.4 django-template-partials==25.2 django-unfold==0.67.0 django-widget-tweaks==1.5.0 @@ -136,6 +140,7 @@ Pygments==2.19.2 PyJWT==2.10.1 PyMuPDF==1.26.4 pyparsing==3.2.5 +pypdf==6.4.2 PyPDF2==3.0.1 pypdfium2==4.30.0 PyPrind==2.11.3 @@ -206,9 +211,3 @@ wrapt==1.17.3 wurst==0.4 xlrd==2.0.2 xlsxwriter==3.2.9 -locust==2.32.0 -psutil==6.1.0 -matplotlib==3.9.2 -pandas==2.3.2 -faker==37.8.0 -requests==2.32.3 diff --git a/templates/applicant/partials/candidate_facing_base.html b/templates/applicant/partials/candidate_facing_base.html index 9d85b52..ac55cf2 100644 --- a/templates/applicant/partials/candidate_facing_base.html +++ b/templates/applicant/partials/candidate_facing_base.html @@ -107,7 +107,7 @@ } .dropdown-menu .dropdown-item:hover { - background-color: var(--kaauh-teal); + border-color: var(--kaauh-teal); color: white; transform: translateX(4px); } @@ -369,7 +369,7 @@ {% if user.profile_image %} {{ user.username }} {% else %}
@@ -388,11 +388,11 @@
{% if user.profile_image %} {{ user.username }} {% else %}
+ style="width: 44px; height: 44px; font-size: 1.2rem;"> {{ user.username|first|upper }}
{% endif %} diff --git a/templates/base.html b/templates/base.html index a2bbb1e..4eb2044 100644 --- a/templates/base.html +++ b/templates/base.html @@ -459,7 +459,7 @@ }); } - form_loader(); + //form_loader(); try{ document.body.addEventListener('htmx:afterRequest', function(evt) { diff --git a/templates/forms/document_form.html b/templates/forms/document_form.html index 4eee056..a8b2f6b 100644 --- a/templates/forms/document_form.html +++ b/templates/forms/document_form.html @@ -1,5 +1,5 @@ {% load i18n %} -
+ {% csrf_token %}
-
-
-
- - - -
-
- - +
+
+
+
+ +
+ + {% include 'includes/search_form.html' %} + +
+
+ +
+
+ {# Keep search query context when filtering #} + {% if request.GET.search %}{% endif %} + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + {% if request.GET.search or request.GET.job or request.GET.status or request.GET.type %} + + {% trans 'Clear Filter' %} + + {% endif %} +
+
+
-
- - -
-
- - -
-
- - - {% trans "Clear" %} - -
- +
+
{% if interviews %}
diff --git a/templates/people/person_list.html b/templates/people/person_list.html index 56c8a82..8659c96 100644 --- a/templates/people/person_list.html +++ b/templates/people/person_list.html @@ -201,9 +201,9 @@ - {% if request.GET.q or request.GET.nationality or request.GET.gender %} + {% if search_query or nationality %} - {% trans "Clear" %} + {% trans "Clear Filter" %} {% endif %}
diff --git a/templates/recruitment/agency_assignment_list.html b/templates/recruitment/agency_assignment_list.html index 956b6b4..649a87d 100644 --- a/templates/recruitment/agency_assignment_list.html +++ b/templates/recruitment/agency_assignment_list.html @@ -71,18 +71,24 @@
-
-
-
- - + +
+ +
+ + + +
+
-
+
- @@ -91,15 +97,21 @@
-
-
- - -
-
+
+
+ + {% if status_filter or search_query %} + + {% trans "Clear Filter" %} + + {% endif %} +
+
+ +
diff --git a/templates/recruitment/agency_detail.html b/templates/recruitment/agency_detail.html index 6e95740..f6706c9 100644 --- a/templates/recruitment/agency_detail.html +++ b/templates/recruitment/agency_detail.html @@ -584,6 +584,7 @@ {% if application.phone %} {{ application.phone }} {% endif %} + {{ application.job.title}}
diff --git a/templates/recruitment/applications_document_review_view.html b/templates/recruitment/applications_document_review_view.html index 3c8d7d1..5993b23 100644 --- a/templates/recruitment/applications_document_review_view.html +++ b/templates/recruitment/applications_document_review_view.html @@ -271,12 +271,13 @@ - + + {% if job_filter or stage_filter or search_query %} - {% trans "Clear" %} + {% trans "Clear Filter" %} {% endif %}