diff --git a/.env b/.env index b9e2bf0..8d7fbd5 100644 --- a/.env +++ b/.env @@ -1,3 +1,3 @@ -DB_NAME=norahuniversity -DB_USER=norahuniversity -DB_PASSWORD=norahuniversity \ No newline at end of file +DB_NAME=haikal_db +DB_USER=faheed +DB_PASSWORD=Faheed@215 \ No newline at end of file diff --git a/NorahUniversity/settings.py b/NorahUniversity/settings.py index ca20ac3..702acec 100644 --- a/NorahUniversity/settings.py +++ b/NorahUniversity/settings.py @@ -298,7 +298,7 @@ Q_CLUSTER = { "name": "KAAUH_CLUSTER", "workers": 2, "recycle": 500, - "timeout": 60, + "timeout": 120, "max_attempts": 1, "compress": True, "save_limit": 250, diff --git a/NorahUniversity/urls.py b/NorahUniversity/urls.py index ae9308a..2b03c22 100644 --- a/NorahUniversity/urls.py +++ b/NorahUniversity/urls.py @@ -25,8 +25,8 @@ urlpatterns = [ path('application//', views.application_submit_form, name='application_submit_form'), path('application//submit/', views.application_submit, name='application_submit'), - path('application//apply/', views.application_detail, name='application_detail'), - path('application//signup/', views.candidate_signup, name='candidate_signup'), + path('application//apply/', views.job_application_detail, name='job_application_detail'), + path('application//signup/', views.application_signup, name='application_signup'), path('application//success/', views.application_success, name='application_success'), # path('application/applicant/profile', views.applicant_profile, name='applicant_profile'), diff --git a/debug_test.py b/debug_test.py index 5ed93bc..9f16df8 100644 --- a/debug_test.py +++ b/debug_test.py @@ -79,10 +79,10 @@ def debug_url_routing(): print(f"Error with document_upload URL: {e}") try: - url2 = reverse('candidate_document_upload', kwargs={'slug': application.slug}) - print(f"URL pattern 2 (candidate_document_upload): {url2}") + url2 = reverse('pplication_document_upload', kwargs={'slug': application.slug}) + print(f"URL pattern 2 (pplication_document_upload): {url2}") except Exception as e: - print(f"Error with candidate_document_upload URL: {e}") + print(f"Error with pplication_document_upload URL: {e}") # Test GET request to see if the URL is accessible try: diff --git a/recruitment/backends.py b/recruitment/backends.py index 8870012..9a8fcb2 100644 --- a/recruitment/backends.py +++ b/recruitment/backends.py @@ -24,7 +24,7 @@ class CustomAuthenticationBackend(AuthenticationBackend): elif user.user_type == 'agency': redirect_url = reverse('agency_portal_dashboard') elif user.user_type == 'candidate': - redirect_url = reverse('candidate_portal_dashboard') + redirect_url = reverse('applicant_portal_dashboard') else: # Fallback to default redirect URL if user type is unknown redirect_url = '/' diff --git a/recruitment/candidate_sync_service.py b/recruitment/candidate_sync_service.py index 65a84a3..7e76f33 100644 --- a/recruitment/candidate_sync_service.py +++ b/recruitment/candidate_sync_service.py @@ -35,7 +35,7 @@ class CandidateSyncService: } # Get all hired candidates for this job - hired_candidates = list(job.hired_candidates.select_related('job')) + hired_candidates = list(job.hired_applications.select_related('job')) results['total_candidates'] = len(hired_candidates) diff --git a/recruitment/decorators.py b/recruitment/decorators.py index d232133..7ea41c5 100644 --- a/recruitment/decorators.py +++ b/recruitment/decorators.py @@ -55,7 +55,7 @@ def user_type_required(allowed_types=None, login_url=None): if user.user_type == 'agency': return redirect('agency_portal_dashboard') elif user.user_type == 'candidate': - return redirect('candidate_portal_dashboard') + return redirect('applicant_portal_dashboard') else: return redirect('dashboard') @@ -92,7 +92,7 @@ class UserTypeRequiredMixin(AccessMixin): if request.user.user_type == 'agency': return redirect('agency_portal_dashboard') elif request.user.user_type == 'candidate': - return redirect('candidate_portal_dashboard') + return redirect('applicant_portal_dashboard') else: return redirect('dashboard') diff --git a/recruitment/forms.py b/recruitment/forms.py index 1aa1deb..0438cc3 100644 --- a/recruitment/forms.py +++ b/recruitment/forms.py @@ -293,6 +293,7 @@ class ApplicationForm(forms.ModelForm): "resume", ] labels = { + "person":_("Applicant"), "resume": _("Resume"), "hiring_source": _("Hiring Type"), "hiring_agency": _("Hiring Agency"), @@ -903,7 +904,7 @@ class FormTemplateIsActiveForm(forms.ModelForm): fields = ["is_active"] -class CandidateExamDateForm(forms.ModelForm): +class ApplicationExamDateForm(forms.ModelForm): class Meta: model = Application fields = ["exam_date"] @@ -2344,7 +2345,7 @@ class MessageForm(forms.ModelForm): ) -class CandidateSignupForm(forms.ModelForm): +class ApplicantSignupForm(forms.ModelForm): password = forms.CharField(widget=forms.PasswordInput(attrs={'class': 'form-control'})) confirm_password = forms.CharField(widget=forms.PasswordInput(attrs={'class': 'form-control'})) diff --git a/recruitment/migrations/0007_alter_person_email.py b/recruitment/migrations/0007_alter_person_email.py new file mode 100644 index 0000000..7390323 --- /dev/null +++ b/recruitment/migrations/0007_alter_person_email.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.7 on 2025-11-25 12:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0006_alter_customuser_email'), + ] + + operations = [ + migrations.AlterField( + model_name='person', + name='email', + field=models.EmailField(db_index=True, max_length=254, unique=True, verbose_name='Email'), + ), + ] diff --git a/recruitment/models.py b/recruitment/models.py index 18f5f77..1441182 100644 --- a/recruitment/models.py +++ b/recruitment/models.py @@ -18,7 +18,9 @@ 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.functions import Coalesce, Cast +from django.db.models.fields.json import KeyTransform, KeyTextTransform class CustomUser(AbstractUser): """Custom user model extending AbstractUser""" @@ -130,7 +132,7 @@ class JobPosting(Base): # Application Information ---job detail apply link for the candidates application_url = models.URLField( validators=[URLValidator()], - help_text="URL where candidates apply", + help_text="URL where applicants apply", null=True, blank=True, ) @@ -220,7 +222,7 @@ class JobPosting(Base): related_name="jobs", verbose_name=_("Hiring Agency"), help_text=_( - "External agency responsible for sourcing candidates for this role" + "External agency responsible for sourcing applicants for this role" ), ) cancel_reason = models.TextField( @@ -363,7 +365,7 @@ class JobPosting(Base): @property def current_applications_count(self): - """Returns the current number of candidates associated with this job.""" + """Returns the current number of applications associated with this job.""" return self.applications.count() @property @@ -375,88 +377,90 @@ class JobPosting(Base): return self.current_applications_count >= self.max_applications @property - def all_candidates(self): - return self.applications.annotate( - sortable_score=Coalesce( - Cast( - "ai_analysis_data__analysis_data__match_score", - output_field=IntegerField(), + def all_applications(self): + # 1. Define the safe JSON extraction and conversion expression + safe_score_expression = Cast( + Coalesce( + # Extract the score explicitly as a text string (KeyTextTransform) + KeyTextTransform( + 'match_score', + KeyTransform('analysis_data_en', 'ai_analysis_data') ), - 0, - ) + Value('0'), # Replace SQL NULL (from missing score) with the string '0' + ), + output_field=IntegerField() # Cast the resulting string ('90' or '0') to an integer + ) + + # 2. Annotate the score using the safe expression + return self.applications.annotate( + sortable_score=safe_score_expression ).order_by("-sortable_score") @property - def screening_candidates(self): - return self.all_candidates.filter(stage="Applied") + def screening_applications(self): + return self.all_applications.filter(stage="Applied") @property - def exam_candidates(self): - return self.all_candidates.filter(stage="Exam") + def exam_applications(self): + return self.all_applications.filter(stage="Exam") @property - def interview_candidates(self): - return self.all_candidates.filter(stage="Interview") + def interview_applications(self): + return self.all_applications.filter(stage="Interview") @property - def document_review_candidates(self): - return self.all_candidates.filter(stage="Document Review") + def document_review_applications(self): + return self.all_applications.filter(stage="Document Review") @property - def offer_candidates(self): - return self.all_candidates.filter(stage="Offer") + def offer_applications(self): + return self.all_applications.filter(stage="Offer") @property - def accepted_candidates(self): - return self.all_candidates.filter(offer_status="Accepted") + def accepted_applications(self): + return self.all_applications.filter(offer_status="Accepted") @property - def hired_candidates(self): - return self.all_candidates.filter(stage="Hired") + def hired_applications(self): + return self.all_applications.filter(stage="Hired") # counts @property - def all_candidates_count(self): - return ( - self.candidates.annotate( - sortable_score=Cast( - "ai_analysis_data__match_score", output_field=CharField() - ) - ) - .order_by("-sortable_score") - .count() - or 0 - ) + def all_applications_count(self): + return self.all_applications.count() + @property - def screening_candidates_count(self): - return self.all_candidates.filter(stage="Applied").count() or 0 + def screening_applications_count(self): + return self.all_applications.filter(stage="Applied").count() or 0 @property - def exam_candidates_count(self): - return self.all_candidates.filter(stage="Exam").count() or 0 + def exam_applications_count(self): + return self.all_applications.filter(stage="Exam").count() or 0 @property - def interview_candidates_count(self): - return self.all_candidates.filter(stage="Interview").count() or 0 + def interview_applications_count(self): + return self.all_applications.filter(stage="Interview").count() or 0 @property - def document_review_candidates_count(self): - return self.all_candidates.filter(stage="Document Review").count() or 0 + def document_review_applications_count(self): + return self.all_applications.filter(stage="Document Review").count() or 0 @property - def offer_candidates_count(self): - return self.all_candidates.filter(stage="Offer").count() or 0 + def offer_applications_count(self): + return self.all_applications.filter(stage="Offer").count() or 0 @property - def hired_candidates_count(self): - return self.all_candidates.filter(stage="Hired").count() or 0 + def hired_applications_count(self): + return self.all_applications.filter(stage="Hired").count() or 0 @property def vacancy_fill_rate(self): total_positions = self.open_positions + print(total_positions) - no_of_positions_filled = self.applications.filter(stage__in=["HIRED"]).count() + no_of_positions_filled = self.applications.filter(stage__in=["Hired"]).count() + print(no_of_positions_filled) if total_positions > 0: vacancy_fill_rate = no_of_positions_filled / total_positions @@ -751,88 +755,162 @@ class Application(Base): # ==================================================================== # ✨ PROPERTIES (GETTERS) - Migrated from Candidate # ==================================================================== - @property - def resume_data(self): - return self.ai_analysis_data.get("resume_data", {}) + # @property + # def resume_data(self): + # return self.ai_analysis_data.get("resume_data", {}) + + # @property + # def analysis_data(self): + # return self.ai_analysis_data.get("analysis_data", {}) @property - def analysis_data(self): - return self.ai_analysis_data.get("analysis_data", {}) + def resume_data_en(self): + return self.ai_analysis_data.get("resume_data_en", {}) + + @property + def resume_data_ar(self): + return self.ai_analysis_data.get("resume_data_ar", {}) + + @property + def analysis_data_en(self): + return self.ai_analysis_data.get("analysis_data_en", {}) + @property + def analysis_data_ar(self): + return self.ai_analysis_data.get("analysis_data_ar", {}) + @property def match_score(self) -> int: """1. A score from 0 to 100 representing how well the candidate fits the role.""" - return self.analysis_data.get("match_score", 0) + return self.analysis_data_en.get("match_score", 0) @property def years_of_experience(self) -> float: """4. The total number of years of professional experience as a numerical value.""" - return self.analysis_data.get("years_of_experience", 0.0) + return self.analysis_data_en.get("years_of_experience", 0.0) @property def soft_skills_score(self) -> int: """15. A score (0-100) for inferred non-technical skills.""" - return self.analysis_data.get("soft_skills_score", 0) + return self.analysis_data_en.get("soft_skills_score", 0) @property def industry_match_score(self) -> int: """16. A score (0-100) for the relevance of the candidate's industry experience.""" - return self.analysis_data.get("experience_industry_match", 0) + return self.analysis_data_en.get("experience_industry_match", 0) @property def min_requirements_met(self) -> bool: """14. Boolean (true/false) indicating if all non-negotiable minimum requirements are met.""" - return self.analysis_data.get("min_req_met_bool", False) + return self.analysis_data_en.get("min_req_met_bool", False) @property def screening_stage_rating(self) -> str: """13. A standardized rating (e.g., "A - Highly Qualified", "B - Qualified").""" - return self.analysis_data.get("screening_stage_rating", "N/A") + return self.analysis_data_en.get("screening_stage_rating", "N/A") @property def top_3_keywords(self) -> List[str]: """10. A list of the three most dominant and relevant technical skills or technologies.""" - return self.analysis_data.get("top_3_keywords", []) + return self.analysis_data_en.get("top_3_keywords", []) @property def most_recent_job_title(self) -> str: """8. The candidate's most recent or current professional job title.""" - return self.analysis_data.get("most_recent_job_title", "N/A") + return self.analysis_data_en.get("most_recent_job_title", "N/A") @property def criteria_checklist(self) -> Dict[str, str]: """5 & 6. An object rating the candidate's match for each specific criterion.""" - return self.analysis_data.get("criteria_checklist", {}) + return self.analysis_data_en.get("criteria_checklist", {}) @property def professional_category(self) -> str: """7. The most fitting professional field or category for the individual.""" - return self.analysis_data.get("category", "N/A") + return self.analysis_data_en.get("category", "N/A") @property def language_fluency(self) -> List[Dict[str, str]]: """12. A list of languages and their fluency levels mentioned.""" - return self.analysis_data.get("language_fluency", []) + return self.analysis_data_en.get("language_fluency", []) @property def strengths(self) -> str: """2. A brief summary of why the candidate is a strong fit.""" - return self.analysis_data.get("strengths", "") + return self.analysis_data_en.get("strengths", "") @property def weaknesses(self) -> str: """3. A brief summary of where the candidate falls short or what criteria are missing.""" - return self.analysis_data.get("weaknesses", "") + return self.analysis_data_en.get("weaknesses", "") @property def job_fit_narrative(self) -> str: """11. A single, concise sentence summarizing the core fit.""" - return self.analysis_data.get("job_fit_narrative", "") + return self.analysis_data_en.get("job_fit_narrative", "") @property def recommendation(self) -> str: """9. Provide a detailed final recommendation for the candidate.""" - return self.analysis_data.get("recommendation", "") + return self.analysis_data_en.get("recommendation", "") + + #for arabic + + @property + def min_requirements_met_ar(self) -> bool: + """14. Boolean (true/false) indicating if all non-negotiable minimum requirements are met.""" + return self.analysis_data_ar.get("min_req_met_bool", False) + + @property + def screening_stage_rating_ar(self) -> str: + """13. A standardized rating (e.g., "A - Highly Qualified", "B - Qualified").""" + return self.analysis_data_ar.get("screening_stage_rating", "N/A") + + @property + def top_3_keywords_ar(self) -> List[str]: + """10. A list of the three most dominant and relevant technical skills or technologies.""" + return self.analysis_data_ar.get("top_3_keywords", []) + + @property + def most_recent_job_title_ar(self) -> str: + """8. The candidate's most recent or current professional job title.""" + return self.analysis_data_ar.get("most_recent_job_title", "N/A") + + @property + def criteria_checklist_ar(self) -> Dict[str, str]: + """5 & 6. An object rating the candidate's match for each specific criterion.""" + return self.analysis_data_ar.get("criteria_checklist", {}) + + @property + def professional_category_ar(self) -> str: + """7. The most fitting professional field or category for the individual.""" + return self.analysis_data_ar.get("category", "N/A") + + @property + def language_fluency_ar(self) -> List[Dict[str, str]]: + """12. A list of languages and their fluency levels mentioned.""" + return self.analysis_data_ar.get("language_fluency", []) + + @property + def strengths_ar(self) -> str: + """2. A brief summary of why the candidate is a strong fit.""" + return self.analysis_data_ar.get("strengths", "") + + @property + def weaknesses_ar(self) -> str: + """3. A brief summary of where the candidate falls short or what criteria are missing.""" + return self.analysis_data_ar.get("weaknesses", "") + + @property + def job_fit_narrative_ar(self) -> str: + """11. A single, concise sentence summarizing the core fit.""" + return self.analysis_data_ar.get("job_fit_narrative", "") + + @property + def recommendation_ar(self) -> str: + """9. Provide a detailed final recommendation for the candidate.""" + return self.analysis_data_ar.get("recommendation", "") + # ==================================================================== # 🔄 HELPER METHODS diff --git a/recruitment/score_utils.py b/recruitment/score_utils.py deleted file mode 100644 index a9b457a..0000000 --- a/recruitment/score_utils.py +++ /dev/null @@ -1,36 +0,0 @@ -from django.db.models import Value, IntegerField, CharField, F -from django.db.models.functions import Coalesce, Cast, Replace, NullIf, KeyTextTransform - -# Define the path to the match score -# Based on your tracebacks, the path is: ai_analysis_data -> analysis_data -> match_score -SCORE_PATH_RAW = F('ai_analysis_data__analysis_data__match_score') - -# Define a robust annotation expression for safely extracting and casting the match score. -# This sequence handles three common failure points: -# 1. Missing keys (handled by Coalesce). -# 2. Textual scores (e.g., "N/A" or "") (handled by NullIf). -# 3. Quoted numeric scores (e.g., "50") from JSONB extraction (handled by Replace). -def get_safe_score_annotation(): - """ - Returns a Django Expression object that safely extracts a score from the - JSONField, cleans it, and casts it to an IntegerField. - """ - - # 1. Extract the JSON value as text and force a CharField for cleaning functions - # Using the double-underscore path is equivalent to the KeyTextTransform - # for the final nested key in a PostgreSQL JSONField. - extracted_text = Cast(SCORE_PATH_RAW, output_field=CharField()) - - # 2. Clean up any residual double-quotes that sometimes remain if the data - # was stored as a quoted string (e.g., "50") - cleaned_text = Replace(extracted_text, Value('"'), Value('')) - - # 3. Use NullIf to convert the cleaned text to NULL if it is an empty string - # (or if it was a non-numeric string like "N/A" after quote removal) - null_if_empty = NullIf(cleaned_text, Value('')) - - # 4. Cast the result (which is now either a clean numeric string or NULL) to an IntegerField. - final_cast = Cast(null_if_empty, output_field=IntegerField()) - - # 5. Use Coalesce to ensure NULL scores (from errors or missing data) default to 0. - return Coalesce(final_cast, Value(0)) \ No newline at end of file diff --git a/recruitment/signals.py b/recruitment/signals.py index e567816..8a6559a 100644 --- a/recruitment/signals.py +++ b/recruitment/signals.py @@ -86,7 +86,7 @@ def score_candidate_resume(sender, instance, created, **kwargs): if instance.resume and not instance.is_resume_parsed: logger.info(f"Scoring resume for candidate {instance.pk}") async_task( - "recruitment.tasks.handle_reume_parsing_and_scoring", + "recruitment.tasks.handle_resume_parsing_and_scoring", instance.pk, hook="recruitment.hooks.callback_ai_parsing", ) diff --git a/recruitment/tasks.py b/recruitment/tasks.py index 0217cde..2ec216b 100644 --- a/recruitment/tasks.py +++ b/recruitment/tasks.py @@ -238,14 +238,219 @@ def safe_cast_to_float(value, default=0.0): return default return default -def handle_reume_parsing_and_scoring(pk): +# def handle_resume_parsing_and_scoring(pk): +# """ +# Optimized Django-Q task to parse a resume, score the candidate against a job, +# and atomically save the results. +# """ + +# # --- 1. Robust Object Retrieval (Prevents looping on DoesNotExist) --- +# try: +# instance = Application.objects.get(pk=pk) +# except Application.DoesNotExist: +# # Exit gracefully if the candidate was deleted after the task was queued +# logger.warning(f"Candidate matching query does not exist for pk={pk}. Exiting task.") +# print(f"Candidate matching query does not exist for pk={pk}. Exiting task.") +# return + +# logger.info(f"Scoring resume for candidate {pk}") +# print(f"Scoring resume for candidate {pk}") + +# # --- 2. I/O and Initial Data Check --- +# try: +# file_path = instance.resume.path +# if not os.path.exists(file_path): +# logger.warning(f"Resume file not found: {file_path}") +# print(f"Resume file not found: {file_path}") +# # Consider marking the task as unsuccessful but don't re-queue +# return + +# # Use the new unified document parser +# resume_text = extract_text_from_document(file_path) +# job_detail = f"{instance.job.description} {instance.job.qualifications}" + +# except Exception as e: +# logger.error(f"Error during initial data retrieval/parsing for candidate {instance.pk}: {e}") +# print(f"Error during initial data retrieval/parsing for candidate {instance.pk}: {e}") +# return +# print(resume_text) +# # --- 3. Single, Combined LLM Prompt (Major Cost & Latency Optimization) --- +# prompt = f""" +# You are an expert AI system functioning as both a Resume Parser and a Technical Recruiter. + +# Your task is to: +# 1. **PARSE**: Extract all key-value information from the provided RESUME TEXT into a clean JSON structure under the key 'resume_data', preserving the original text and it's formatting and dont add any extra text. +# 2. **SCORE**: Analyze the parsed data against the JOB CRITERIA and generate a comprehensive score and analysis under the key 'analysis_data'. + +# **JOB CRITERIA:** +# {job_detail} + +# **RESUME TEXT:** +# {resume_text} + +# **STRICT JSON OUTPUT INSTRUCTIONS:** +# Output a single, valid JSON object with ONLY the following two top-level keys: + + +# 1. "resume_data": {{ +# "full_name": "Full name of the candidate", +# "current_title": "Most recent or current job title", +# "location": "City and state", +# "contact": "Phone number and email", +# "linkedin": "LinkedIn profile URL", +# "github": "GitHub or portfolio URL", +# "summary": "Brief professional profile or summary (1–2 sentences)", +# "education": [{{ +# "institution": "Institution name", +# "degree": "Degree name", +# "year": "Year of graduation" (if provided) or '', +# "gpa": "GPA (if provided)", +# "relevant_courses": ["list", "of", "courses"](if provided) or [] +# }}], +# "skills": {{ +# "category_1": ["skill_a", "skill_b"], +# "uncategorized": ["tool_x"] +# }}, +# "experience": [{{ +# "company": "Company name", +# "job_title": "Job Title", +# "location": "Location", +# "start_date": "YYYY-MM", +# "end_date": "YYYY-MM or Present", +# "key_achievements": ["concise bullet points"] (if provided) or [] +# }}], +# "projects": [{{ +# "name": "Project name", +# "year": "Year", +# "technologies_used": ["list", "of", "tech"] (if provided) or [], +# "brief_description": "description" +# }}] +# }} + +# 2. "analysis_data": {{ +# "match_score": "Integer Score 0-100", +# "strengths": "Brief summary of strengths", +# "weaknesses": "Brief summary of weaknesses", +# "years_of_experience": "Total years of experience (float, e.g., 6.5)", +# "criteria_checklist": List of job requirements if any {{ "Python": "Met", "AWS": "Not Met"}} only output the criteria_checklist in one of ('Met','Not Met') don't output any extra text, +# "category": "Most fitting professional field (e.g., Data Science), only output the category name and no other text example ('Software Development', 'correct') , ('Software Development and devops','wrong') ('Software Development / Backend Development','wrong')", +# "most_recent_job_title": "Candidate's most recent job title", +# "recommendation": "Detailed hiring recommendation narrative", +# "top_3_keywords": ["keyword1", "keyword2", "keyword3"], +# "job_fit_narrative": "Single, concise summary sentence", +# "language_fluency": ["language: fluency_level"], +# "screening_stage_rating": "Standardized rating (Highly Qualified, Qualified , Partially Qualified, Not Qualified)", +# "min_req_met_bool": "Boolean (true/false)", +# "soft_skills_score": "Integer Score 0-100 for inferred non-technical skills", +# "experience_industry_match": "Integer Score 0-100 for industry relevance", +# "seniority_level_match": "Integer Score 0-100 for alignment with JD's seniority level", +# "red_flags": ["List of any potential concerns (if any): e.g., 'Employment gap 1 year', 'Frequent job hopping', 'Missing required certification'"], +# "employment_stability_score": "Integer Score 0-100 (Higher is more stable/longer tenure) (if possible)", +# "transferable_skills_narrative": "A brief sentence describing the relevance of non-core experience (if applicable).", +# "cultural_fit_keywords": ["A list of 3-5 keywords extracted from the resume (if possible) (e.g., 'team-player', 'mentored', 'cross-functional')"] +# }} + +# If a top-level key or its required fields are missing, set the field to null, an empty list, or an empty object as appropriate. + +# Output only valid JSON—no markdown, no extra text. +# """ + +# try: +# result = ai_handler(prompt) +# if result['status'] == 'error': +# logger.error(f"AI handler returned error for candidate {instance.pk}") +# print(f"AI handler returned error for candidate {instance.pk}") +# return +# # Ensure the result is parsed as a Python dict (if ai_handler returns a JSON string) +# data = result['data'] + +# if isinstance(data, str): +# data = json.loads(data) +# print(data) + +# # parsed_summary = data.get('parsed_data', {}) +# # scoring_result = data.get('scoring_data', {}) + +# except Exception as e: +# logger.error(f"AI handler failed for candidate {instance.pk}: {e}") +# print(f"AI handler failed for candidate {instance.pk}: {e}") +# return + +# # --- 4. Atomic Database Update (Ensures data integrity) --- +# with transaction.atomic(): + +# # Map JSON keys to model fields with appropriate defaults +# # update_map = { +# # 'match_score': ('match_score', 0), +# # 'years_of_experience': ('years_of_experience', 0.0), +# # 'soft_skills_score': ('soft_skills_score', 0), +# # 'experience_industry_match': ('experience_industry_match', 0), + +# # 'min_req_met_bool': ('min_req_met_bool', False), +# # 'screening_stage_rating': ('screening_stage_rating', 'N/A'), +# # 'most_recent_job_title': ('most_recent_job_title', 'N/A'), +# # 'top_3_keywords': ('top_3_keywords', []), + +# # 'strengths': ('strengths', ''), +# # 'weaknesses': ('weaknesses', ''), +# # 'job_fit_narrative': ('job_fit_narrative', ''), +# # 'recommendation': ('recommendation', ''), + +# # 'criteria_checklist': ('criteria_checklist', {}), +# # 'language_fluency': ('language_fluency', []), +# # 'category': ('category', 'N/A'), +# # } + +# # Apply scoring results to the instance +# # for model_field, (json_key, default_value) in update_map.items(): +# # instance.ai_analysis_data[model_field] = scoring_result.get(json_key, default_value) +# # instance.set_field(model_field, scoring_result.get(json_key, default_value)) +# # instance.set_field("match_score" , int(safe_cast_to_float(scoring_result.get('match_score', 0), default=0))) +# # instance.set_field("years_of_experience" , safe_cast_to_float(scoring_result.get('years_of_experience', 0.0))) +# # instance.set_field("soft_skills_score" , int(safe_cast_to_float(scoring_result.get('soft_skills_score', 0), default=0))) +# # instance.set_field("experience_industry_match" , int(safe_cast_to_float(scoring_result.get('experience_industry_match', 0), default=0))) + +# # # Other Model Fields +# # instance.set_field("min_req_met_bool" , scoring_result.get('min_req_met_bool', False)) +# # instance.set_field("screening_stage_rating" , scoring_result.get('screening_stage_rating', 'N/A')) +# # instance.set_field("category" , scoring_result.get('category', 'N/A')) +# # instance.set_field("most_recent_job_title" , scoring_result.get('most_recent_job_title', 'N/A')) +# # instance.set_field("top_3_keywords" , scoring_result.get('top_3_keywords', [])) +# # instance.set_field("strengths" , scoring_result.get('strengths', '')) +# # instance.set_field("weaknesses" , scoring_result.get('weaknesses', '')) +# # instance.set_field("job_fit_narrative" , scoring_result.get('job_fit_narrative', '')) +# # instance.set_field("recommendation" , scoring_result.get('recommendation', '')) +# # instance.set_field("criteria_checklist" , scoring_result.get('criteria_checklist', {})) +# # instance.set_field("language_fluency" , scoring_result.get('language_fluency', [])) + + +# # 2. Update the Full JSON Field (ai_analysis_data) +# if instance.ai_analysis_data is None: +# instance.ai_analysis_data = {} + +# # Save both structured outputs into the single JSONField for completeness +# instance.ai_analysis_data = data +# # instance.ai_analysis_data['parsed_data'] = parsed_summary +# # instance.ai_analysis_data['scoring_data'] = scoring_result + +# # Apply parsing results +# # instance.parsed_summary = json.dumps(parsed_summary) +# instance.is_resume_parsed = True + +# instance.save(update_fields=['ai_analysis_data', 'is_resume_parsed']) + +# logger.info(f"Successfully scored and saved analysis for candidate {instance.id}") +# print(f"Successfully scored and saved analysis for candidate {instance.id}") + +def handle_resume_parsing_and_scoring(pk: int): """ - Optimized Django-Q task to parse a resume, score the candidate against a job, + Optimized Django-Q task to parse a resume in English and Arabic, score the candidate, and atomically save the results. """ # --- 1. Robust Object Retrieval (Prevents looping on DoesNotExist) --- try: + # NOTE: Replace 'Application.objects.get' with your actual model manager call instance = Application.objects.get(pk=pk) except Application.DoesNotExist: # Exit gracefully if the candidate was deleted after the task was queued @@ -258,11 +463,11 @@ def handle_reume_parsing_and_scoring(pk): # --- 2. I/O and Initial Data Check --- try: + # Assuming instance.resume is a Django FileField file_path = instance.resume.path if not os.path.exists(file_path): logger.warning(f"Resume file not found: {file_path}") print(f"Resume file not found: {file_path}") - # Consider marking the task as unsuccessful but don't re-queue return # Use the new unified document parser @@ -276,11 +481,13 @@ def handle_reume_parsing_and_scoring(pk): print(resume_text) # --- 3. Single, Combined LLM Prompt (Major Cost & Latency Optimization) --- prompt = f""" - You are an expert AI system functioning as both a Resume Parser and a Technical Recruiter. + You are an expert AI system functioning as both a Resume Parser and a Technical Recruiter, capable of multi-language output. Your task is to: - 1. **PARSE**: Extract all key-value information from the provided RESUME TEXT into a clean JSON structure under the key 'resume_data', preserving the original text and it's formatting and dont add any extra text. - 2. **SCORE**: Analyze the parsed data against the JOB CRITERIA and generate a comprehensive score and analysis under the key 'analysis_data'. + 1. **PARSE (English)**: Extract all key-value information from the RESUME TEXT into a clean JSON structure under the key **'resume_data_en'**. + 2. **PARSE (Arabic)**: Translate and output the exact same parsed data structure into Arabic under the key **'resume_data_ar'**. The keys must remain in English, but the values (names, titles, summaries, descriptions) must be in Arabic. + 3. **SCORE (English)**: Analyze the data against the JOB CRITERIA and generate a comprehensive score and analysis under **'analysis_data_en'**, including an English narrative/recommendation. + 4. **SCORE (Arabic)**: Output an identical analysis structure under **'analysis_data_ar'**, but ensure the narrative fields (**recommendation**, **job_fit_narrative**, **strengths**, **weaknesses**, **transferable_skills_narrative**) are translated into Arabic. All numerical and list fields (scores, checklist, keywords) must be identical to the English analysis. **JOB CRITERIA:** {job_detail} @@ -289,10 +496,16 @@ def handle_reume_parsing_and_scoring(pk): {resume_text} **STRICT JSON OUTPUT INSTRUCTIONS:** - Output a single, valid JSON object with ONLY the following two top-level keys: + You MUST output a single, valid JSON object. + This object MUST contain ONLY the following four top-level keys: + 1. "resume_data_en" + 2. "resume_data_ar" + 3. "analysis_data_en" + 4. "analysis_data_ar" + **ABSOLUTELY DO NOT use generic keys like "resume_data" or "analysis_data" at the top level.** - 1. "resume_data": {{ + 1. "resume_data_en": {{ /* English Parsed Data */ "full_name": "Full name of the candidate", "current_title": "Most recent or current job title", "location": "City and state", @@ -327,7 +540,43 @@ def handle_reume_parsing_and_scoring(pk): }}] }} - 2. "analysis_data": {{ + 2. "resume_data_ar": {{ /* Arabic Translated Parsed Data (Keys in English, Values in Arabic) */ + "full_name": "الاسم الكامل للمرشح", + "current_title": "أحدث أو الحالي مسمى وظيفي", + "location": "المدينة والدولة", + "contact": "رقم الهاتف والبريد الإلكتروني", + "linkedin": "رابط ملف LinkedIn الشخصي", + "github": "رابط GitHub أو ملف الأعمال", + "summary": "ملف تعريفي مهني موجز أو ملخص (جملة واحدة أو جملتين)", + "education": [{{ + "institution": "اسم المؤسسة", + "degree": "اسم الدرجة العلمية", + "year": "سنة التخرج (إذا توفرت) أو ''", + "gpa": "المعدل التراكمي (إذا توفر)", + "relevant_courses": ["قائمة", "بالدورات", "ذات", "الصلة"](إذا توفرت) أو [] + }}], + "skills": {{ + "category_1": ["مهارة_أ", "مهارة_ب"], + "uncategorized": ["أداة_س"] + }}, + "experience": [{{ + "company": "اسم الشركة", + "job_title": "المسمى الوظيفي", + "location": "الموقع", + "start_date": "السنة-الشهر (YYYY-MM)", + "end_date": "السنة-الشهر (YYYY-MM) أو Present", + "key_achievements": ["نقاط", "رئيسية", "موجزة", "للإنجازات"] (إذا توفرت) أو [] + }}], + "projects": [{{ + "name": "اسم المشروع", + "year": "السنة", + "technologies_used": ["قائمة", "بالتقنيات", "المستخدمة"] (إذا توفرت) أو [], + "brief_description": "وصف موجز" + }}] + }} + + + 3. "analysis_data_en": {{ /* English Analysis and Narratives */ "match_score": "Integer Score 0-100", "strengths": "Brief summary of strengths", "weaknesses": "Brief summary of weaknesses", @@ -350,26 +599,48 @@ def handle_reume_parsing_and_scoring(pk): "cultural_fit_keywords": ["A list of 3-5 keywords extracted from the resume (if possible) (e.g., 'team-player', 'mentored', 'cross-functional')"] }} + 4. "analysis_data_ar": {{ /* Identical Analysis structure, but with Arabic Translated Narratives */ + "match_score": "Integer Score 0-100", + "strengths": "ملخص موجز لنقاط القوة", + "weaknesses": "ملخص موجز لنقاط الضعف", + "years_of_experience": "Total years of experience (float, e.g., 6.5)", + "criteria_checklist": List of job requirements if any {{ "Python": "Met", "AWS": "Not Met"}} only output the criteria_checklist in one of ('Met','Not Met') don't output any extra text, + "category": "Most fitting professional field (e.g., Data Science), only output the category name and no other text example ('Software Development', 'correct') , ('Software Development and devops','wrong') ('Software Development / Backend Development','wrong')", + "most_recent_job_title": "Candidate's most recent job title", + "recommendation": "سرد تفصيلي بتوصية التوظيف", + "top_3_keywords": ["keyword1", "keyword2", "keyword3"], + "job_fit_narrative": "جملة واحدة موجزة تلخص مدى ملاءمة الوظيفة", + "language_fluency": ["language: fluency_level"], + "screening_stage_rating": "Standardized rating (Highly Qualified, Qualified , Partially Qualified, Not Qualified)", + "min_req_met_bool": "Boolean (true/false)", + "soft_skills_score": "Integer Score 0-100 for inferred non-technical skills", + "experience_industry_match": "Integer Score 0-100 for industry relevance", + "seniority_level_match": "Integer Score 0-100 for alignment with JD's seniority level", + "red_flags": ["List of any potential concerns (if any): e.g., 'Employment gap 1 year', 'Frequent job hopping', 'Missing required certification'"], + "employment_stability_score": "Integer Score 0-100 (Higher is more stable/longer tenure) (if possible)", + "transferable_skills_narrative": "جملة موجزة تصف أهمية الخبرة غير الأساسية (إذا انطبقت).", + "cultural_fit_keywords": ["A list of 3-5 keywords extracted from the resume (if possible) (e.g., 'team-player', 'mentored', 'cross-functional')"] + }} + If a top-level key or its required fields are missing, set the field to null, an empty list, or an empty object as appropriate. Output only valid JSON—no markdown, no extra text. """ try: + # Call the AI handler result = ai_handler(prompt) if result['status'] == 'error': logger.error(f"AI handler returned error for candidate {instance.pk}") print(f"AI handler returned error for candidate {instance.pk}") return - # Ensure the result is parsed as a Python dict (if ai_handler returns a JSON string) + + # Ensure the result is parsed as a Python dict data = result['data'] if isinstance(data, str): data = json.loads(data) print(data) - # parsed_summary = data.get('parsed_data', {}) - # scoring_result = data.get('scoring_data', {}) - except Exception as e: logger.error(f"AI handler failed for candidate {instance.pk}: {e}") print(f"AI handler failed for candidate {instance.pk}: {e}") @@ -377,69 +648,22 @@ def handle_reume_parsing_and_scoring(pk): # --- 4. Atomic Database Update (Ensures data integrity) --- with transaction.atomic(): - - # Map JSON keys to model fields with appropriate defaults - # update_map = { - # 'match_score': ('match_score', 0), - # 'years_of_experience': ('years_of_experience', 0.0), - # 'soft_skills_score': ('soft_skills_score', 0), - # 'experience_industry_match': ('experience_industry_match', 0), - - # 'min_req_met_bool': ('min_req_met_bool', False), - # 'screening_stage_rating': ('screening_stage_rating', 'N/A'), - # 'most_recent_job_title': ('most_recent_job_title', 'N/A'), - # 'top_3_keywords': ('top_3_keywords', []), - - # 'strengths': ('strengths', ''), - # 'weaknesses': ('weaknesses', ''), - # 'job_fit_narrative': ('job_fit_narrative', ''), - # 'recommendation': ('recommendation', ''), - - # 'criteria_checklist': ('criteria_checklist', {}), - # 'language_fluency': ('language_fluency', []), - # 'category': ('category', 'N/A'), - # } - - # Apply scoring results to the instance - # for model_field, (json_key, default_value) in update_map.items(): - # instance.ai_analysis_data[model_field] = scoring_result.get(json_key, default_value) - # instance.set_field(model_field, scoring_result.get(json_key, default_value)) - # instance.set_field("match_score" , int(safe_cast_to_float(scoring_result.get('match_score', 0), default=0))) - # instance.set_field("years_of_experience" , safe_cast_to_float(scoring_result.get('years_of_experience', 0.0))) - # instance.set_field("soft_skills_score" , int(safe_cast_to_float(scoring_result.get('soft_skills_score', 0), default=0))) - # instance.set_field("experience_industry_match" , int(safe_cast_to_float(scoring_result.get('experience_industry_match', 0), default=0))) - - # # Other Model Fields - # instance.set_field("min_req_met_bool" , scoring_result.get('min_req_met_bool', False)) - # instance.set_field("screening_stage_rating" , scoring_result.get('screening_stage_rating', 'N/A')) - # instance.set_field("category" , scoring_result.get('category', 'N/A')) - # instance.set_field("most_recent_job_title" , scoring_result.get('most_recent_job_title', 'N/A')) - # instance.set_field("top_3_keywords" , scoring_result.get('top_3_keywords', [])) - # instance.set_field("strengths" , scoring_result.get('strengths', '')) - # instance.set_field("weaknesses" , scoring_result.get('weaknesses', '')) - # instance.set_field("job_fit_narrative" , scoring_result.get('job_fit_narrative', '')) - # instance.set_field("recommendation" , scoring_result.get('recommendation', '')) - # instance.set_field("criteria_checklist" , scoring_result.get('criteria_checklist', {})) - # instance.set_field("language_fluency" , scoring_result.get('language_fluency', [])) - - # 2. Update the Full JSON Field (ai_analysis_data) if instance.ai_analysis_data is None: instance.ai_analysis_data = {} - # Save both structured outputs into the single JSONField for completeness + # Save all four structured outputs into the single JSONField instance.ai_analysis_data = data - # instance.ai_analysis_data['parsed_data'] = parsed_summary - # instance.ai_analysis_data['scoring_data'] = scoring_result - - # Apply parsing results - # instance.parsed_summary = json.dumps(parsed_summary) instance.is_resume_parsed = True + # Save changes to the database + # NOTE: If you extract individual fields (like match_score) to separate columns, + # ensure those are handled here, using data.get('analysis_data_en', {}).get('match_score'). instance.save(update_fields=['ai_analysis_data', 'is_resume_parsed']) - logger.info(f"Successfully scored and saved analysis for candidate {instance.id}") - print(f"Successfully scored and saved analysis for candidate {instance.id}") + logger.info(f"Successfully scored and saved analysis (EN/AR) for candidate {instance.id}") + print(f"Successfully scored and saved analysis (EN/AR) for candidate {instance.id}") + from django.utils import timezone @@ -642,7 +866,7 @@ def sync_hired_candidates_task(job_slug): # action=IntegrationLog.ActionChoices.SYNC, # endpoint="multi_source_sync", # method="BACKGROUND_TASK", - # request_data={"job_slug": job_slug, "candidate_count": job.accepted_candidates.count()}, + # request_data={"job_slug": job_slug, "candidate_count": job.accepted_applications.count()}, # response_data=results, # status_code="SUCCESS" if results.get('summary', {}).get('failed', 0) == 0 else "PARTIAL", # ip_address="127.0.0.1", # Background task @@ -799,10 +1023,6 @@ def _task_send_individual_email(subject, body_message, recipient, attachments,se logger.error(f"Email sent to {recipient}, but failed to store in DB: {str(e)}") - else: - logger.error("failed to send email") - - except Exception as e: logger.error(f"Failed to send email to {recipient}: {str(e)}", exc_info=True) diff --git a/recruitment/tests.py b/recruitment/tests.py index 847d494..43c615e 100644 --- a/recruitment/tests.py +++ b/recruitment/tests.py @@ -19,11 +19,11 @@ from .forms import ( CandidateStageForm, InterviewScheduleForm, CandidateSignupForm ) from .views import ( - ZoomMeetingListView, ZoomMeetingCreateView, job_detail, candidate_screening_view, - candidate_exam_view, candidate_interview_view, api_schedule_candidate_meeting + ZoomMeetingListView, ZoomMeetingCreateView, job_detail, applications_screening_view, + applications_exam_view, applications_interview_view, api_schedule_application_meeting ) from .views_frontend import CandidateListView, JobListView -from .utils import create_zoom_meeting, get_candidates_from_request +from .utils import create_zoom_meeting, get_applications_from_request class BaseTestCase(TestCase): @@ -189,32 +189,32 @@ class ViewTests(BaseTestCase): def test_candidate_screening_view(self): """Test candidate_screening_view""" - response = self.client.get(reverse('candidate_screening_view', kwargs={'slug': self.job.slug})) + response = self.client.get(reverse('applications_screening_view', kwargs={'slug': self.job.slug})) self.assertEqual(response.status_code, 200) self.assertContains(response, 'John Doe') def test_candidate_screening_view_filters(self): """Test candidate_screening_view with filters""" response = self.client.get( - reverse('candidate_screening_view', kwargs={'slug': self.job.slug}), + reverse('applications_screening_view', kwargs={'slug': self.job.slug}), {'min_ai_score': '50', 'tier1_count': '5'} ) self.assertEqual(response.status_code, 200) def test_candidate_exam_view(self): """Test candidate_exam_view""" - response = self.client.get(reverse('candidate_exam_view', kwargs={'slug': self.job.slug})) + response = self.client.get(reverse('applications_exam_view', kwargs={'slug': self.job.slug})) self.assertEqual(response.status_code, 200) self.assertContains(response, 'John Doe') def test_candidate_interview_view(self): - """Test candidate_interview_view""" - response = self.client.get(reverse('candidate_interview_view', kwargs={'slug': self.job.slug})) + """Test applications_interview_view""" + response = self.client.get(reverse('applications_interview_view', kwargs={'slug': self.job.slug})) self.assertEqual(response.status_code, 200) @patch('recruitment.views.create_zoom_meeting') def test_schedule_candidate_meeting(self, mock_create_zoom): - """Test api_schedule_candidate_meeting view""" + """Test api_schedule_application_meeting view""" mock_create_zoom.return_value = { 'status': 'success', 'meeting_details': { @@ -231,7 +231,7 @@ class ViewTests(BaseTestCase): 'duration': 60 } response = self.client.post( - reverse('api_schedule_candidate_meeting', + reverse('api_schedule_application_meeting', kwargs={'job_slug': self.job.slug, 'candidate_pk': self.candidate.pk}), data ) @@ -478,7 +478,7 @@ class PerformanceTests(BaseTestCase): ) # Test pagination - response = self.client.get(reverse('candidate_list')) + response = self.client.get(reverse('application_list')) self.assertEqual(response.status_code, 200) self.assertContains(response, 'Candidate') @@ -586,8 +586,8 @@ class UtilityFunctionTests(BaseTestCase): self.assertEqual(result['status'], 'success') self.assertIn('meeting_id', result['meeting_details']) - def test_get_candidates_from_request(self): - """Test the get_candidates_from_request utility function""" + def get_applications_from_request(self): + """Test the get_applications_from_request utility function""" # This would be tested with a request that has candidate_ids pass diff --git a/recruitment/tests_advanced.py b/recruitment/tests_advanced.py index e24c462..16ee992 100644 --- a/recruitment/tests_advanced.py +++ b/recruitment/tests_advanced.py @@ -33,15 +33,15 @@ from .forms import ( ApplicationStageForm, InterviewScheduleForm, BreakTimeFormSet ) from .views import ( - ZoomMeetingListView, ZoomMeetingCreateView, job_detail, candidate_screening_view, - candidate_exam_view, candidate_interview_view, api_schedule_candidate_meeting, + ZoomMeetingListView, ZoomMeetingCreateView, job_detail, applications_screening_view, + applications_exam_view, applications_interview_view, api_schedule_application_meeting, schedule_interviews_view, confirm_schedule_interviews_view, _handle_preview_submission, _handle_confirm_schedule, _handle_get_request ) # from .views_frontend import CandidateListView, JobListView, JobCreateView from .utils import ( create_zoom_meeting, delete_zoom_meeting, update_zoom_meeting, - get_zoom_meeting_details, get_candidates_from_request, + get_zoom_meeting_details, get_applications_from_request, get_available_time_slots ) # from .zoom_api import ZoomAPIError @@ -421,27 +421,27 @@ class AdvancedViewTests(TestCase): ) # Test search by name - response = self.client.get(reverse('candidate_list'), { + response = self.client.get(reverse('application_list'), { 'search': 'Jane' }) self.assertEqual(response.status_code, 200) self.assertContains(response, 'Jane Smith') # Test search by email - response = self.client.get(reverse('candidate_list'), { + response = self.client.get(reverse('application_list'), { 'search': 'bob@example.com' }) self.assertEqual(response.status_code, 200) self.assertContains(response, 'Bob Johnson') # Test filter by job - response = self.client.get(reverse('candidate_list'), { + response = self.client.get(reverse('application_list'), { 'job': self.job.slug }) self.assertEqual(response.status_code, 200) # Test filter by stage - response = self.client.get(reverse('candidate_list'), { + response = self.client.get(reverse('application_list'), { 'stage': 'Exam' }) self.assertEqual(response.status_code, 200) @@ -521,7 +521,7 @@ class AdvancedViewTests(TestCase): """Test HTMX responses for partial updates""" # Test HTMX request for candidate screening response = self.client.get( - reverse('candidate_screening_view', kwargs={'slug': self.job.slug}), + reverse('applications_screening_view', kwargs={'slug': self.job.slug}), HTTP_HX_REQUEST='true' ) self.assertEqual(response.status_code, 200) @@ -557,7 +557,7 @@ class AdvancedViewTests(TestCase): # This would be tested via a form submission # For now, we test the view logic directly request = self.client.post( - reverse('candidate_update_status', kwargs={'slug': self.job.slug}), + reverse('application_update_status', kwargs={'slug': self.job.slug}), data={'candidate_ids': application_ids, 'mark_as': 'Exam'} ) # Should redirect back to the view @@ -954,7 +954,7 @@ class AdvancedIntegrationTests(TransactionTestCase): ) response = self.client.post( - reverse('api_schedule_candidate_meeting', + reverse('api_schedule_application_meeting', kwargs={'job_slug': job.slug, 'candidate_pk': application.pk}), data={ 'start_time': (timezone.now() + timedelta(hours=1)).isoformat(), diff --git a/recruitment/urls.py b/recruitment/urls.py index f7a7ec3..9ab1d9b 100644 --- a/recruitment/urls.py +++ b/recruitment/urls.py @@ -41,52 +41,52 @@ urlpatterns = [ # Candidate URLs path( - "candidates/", views_frontend.ApplicationListView.as_view(), name="candidate_list" + "applications/", views_frontend.ApplicationListView.as_view(), name="application_list" ), path( - "candidates/create/", + "application/create/", views_frontend.ApplicationCreateView.as_view(), - name="candidate_create", + name="application_create", ), path( - "candidates/create//", + "application/create//", views_frontend.ApplicationCreateView.as_view(), - name="candidate_create_for_job", + name="application_create_for_job", ), path( - "jobs//candidates/", + "jobs//application/", views_frontend.JobApplicationListView.as_view(), - name="job_candidates_list", + name="job_applications_list", ), path( - "candidates//update/", + "applications//update/", views_frontend.ApplicationUpdateView.as_view(), - name="candidate_update", + name="application_update", ), path( - "candidates//delete/", + "application//delete/", views_frontend.ApplicationDeleteView.as_view(), - name="candidate_delete", + name="application_delete", ), path( - "candidate//view/", - views_frontend.candidate_detail, - name="candidate_detail", + "application//view/", + views_frontend.application_detail, + name="application_detail", ), path( - "candidate//resume-template/", - views_frontend.candidate_resume_template_view, - name="candidate_resume_template", + "application//resume-template/", + views_frontend.application_resume_template_view, + name="application_resume_template", ), path( - "candidate//update-stage/", - views_frontend.candidate_update_stage, - name="candidate_update_stage", + "application//update-stage/", + views_frontend.application_update_stage, + name="application_update_stage", ), path( - "candidate//retry-scoring/", + "application//retry-scoring/", views_frontend.retry_scoring_view, - name="candidate_retry_scoring", + name="application_retry_scoring", ), # Training URLs path("training/", views_frontend.TrainingListView.as_view(), name="training_list"), @@ -155,85 +155,87 @@ urlpatterns = [ name="edit_linkedin_post_content", ), path( - "jobs//candidate_screening_view/", - views.candidate_screening_view, - name="candidate_screening_view", + "jobs//applications_screening_view/", + views.applications_screening_view, + name="applications_screening_view", ), path( - "jobs//candidate_exam_view/", - views.candidate_exam_view, - name="candidate_exam_view", + "jobs//applications_exam_view/", + views.applications_exam_view, + name="applications_exam_view", ), path( - "jobs//candidate_interview_view/", - views.candidate_interview_view, - name="candidate_interview_view", + "jobs//applications_interview_view/", + views.applications_interview_view, + name="applications_interview_view", ), path( - "jobs//candidate_document_review_view/", - views.candidate_document_review_view, - name="candidate_document_review_view", + "jobs//applications_document_review_view/", + views.applications_document_review_view, + name="applications_document_review_view", ), path( - "jobs//candidate_offer_view/", - views_frontend.candidate_offer_view, - name="candidate_offer_view", + "jobs//applications_offer_view/", + views_frontend.applications_offer_view, + name="applications_offer_view", ), path( - "jobs//candidate_hired_view/", - views_frontend.candidate_hired_view, - name="candidate_hired_view", + "jobs//applications_hired_view/", + views_frontend.applications_hired_view, + name="applications_hired_view", ), path( "jobs//export//csv/", - views_frontend.export_candidates_csv, - name="export_candidates_csv", + views_frontend.export_applications_csv, + name="export_applications_csv", ), path( - "jobs//candidates//update_status///", - views_frontend.update_candidate_status, - name="update_candidate_status", + "jobs//application//update_status///", + views_frontend.update_application_status, + name="update_application_status", ), - # Sync URLs + # Sync URLs (check) path( - "jobs//sync-hired-candidates/", - views_frontend.sync_hired_candidates, - name="sync_hired_candidates", + "jobs//sync-hired-applications/", + views_frontend.sync_hired_applications, + name="sync_hired_applications", ), path( "sources//test-connection/", views_frontend.test_source_connection, name="test_source_connection", ), + + path( - "jobs///reschedule_meeting_for_candidate//", - views.reschedule_meeting_for_candidate, - name="reschedule_meeting_for_candidate", + "jobs///reschedule_meeting_for_application//", + views.reschedule_meeting_for_application, + name="reschedule_meeting_for_application", ), path( - "jobs//update_candidate_exam_status/", - views.update_candidate_exam_status, - name="update_candidate_exam_status", + "jobs//update_application_exam_status/", + views.update_application_exam_status, + name="update_application_exam_status", ), path( - "jobs//bulk_update_candidate_exam_status/", - views.bulk_update_candidate_exam_status, - name="bulk_update_candidate_exam_status", + "jobs//bulk_update_application_exam_status/", + views.bulk_update_application_exam_status, + name="bulk_update_application_exam_status", ), path( - "htmx//candidate_criteria_view/", - views.candidate_criteria_view_htmx, - name="candidate_criteria_view_htmx", + "htmx//application_criteria_view/", + views.application_criteria_view_htmx, + name="application_criteria_view_htmx", ), path( - "htmx//candidate_set_exam_date/", - views.candidate_set_exam_date, - name="candidate_set_exam_date", + "htmx//application_set_exam_date/", + views.application_set_exam_date, + name="application_set_exam_date", ), path( - "htmx//candidate_update_status/", - views.candidate_update_status, - name="candidate_update_status", + "htmx//application_update_status/", + views.application_update_status, + name="application_update_status", ), # path('forms/form//submit/', views.submit_form, name='submit_form'), # path('forms/form//', views.form_wizard_view, name='form_wizard'), @@ -347,9 +349,9 @@ urlpatterns = [ name="delete_meeting_comment", ), path( - "meetings//set_meeting_candidate/", - views.set_meeting_candidate, - name="set_meeting_candidate", + "meetings//set_meeting_application/", + views.set_meeting_application, + name="set_meeting_application", ), # Hiring Agency URLs path("agencies/", views.agency_list, name="agency_list"), @@ -357,10 +359,10 @@ urlpatterns = [ path("agencies//", views.agency_detail, name="agency_detail"), path("agencies//update/", views.agency_update, name="agency_update"), path("agencies//delete/", views.agency_delete, name="agency_delete"), - path( - "agencies//candidates/", - views.agency_candidates, - name="agency_candidates", + path( #check the html of this url it is not used anywhere + "agencies//applications/", + views.agency_applications, + name="agency_applications", ), # path('agencies//send-message/', views.agency_detail_send_message, name='agency_detail_send_message'), # Agency Assignment Management URLs @@ -369,12 +371,12 @@ urlpatterns = [ views.agency_assignment_list, name="agency_assignment_list", ), - path( + path( #check "agency-assignments/create/", views.agency_assignment_create, name="agency_assignment_create", ), - path( + path(#check "agency-assignments//create/", views.agency_assignment_create, name="agency_assignment_create", @@ -423,7 +425,7 @@ urlpatterns = [ # path('admin/messages//mark-read/', views.admin_mark_message_read, name='admin_mark_message_read'), # path('admin/messages//delete/', views.admin_delete_message, name='admin_delete_message'), # Agency Portal URLs (for external agencies) - path("portal/login/", views.agency_portal_login, name="agency_portal_login"), + # path("portal/login/", views.agency_portal_login, name="agency_portal_login"), path("portal//reset/", views.portal_password_reset, name="portal_password_reset"), path( "portal/dashboard/", @@ -433,19 +435,19 @@ urlpatterns = [ # Unified Portal URLs path("login/", views.portal_login, name="portal_login"), path( - "candidate/dashboard/", - views.candidate_portal_dashboard, - name="candidate_portal_dashboard", + "applicant/dashboard/", + views.applicant_portal_dashboard, + name="applicant_portal_dashboard", ), path( - "candidate/applications//", - views.candidate_application_detail, - name="candidate_application_detail", + "applications/applications//", + views.applicant_application_detail, + name="applicant_application_detail", ), # path( # "candidate//applications//detail//", - # views.candidate_application_detail, - # name="candidate_application_detail", + # views.applicant_application_detail, + # name="applicant_application_detail", # ), path( "portal/dashboard/", @@ -463,35 +465,35 @@ urlpatterns = [ name="agency_portal_assignment_detail", ), path( - "portal/assignment//submit-candidate/", - views.agency_portal_submit_candidate_page, - name="agency_portal_submit_candidate_page", + "portal/assignment//submit-application/", + views.agency_portal_submit_application_page, + name="agency_portal_submit_application_page", ), path( - "portal/submit-candidate/", - views.agency_portal_submit_candidate, - name="agency_portal_submit_candidate", + "portal/submit-application/", + views.agency_portal_submit_application, + name="agency_portal_submit_application", ), path("portal/logout/", views.portal_logout, name="portal_logout"), # Agency Portal Candidate Management URLs path( - "portal/candidates//edit/", - views.agency_portal_edit_candidate, - name="agency_portal_edit_candidate", + "portal/applications//edit/", + views.agency_portal_edit_application, + name="agency_portal_edit_application", ), path( - "portal/candidates//delete/", - views.agency_portal_delete_candidate, - name="agency_portal_delete_candidate", + "portal/applications//delete/", + views.agency_portal_delete_application, + name="agency_portal_delete_application", ), # API URLs for messaging (removed) # path('api/agency/messages//', views.api_agency_message_detail, name='api_agency_message_detail'), # path('api/agency/messages//mark-read/', views.api_agency_mark_message_read, name='api_agency_mark_message_read'), # API URLs for candidate management path( - "api/candidate//", - views.api_candidate_detail, - name="api_candidate_detail", + "api/application//", + views.api_application_detail, + name="api_application_detail", ), # # Admin Notification API # path('api/admin/notification-count/', views.api_notification_count, name='admin_notification_count'), @@ -535,10 +537,11 @@ urlpatterns = [ ), # Email composition URLs path( - "jobs//candidates/compose-email/", - views.compose_candidate_email, - name="compose_candidate_email", + "jobs//applications/compose-email/", + views.compose_application_email, + name="compose_application_email", ), + # Message URLs path("messages/", views.message_list, name="message_list"), path("messages/create/", views.message_create, name="message_create"), @@ -555,15 +558,15 @@ urlpatterns = [ path("documents//delete/", views.document_delete, name="document_delete"), path("documents//download/", views.document_download, name="document_download"), # Candidate Document Management URLs - path("candidate/documents/upload//", views.document_upload, name="candidate_document_upload"), - path("candidate/documents//delete/", views.document_delete, name="candidate_document_delete"), - path("candidate/documents//download/", views.document_download, name="candidate_document_download"), - path('jobs//candidates/compose_email/', views.compose_candidate_email, name='compose_candidate_email'), + path("application/documents/upload//", views.document_upload, name="application_document_upload"), + path("application/documents//delete/", views.document_delete, name="application_document_delete"), + path("application/documents//download/", views.document_download, name="application_document_download"), + path('jobs//applications/compose_email/', views.compose_application_email, name='compose_application_email'), path('interview/partcipants//',views.create_interview_participants,name='create_interview_participants'), path('interview/email//',views.send_interview_email,name='send_interview_email'), # Candidate Signup - path('candidate/signup//', views.candidate_signup, name='candidate_signup'), + path('application/signup//', views.application_signup, name='application_signup'), # Password Reset path('user//password-reset/', views.portal_password_reset, name='portal_password_reset'), @@ -607,43 +610,43 @@ urlpatterns = [ ), # Candidate Meeting Scheduling/Rescheduling URLs path( - "jobs//candidates//schedule-meeting/", - views.schedule_candidate_meeting, - name="schedule_candidate_meeting", + "jobs//applications//schedule-meeting/", + views.schedule_application_meeting, + name="schedule_application_meeting", ), path( - "api/jobs//candidates//schedule-meeting/", - views.api_schedule_candidate_meeting, - name="api_schedule_candidate_meeting", + "api/jobs//applications//schedule-meeting/", + views.api_schedule_application_meeting, + name="api_schedule_application_meeting", ), path( - "jobs//candidates//reschedule-meeting//", - views.reschedule_candidate_meeting, - name="reschedule_candidate_meeting", + "jobs//applications//reschedule-meeting//", + views.reschedule_application_meeting, + name="reschedule_application_meeting", ), path( - "api/jobs//candidates//reschedule-meeting//", - views.api_reschedule_candidate_meeting, - name="api_reschedule_candidate_meeting", + "api/jobs//applications//reschedule-meeting//", + views.api_reschedule_application_meeting, + name="api_reschedule_application_meeting", ), # New URL for simple page-based meeting scheduling path( - "jobs//candidates//schedule-meeting-page/", - views.schedule_meeting_for_candidate, - name="schedule_meeting_for_candidate", - ), - path( - "jobs//candidates//delete_meeting_for_candidate//", - views.delete_meeting_for_candidate, - name="delete_meeting_for_candidate", + "jobs//applications//schedule-meeting-page/", + views.schedule_meeting_for_application, + name="schedule_meeting_for_application", ), + # path( + # "jobs//applications//delete_meeting_for_application//", + # views.delete_meeting_for_candidate, + # name="delete_meeting_for_candidate", + # ), path("interviews/meetings/", views.MeetingListView.as_view(), name="list_meetings"), # 1. Onsite Reschedule URL path( - '/candidate//onsite/reschedule//', + '/application//onsite/reschedule//', views.reschedule_onsite_meeting, name='reschedule_onsite_meeting' ), @@ -651,15 +654,15 @@ urlpatterns = [ # 2. Onsite Delete URL path( - 'job//candidates//delete-onsite-meeting//', - views.delete_onsite_meeting_for_candidate, - name='delete_onsite_meeting_for_candidate' + 'job//applications//delete-onsite-meeting//', + views.delete_onsite_meeting_for_application, + name='delete_onsite_meeting_for_application' ), path( - 'job//candidate//schedule/onsite/', - views.schedule_onsite_meeting_for_candidate, - name='schedule_onsite_meeting_for_candidate' # This is the name used in the button + 'job//application//schedule/onsite/', + views.schedule_onsite_meeting_for_application, + name='schedule_onsite_meeting_for_application' # This is the name used in the button ), @@ -667,7 +670,7 @@ urlpatterns = [ path("interviews/meetings//", views.meeting_details, name="meeting_details"), # Email invitation URLs - path("interviews/meetings//send-candidate-invitation/", views.send_candidate_invitation, name="send_candidate_invitation"), + path("interviews/meetings//send-application-invitation/", views.send_application_invitation, name="send_application_invitation"), path("interviews/meetings//send-participants-invitation/", views.send_participants_invitation, name="send_participants_invitation"), ] diff --git a/recruitment/utils.py b/recruitment/utils.py index 3ef5dd4..27e3b97 100644 --- a/recruitment/utils.py +++ b/recruitment/utils.py @@ -571,10 +571,10 @@ def json_to_markdown_table(data_list): return markdown -def get_candidates_from_request(request): +def get_applications_from_request(request): for c in request.POST.items(): try: - yield models.Candidate.objects.get(pk=c[0]) + yield models.Application.objects.get(pk=c[0]) except Exception as e: logger.error(e) yield None diff --git a/recruitment/views.py b/recruitment/views.py index eb00fdb..19a22a5 100644 --- a/recruitment/views.py +++ b/recruitment/views.py @@ -61,7 +61,7 @@ from django.urls import reverse_lazy from django.db.models import Count, Avg, F, Q from .forms import ( ZoomMeetingForm, - CandidateExamDateForm, + ApplicationExamDateForm, JobPostingForm, JobPostingImageForm, InterviewNoteForm, @@ -98,7 +98,7 @@ from django.views.generic import ( from .utils import ( create_zoom_meeting, delete_zoom_meeting, - get_candidates_from_request, + get_applications_from_request, update_meeting, update_zoom_meeting, get_zoom_meeting_details, @@ -187,6 +187,7 @@ class PersonCreateView(CreateView): template_name = "people/create_person.html" form_class = PersonForm success_url = reverse_lazy("person_list") + print("from agency") def form_valid(self, form): if "HX-Request" in self.request.headers: instance = form.save() @@ -195,11 +196,12 @@ class PersonCreateView(CreateView): slug = self.request.POST.get("agency") if slug: agency = HiringAgency.objects.get(slug=slug) + print(agency) instance.agency = agency instance.save() return redirect("agency_portal_persons_list") if view == "job": - return redirect("candidate_create") + return redirect("application_create") return super().form_valid(form) @@ -450,13 +452,13 @@ def edit_job(request, slug): if form.is_valid(): try: form.save() - messages.success(request, f'Job "{job.title}" updated successfully!') + messages.success(request, _('Job "%(title)s" updated successfully!') % {'title': job.title}) return redirect("job_list") except Exception as e: logger.error(f"Error updating job: {e}") - messages.error(request, f"Error updating job: {e}") + messages.error(request, _('Error updating job: %(error)s') % {'error': e}) else: - messages.error(request, "Please correct the errors below.") + messages.error(request, _("Please correct the errors below.")) else: job = get_object_or_404(JobPosting, slug=slug) form = JobPostingForm(instance=job) @@ -467,24 +469,27 @@ SCORE_PATH = "ai_analysis_data__analysis_data__match_score" HIGH_POTENTIAL_THRESHOLD = 75 from django.contrib.sites.shortcuts import get_current_site - +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 def job_detail(request, slug): """View details of a specific job""" job = get_object_or_404(JobPosting, slug=slug) # Get all applications for this job, ordered by most recent - applicants = job.applications.all().order_by("-created_at") + applications = job.applications.all().order_by("-created_at") # Count applications by stage for summary statistics - total_applicant = applicants.count() + total_applications = applications.count() - applied_count = applicants.filter(stage="Applied").count() + applied_count = applications.filter(stage="Applied").count() - exam_count = applicants.filter(stage="Exam").count() + exam_count = applications.filter(stage="Exam").count() - interview_count = applicants.filter(stage="Interview").count() + interview_count = applications.filter(stage="Interview").count() - offer_count = applicants.filter(stage="Offer").count() + offer_count = applications.filter(stage="Offer").count() status_form = JobPostingStatusForm(instance=job) linkedin_content_form = LinkedPostContentForm(instance=job) @@ -523,35 +528,50 @@ def job_detail(request, slug): # --- 2. Quality Metrics (JSON Aggregation) --- + applications_with_score = applications.filter(is_resume_parsed=True) + total_applications_ = applications_with_score.count() # For context - - candidates_with_score = applicants.filter(is_resume_parsed=True).annotate( - annotated_match_score=Coalesce(Cast(SCORE_PATH, output_field=IntegerField()), 0) + # Define the queryset for applications that have been parsed + score_expression = Cast( + Coalesce( + KeyTextTransform( + 'match_score', + KeyTransform('analysis_data_en', 'ai_analysis_data') + ), + Value('0'), + ), + output_field=IntegerField() ) - total_candidates = applicants.count() - avg_match_score_result = candidates_with_score.aggregate( - avg_score=Avg("annotated_match_score") - )["avg_score"] - avg_match_score = round(avg_match_score_result or 0, 1) - high_potential_count = candidates_with_score.filter( + # 2. ANNOTATE the queryset with the new field + applications_with_score = applications_with_score.annotate( + annotated_match_score=score_expression + ) + + avg_match_score_result = applications_with_score.aggregate( + avg_score=Avg('annotated_match_score') + ) + avg_match_score = avg_match_score_result.get("avg_score") or 0.0 + + high_potential_count = applications_with_score.filter( annotated_match_score__gte=HIGH_POTENTIAL_THRESHOLD ).count() + high_potential_ratio = ( - round((high_potential_count / total_candidates) * 100, 1) - if total_candidates > 0 + round((high_potential_count / total_applications_) * 100, 1) + if total_applications_ > 0 else 0 ) # --- 3. Time Metrics (Duration Aggregation) --- # Metric: Average Time from Applied to Interview (T2I) - t2i_candidates = applicants.filter(interview_date__isnull=False).annotate( + t2i_applications = applications.filter(interview_date__isnull=False).annotate( time_to_interview=ExpressionWrapper( F("interview_date") - F("created_at"), output_field=DurationField() ) ) - avg_t2i_duration = t2i_candidates.aggregate(avg_t2i=Avg("time_to_interview"))[ + avg_t2i_duration = t2i_applications.aggregate(avg_t2i=Avg("time_to_interview"))[ "avg_t2i" ] @@ -563,14 +583,14 @@ def job_detail(request, slug): ) # Metric: Average Time in Exam Stage - t_in_exam_candidates = applicants.filter( + t_in_exam_applications = applications.filter( exam_date__isnull=False, interview_date__isnull=False ).annotate( time_in_exam=ExpressionWrapper( F("interview_date") - F("exam_date"), output_field=DurationField() ) ) - avg_t_in_exam_duration = t_in_exam_candidates.aggregate( + avg_t_in_exam_duration = t_in_exam_applications.aggregate( avg_t_in_exam=Avg("time_in_exam") )["avg_t_in_exam"] @@ -582,26 +602,27 @@ def job_detail(request, slug): ) category_data = ( - applicants.filter(ai_analysis_data__analysis_data__category__isnull=False) - .values("ai_analysis_data__analysis_data__category") + applications.filter(ai_analysis_data__analysis_data_en__category__isnull=False) + .values("ai_analysis_data__analysis_data_en__category") .annotate( - candidate_count=Count("id"), + application_count=Count("id"), category=Cast( - "ai_analysis_data__analysis_data__category", output_field=CharField() + "ai_analysis_data__analysis_data_en__category", output_field=CharField() ), ) - .order_by("ai_analysis_data__analysis_data__category") + .order_by("ai_analysis_data__analysis_data_en__category") ) # Prepare data for Chart.js - print(category_data) + categories = [item["category"] for item in category_data] - candidate_counts = [item["candidate_count"] for item in category_data] + + applications_count = [item["application_count"] for item in category_data] # avg_scores = [round(item['avg_match_score'], 2) if item['avg_match_score'] is not None else 0 for item in category_data] context = { "job": job, - "applicants": applicants, - "total_applicants": total_applicant, # This was total_candidates in the prompt, using total_applicant for consistency + "applications": applications, + "total_applications": total_applications, # This was total_candidates in the prompt, using total_applicant for consistency "applied_count": applied_count, "exam_count": exam_count, "interview_count": interview_count, @@ -609,7 +630,7 @@ def job_detail(request, slug): "status_form": status_form, "image_upload_form": image_upload_form, "categories": categories, - "candidate_counts": candidate_counts, + "applications_count": applications_count, # 'avg_scores': avg_scores, # New statistics "avg_match_score": avg_match_score, @@ -804,11 +825,11 @@ def kaauh_career(request): selected_job_type = request.GET.get("employment_type", "") job_type_keys = active_jobs.order_by("job_type").distinct("job_type").values_list("job_type", flat=True) - + workplace_type_keys = active_jobs.order_by("workplace_type").distinct("workplace_type").values_list( "workplace_type", flat=True ).distinct() - + if selected_job_type and selected_job_type in job_type_keys: active_jobs = active_jobs.filter(job_type=selected_job_type) if selected_workplace_type and selected_workplace_type in workplace_type_keys: @@ -843,12 +864,9 @@ def kaauh_career(request): # job detail facing the candidate: -def application_detail(request, slug): +def job_application_detail(request, slug): job = get_object_or_404(JobPosting, slug=slug) - already_applied = False - if request.user.is_authenticated: - already_applied = Application.objects.filter(job=job,person=request.user.person_profile).exists() - return render(request, "applicant/application_detail.html", {"job": job,"already_applied":already_applied}) + return render(request, "applicant/job_application_detail.html", {"job": job}) @login_required @@ -1182,14 +1200,8 @@ def delete_form_template(request, template_id): def application_submit_form(request, template_slug): """Display the form as a step-by-step wizard""" if not request.user.is_authenticated: - return redirect("candidate_signup",slug=template_slug) - + return redirect("application_signup",slug=template_slug) template = get_object_or_404(FormTemplate, slug=template_slug, is_active=True) - - if Application.objects.filter(job=template.job,person=request.user.person_profile).exists(): - messages.error(request, _("You have already submitted an application for this job.")) - return redirect("application_detail",slug=template.job.slug) - stage = template.stages.filter(name="Contact Information") @@ -1318,7 +1330,7 @@ def application_submit(request, template_slug): # return redirect('application_success',slug=job.slug) except Exception as e: - logger.error(f"Candidate creation failed,{e}") + logger.error(f"Application creation failed,{e}") pass return JsonResponse( { @@ -1749,12 +1761,12 @@ def confirm_schedule_interviews_view(request, slug): @staff_user_required -def candidate_screening_view(request, slug): +def applications_screening_view(request, slug): """ Manage candidate tiers and stage transitions """ job = get_object_or_404(JobPosting, slug=slug) - candidates = job.screening_candidates + applications = job.screening_applications # Get filter parameters min_ai_score_str = request.GET.get("min_ai_score") @@ -1786,7 +1798,7 @@ def candidate_screening_view(request, slug): gpa = 0 except ValueError: - # This catches if the user enters non-numeric text (e.g., "abc") + # This catches if the user enters non-numeric text (e.g., "abc") min_ai_score = 0 min_experience = 0 tier1_count = 0 @@ -1794,31 +1806,31 @@ def candidate_screening_view(request, slug): # Apply filters if min_ai_score > 0: - candidates = candidates.filter( - ai_analysis_data__analysis_data__match_score__gte=min_ai_score + applications = applications.filter( + ai_analysis_data__analysis_data_en__match_score__gte=min_ai_score ) if min_experience > 0: - candidates = candidates.filter( - ai_analysis_data__analysis_data__years_of_experience__gte=min_experience + applications = applications.filter( + ai_analysis_data__analysis_data_en__years_of_experience__gte=min_experience ) if screening_rating: - candidates = candidates.filter( - ai_analysis_data__analysis_data__screening_stage_rating=screening_rating + applications = applications.filter( + ai_analysis_data__analysis_data_en__screening_stage_rating=screening_rating ) if gpa: - candidates = candidates.filter( + applications = applications.filter( person__gpa__gt= gpa ) - print(candidates) + print(applications) if tier1_count > 0: - candidates = candidates[:tier1_count] + applications = applications[:tier1_count] context = { "job": job, - "candidates": candidates, + "applications": applications, "min_ai_score": min_ai_score, "min_experience": min_experience, "screening_rating": screening_rating, @@ -1827,81 +1839,81 @@ def candidate_screening_view(request, slug): "current_stage": "Applied", } - return render(request, "recruitment/candidate_screening_view.html", context) + return render(request, "recruitment/applications_screening_view.html", context) @staff_user_required -def candidate_exam_view(request, slug): +def applications_exam_view(request, slug): """ Manage candidate tiers and stage transitions """ job = get_object_or_404(JobPosting, slug=slug) - context = {"job": job, "candidates": job.exam_candidates, "current_stage": "Exam"} - return render(request, "recruitment/candidate_exam_view.html", context) + context = {"job": job, "applications": job.exam_applications, "current_stage": "Exam"} + return render(request, "recruitment/applications_exam_view.html", context) @staff_user_required -def update_candidate_exam_status(request, slug): - candidate = get_object_or_404(Application, slug=slug) +def update_application_exam_status(request, slug): + application = get_object_or_404(Application, slug=slug) if request.method == "POST": - form = CandidateExamDateForm(request.POST, instance=candidate) + form = ApplicationExamDateForm(request.POST, instance=application) if form.is_valid(): form.save() - return redirect("candidate_exam_view", slug=candidate.job.slug) + return redirect("applications_exam_view", slug=application.job.slug) else: - form = CandidateExamDateForm(request.POST, instance=candidate) + form = ApplicationExamDateForm(request.POST, instance=application) return render( request, - "includes/candidate_exam_status_form.html", - {"candidate": candidate, "form": form}, + "includes/application_exam_status_form.html", + {"application": application, "form": form}, ) @staff_user_required -def bulk_update_candidate_exam_status(request, slug): +def bulk_update_application_exam_status(request, slug): job = get_object_or_404(JobPosting, slug=slug) status = request.headers.get("status") if status: - for candidate in get_candidates_from_request(request): + for application in get_applications_from_request(request): try: if status == "pass": - candidate.exam_status = "Passed" - candidate.stage = "Interview" + application.exam_status = "Passed" + application.stage = "Interview" else: - candidate.exam_status = "Failed" - candidate.save() + application.exam_status = "Failed" + application.save() except Exception as e: print(e) - messages.success(request, f"Updated exam status selected candidates") - return redirect("candidate_exam_view", slug=job.slug) + messages.success(request, f"Updated exam status selected applications") + return redirect("applications_exam_view", slug=job.slug) -def candidate_criteria_view_htmx(request, pk): - candidate = get_object_or_404(Application, pk=pk) +def application_criteria_view_htmx(request, pk): + application = get_object_or_404(Application, pk=pk) return render( - request, "includes/candidate_modal_body.html", {"candidate": candidate} + request, "includes/application_modal_body.html", {"application": application} ) @staff_user_required -def candidate_set_exam_date(request, slug): - candidate = get_object_or_404(Application, slug=slug) - candidate.exam_date = timezone.now() - candidate.save() +def application_set_exam_date(request, slug): + application = get_object_or_404(Application, slug=slug) + application.exam_date = timezone.now() + application.save() messages.success( - request, f"Set exam date for {candidate.name} to {candidate.exam_date}" + request, f"Set exam date for {application.name} to {application.exam_date}" ) - return redirect("candidate_screening_view", slug=candidate.job.slug) + return redirect("applications_screening_view", slug=application.job.slug) @staff_user_required -def candidate_update_status(request, slug): +def application_update_status(request, slug): job = get_object_or_404(JobPosting, slug=slug) mark_as = request.POST.get("mark_as") if mark_as != "----------": - candidate_ids = request.POST.getlist("candidate_ids") - print(candidate_ids) - if c := Application.objects.filter(pk__in=candidate_ids): + application_ids = request.POST.getlist("candidate_ids") + + if c := Application.objects.filter(pk__in=application_ids): if mark_as == "Exam": print("exam") c.update( @@ -1967,38 +1979,38 @@ def candidate_update_status(request, slug): else "Applicant", ) - messages.success(request, f"Candidates Updated") - response = HttpResponse(redirect("candidate_screening_view", slug=job.slug)) + messages.success(request, f"Applications Updated") + response = HttpResponse(redirect("applications_screening_view", slug=job.slug)) response.headers["HX-Refresh"] = "true" return response @staff_user_required -def candidate_interview_view(request, slug): +def applications_interview_view(request, slug): job = get_object_or_404(JobPosting, slug=slug) context = { "job": job, - "candidates": job.interview_candidates, + "applications": job.interview_applications, "current_stage": "Interview", } - return render(request, "recruitment/candidate_interview_view.html", context) + return render(request, "recruitment/applications_interview_view.html", context) @staff_user_required -def candidate_document_review_view(request, slug): +def applications_document_review_view(request, slug): """ Document review view for candidates after interview stage and before offer stage """ job = get_object_or_404(JobPosting, slug=slug) # Get candidates from Interview stage who need document review - candidates = job.document_review_candidates.select_related('person') - print(candidates) + applications = job.document_review_applications.select_related('person') + # Get search query for filtering search_query = request.GET.get('q', '') if search_query: - candidates = candidates.filter( + applications = applications.filter( Q(person__first_name__icontains=search_query) | Q(person__last_name__icontains=search_query) | Q(person__email__icontains=search_query) @@ -2006,15 +2018,15 @@ def candidate_document_review_view(request, slug): context = { "job": job, - "candidates": candidates, + "applications": applications, "current_stage": "Document Review", "search_query": search_query, } - return render(request, "recruitment/candidate_document_review_view.html", context) + return render(request, "recruitment/applications_document_review_view.html", context) @staff_user_required -def reschedule_meeting_for_candidate(request, slug, candidate_id, meeting_id): +def reschedule_meeting_for_application(request, slug, candidate_id, meeting_id): job = get_object_or_404(JobPosting, slug=slug) candidate = get_object_or_404(Application, pk=candidate_id) meeting = get_object_or_404(ZoomMeetingDetails, pk=meeting_id) @@ -2032,7 +2044,7 @@ def reschedule_meeting_for_candidate(request, slug, candidate_id, meeting_id): if instance.start_time < timezone.now(): messages.error(request, "Start time must be in the future.") return redirect( - "reschedule_meeting_for_candidate", + "reschedule_meeting_for_application", slug=job.slug, candidate_id=candidate_id, meeting_id=meeting_id, @@ -2045,7 +2057,7 @@ def reschedule_meeting_for_candidate(request, slug, candidate_id, meeting_id): else: messages.error(request, result["message"]) return redirect( - reverse("candidate_interview_view", kwargs={"slug": job.slug}) + reverse("applications_interview_view", kwargs={"slug": job.slug}) ) context = {"job": job, "candidate": candidate, "meeting": meeting, "form": form} @@ -2053,9 +2065,9 @@ def reschedule_meeting_for_candidate(request, slug, candidate_id, meeting_id): @staff_user_required -def delete_meeting_for_candidate(request, slug, candidate_pk, meeting_id): +def schedule_meeting_for_application(request, slug, candidate_pk, meeting_id): job = get_object_or_404(JobPosting, slug=slug) - candidate = get_object_or_404(Application, pk=candidate_pk) + application = get_object_or_404(Application, pk=candidate_pk) meeting = get_object_or_404(ZoomMeetingDetails, pk=meeting_id) if request.method == "POST": result = delete_zoom_meeting(meeting.meeting_id) @@ -2067,14 +2079,14 @@ def delete_meeting_for_candidate(request, slug, candidate_pk, meeting_id): messages.success(request, "Meeting deleted successfully") else: messages.error(request, result["message"]) - return redirect(reverse("candidate_interview_view", kwargs={"slug": job.slug})) + return redirect(reverse("applications_interview_view", kwargs={"slug": job.slug})) context = { "job": job, - "candidate": candidate, + "application": application, "meeting": meeting, "delete_url": reverse( - "delete_meeting_for_candidate", + "schedule_meeting_for_application", kwargs={ "slug": job.slug, "candidate_pk": candidate_pk, @@ -2114,7 +2126,7 @@ def delete_zoom_meeting_for_candidate(request, slug, candidate_pk, meeting_id): else: messages.error(request, result["message"]) - return redirect(reverse("candidate_interview_view", kwargs={"slug": job.slug})) + return redirect(reverse("applications_interview_view", kwargs={"slug": job.slug})) context = { "job": job, @@ -2207,7 +2219,7 @@ def interview_detail_view(request, slug, interview_id): # Candidate Meeting Scheduling/Rescheduling Views @require_POST -def api_schedule_candidate_meeting(request, job_slug, candidate_pk): +def api_schedule_application_meeting(request, job_slug, candidate_pk): """ Handle POST request to schedule a Zoom meeting for a candidate via HTMX. Returns JSON response for modal update. @@ -2287,23 +2299,23 @@ def api_schedule_candidate_meeting(request, job_slug, candidate_pk): return JsonResponse({"success": False, "error": result["message"]}, status=400) -def schedule_candidate_meeting(request, job_slug, candidate_pk): +def schedule_application_meeting(request, job_slug, candidate_pk): """ GET: Render modal form to schedule a meeting. (For HTMX) - POST: Handled by api_schedule_candidate_meeting. + POST: Handled by api_schedule_application_meeting. """ job = get_object_or_404(JobPosting, slug=job_slug) candidate = get_object_or_404(Application, pk=candidate_pk, job=job) if request.method == "POST": - return api_schedule_candidate_meeting(request, job_slug, candidate_pk) + return api_schedule_application_meeting(request, job_slug, candidate_pk) # GET request - render the form snippet for HTMX context = { "job": job, "candidate": candidate, "action_url": reverse( - "api_schedule_candidate_meeting", + "api_schedule_application_meeting", kwargs={"job_slug": job_slug, "candidate_pk": candidate_pk}, ), "scheduled_interview": None, # Explicitly None for schedule @@ -2313,7 +2325,7 @@ def schedule_candidate_meeting(request, job_slug, candidate_pk): @require_http_methods(["GET", "POST"]) -def api_schedule_candidate_meeting(request, job_slug, candidate_pk): +def api_schedule_application_meeting(request, job_slug, candidate_pk): """ Handles GET to render form and POST to process scheduling. """ @@ -2326,7 +2338,7 @@ def api_schedule_candidate_meeting(request, job_slug, candidate_pk): "job": job, "candidate": candidate, "action_url": reverse( - "api_schedule_candidate_meeting", + "api_schedule_application_meeting", kwargs={"job_slug": job_slug, "candidate_pk": candidate_pk}, ), "scheduled_interview": None, @@ -2397,7 +2409,7 @@ def api_schedule_candidate_meeting(request, job_slug, candidate_pk): @require_http_methods(["GET", "POST"]) -def api_reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_pk): +def api_reschedule_application_meeting(request, job_slug, candidate_pk, interview_pk): """ Handles GET to render form and POST to process rescheduling. """ @@ -2423,7 +2435,7 @@ def api_reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_ "scheduled_interview": scheduled_interview, # Pass for conditional logic in template "initial_data": initial_data, "action_url": reverse( - "api_reschedule_candidate_meeting", + "api_reschedule_application_meeting", kwargs={ "job_slug": job_slug, "candidate_pk": candidate_pk, @@ -2519,14 +2531,14 @@ def api_reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_ return JsonResponse({"success": False, "error": result["message"]}, status=400) -# The original schedule_candidate_meeting and reschedule_candidate_meeting (without api_ prefix) +# The original schedule_application_meeting and reschedule_application_meeting (without api_ prefix) # can be removed if their only purpose was to be called by the JS onclicks. # If they were intended for other direct URL access, they can be kept as simple redirects # or wrappers to the api_ versions. # For now, let's assume the api_ versions are the primary ones for HTMX. -def reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_pk): +def reschedule_application_meeting(request, job_slug, candidate_pk, interview_pk): """ Handles GET to display a form for rescheduling a meeting. Handles POST to process the rescheduling of a meeting. @@ -2581,7 +2593,7 @@ def reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_pk): else "", "initial_duration": new_duration, "action_url": reverse( - "reschedule_candidate_meeting", + "reschedule_application_meeting", kwargs={ "job_slug": job_slug, "candidate_pk": candidate_pk, @@ -2661,7 +2673,7 @@ def reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_pk): f"Meeting for {application.name} rescheduled. (Note: Could not refresh all details from Zoom.)", ) - return redirect("candidate_interview_view", slug=job.slug) + return redirect("applications_interview_view", slug=job.slug) else: messages.error( request, @@ -2682,7 +2694,7 @@ def reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_pk): else "", "initial_duration": new_duration, "action_url": reverse( - "reschedule_candidate_meeting", + "reschedule_application_meeting", kwargs={ "job_slug": job_slug, "candidate_pk": candidate_pk, @@ -2711,7 +2723,7 @@ def reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_pk): ), "initial_duration": request.POST.get("duration", new_duration), "action_url": reverse( - "reschedule_candidate_meeting", + "reschedule_application_meeting", kwargs={ "job_slug": job_slug, "candidate_pk": candidate_pk, @@ -2738,7 +2750,7 @@ def reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_pk): "application": application, "scheduled_interview": scheduled_interview, # Pass to template for title/differentiation "action_url": reverse( - "reschedule_candidate_meeting", + "reschedule_application_meeting", kwargs={ "job_slug": job_slug, "candidate_pk": candidate_pk, @@ -2750,13 +2762,13 @@ def reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_pk): ) -def schedule_meeting_for_candidate(request, slug, candidate_pk): +def schedule_meeting_for_application(request, slug, candidate_pk): """ Handles GET to display a simple form for scheduling a meeting for a candidate. Handles POST to process the form, create a meeting, and redirect back. """ job = get_object_or_404(JobPosting, slug=slug) - candidate = get_object_or_404(Application, pk=candidate_pk, job=job) + application = get_object_or_404(Application, pk=candidate_pk, job=job) if request.method == "POST": form = ZoomMeetingForm(request.POST) @@ -2767,17 +2779,17 @@ def schedule_meeting_for_candidate(request, slug, candidate_pk): # Use a default topic if not provided if not topic_val: - topic_val = f"Interview: {job.title} with {candidate.name}" + topic_val = f"Interview: {job.title} with {application.name}" # Ensure start_time is in the future if start_time_val <= timezone.now(): messages.error(request, "Start time must be in the future.") # Re-render form with error and initial data - return redirect("candidate_interview_view", slug=job.slug) + return redirect("applications_interview_view", slug=job.slug) # return render(request, "recruitment/schedule_meeting_form.html", { # 'form': form, # 'job': job, - # 'candidate': candidate, + # 'application': application, # 'initial_topic': topic_val, # 'initial_start_time': start_time_val.strftime('%Y-%m-%dT%H:%M') if start_time_val else '', # 'initial_duration': duration_val @@ -2810,15 +2822,15 @@ def schedule_meeting_for_candidate(request, slug, candidate_pk): ) # Create a ScheduledInterview record ScheduledInterview.objects.create( - application=candidate, + application=application, job=job, interview_location=zoom_meeting_instance, interview_date=start_time_val.date(), interview_time=start_time_val.time(), status="scheduled", ) - messages.success(request, f"Meeting scheduled with {candidate.name}.") - return redirect("candidate_interview_view", slug=job.slug) + messages.success(request, f"Meeting scheduled with {application.name}.") + return redirect("applications_interview_view", slug=job.slug) else: messages.error( request, @@ -2831,7 +2843,7 @@ def schedule_meeting_for_candidate(request, slug, candidate_pk): { "form": form, "job": job, - "candidate": candidate, + "application": application, "initial_topic": topic_val, "initial_start_time": start_time_val.strftime("%Y-%m-%dT%H:%M") if start_time_val @@ -2847,9 +2859,9 @@ def schedule_meeting_for_candidate(request, slug, candidate_pk): { "form": form, "job": job, - "candidate": candidate, + "application": application, "initial_topic": request.POST.get( - "topic", f"Interview: {job.title} with {candidate.name}" + "topic", f"Interview: {job.title} with {application.name}" ), "initial_start_time": request.POST.get("start_time", ""), "initial_duration": request.POST.get("duration", 60), @@ -2857,7 +2869,7 @@ def schedule_meeting_for_candidate(request, slug, candidate_pk): ) else: # GET request initial_data = { - "topic": f"Interview: {job.title} with {candidate.name}", + "topic": f"Interview: {job.title} with {application.name}", "start_time": (timezone.now() + timedelta(hours=1)).strftime( "%Y-%m-%dT%H:%M" ), # Default to 1 hour from now @@ -2867,7 +2879,7 @@ def schedule_meeting_for_candidate(request, slug, candidate_pk): return render( request, "meetings/schedule_meeting_form.html", - {"form": form, "job": job, "candidate": candidate}, + {"form": form, "job": job, "application": application}, ) @@ -3221,7 +3233,7 @@ def delete_meeting_comment(request, slug, comment_id): @staff_user_required -def set_meeting_candidate(request, slug): +def set_meeting_application(request, slug): meeting = get_object_or_404(ZoomMeetingDetails, slug=slug) if request.method == "POST" and "HX-Request" not in request.headers: form = InterviewForm(request.POST) @@ -3243,7 +3255,7 @@ def set_meeting_candidate(request, slug): form.fields["candidate"].queryset = Application.objects.none() form.fields["job"].widget.attrs.update( { - "hx-get": reverse("set_meeting_candidate", kwargs={"slug": slug}), + "hx-get": reverse("set_meeting_application", kwargs={"slug": slug}), "hx-target": "#div_id_candidate", "hx-select": "#div_id_candidate", "hx-swap": "outerHTML", @@ -3323,7 +3335,8 @@ def agency_detail(request, slug): ).count() hired_candidates = candidates.filter(stage="Hired").count() rejected_candidates = candidates.filter(stage="Rejected").count() - + job_assignments=AgencyJobAssignment.objects.filter(agency=agency) + print(job_assignments) context = { "agency": agency, "candidates": candidates[:10], # Show recent 10 candidates @@ -3334,6 +3347,7 @@ def agency_detail(request, slug): "generated_password": agency.generated_password if agency.generated_password else None, + "job_assignments":job_assignments } return render(request, "recruitment/agency_detail.html", context) @@ -3695,23 +3709,23 @@ def agency_delete(request, slug): @staff_user_required -def agency_candidates(request, slug): - """View all candidates from a specific agency""" +def agency_applications(request, slug): + """View all applications from a specific agency""" agency = get_object_or_404(HiringAgency, slug=slug) - candidates = Application.objects.filter(hiring_agency=agency).order_by( + applications = Application.objects.filter(hiring_agency=agency).order_by( "-created_at" ) # Filter by stage if provided stage_filter = request.GET.get("stage") if stage_filter: - candidates = candidates.filter(stage=stage_filter) + applications = applications.filter(stage=stage_filter) - # Get total candidates before pagination for accurate count - total_candidates = candidates.count() + # Get total applications before pagination for accurate count + total_applications = applications.count() # Pagination - paginator = Paginator(candidates, 20) # Show 20 candidates per page + paginator = Paginator(applications, 20) # Show 20 applications per page page_number = request.GET.get("page") page_obj = paginator.get_page(page_number) @@ -3719,9 +3733,9 @@ def agency_candidates(request, slug): "agency": agency, "page_obj": page_obj, "stage_filter": stage_filter, - "total_candidates": total_candidates, + "total_applications": total_applications, } - return render(request, "recruitment/agency_candidates.html", context) + return render(request, "recruitment/agency_applications.html", context) # Agency Portal Management Views @@ -3803,8 +3817,8 @@ def agency_assignment_detail(request, slug): AgencyJobAssignment.objects.select_related("agency", "job"), slug=slug ) - # Get candidates submitted by this agency for this job - candidates = Application.objects.filter( + # Get applications submitted by this agency for this job + applications = Application.objects.filter( hiring_agency=assignment.agency, job=assignment.job ).order_by("-created_at") @@ -3813,21 +3827,21 @@ def agency_assignment_detail(request, slug): # Get messages for this assignment - total_candidates = candidates.count() - max_candidates = assignment.max_candidates + total_applications = applications.count() + max_applications = assignment.max_candidates circumference = 326.73 # 2 * π * r where r=52 - if max_candidates > 0: - progress_percentage = total_candidates / max_candidates + if max_applications > 0: + progress_percentage = total_applications / max_applications stroke_dashoffset = circumference - (circumference * progress_percentage) else: stroke_dashoffset = circumference context = { "assignment": assignment, - "candidates": candidates, + "applications": applications, "access_link": access_link, - "total_candidates": candidates.count(), + "total_applications": applications.count(), "stroke_dashoffset": stroke_dashoffset, } return render(request, "recruitment/agency_assignment_detail.html", context) @@ -3959,46 +3973,46 @@ def portal_password_reset(request,pk): for error in errors: messages.error(request, f"{field}: {error}") -# Agency Portal Views (for external agencies) -def agency_portal_login(request): - """Agency login page""" - # if request.session.get("agency_assignment_id"): - # return redirect("agency_portal_dashboard") - if request.method == "POST": - form = AgencyLoginForm(request.POST) +# # Agency Portal Views (for external agencies) +# def agency_portal_login(request): +# """Agency login page""" +# # if request.session.get("agency_assignment_id"): +# # return redirect("agency_portal_dashboard") +# if request.method == "POST": +# form = AgencyLoginForm(request.POST) - if form.is_valid(): - # Check if validated_access_link attribute exists +# if form.is_valid(): +# # Check if validated_access_link attribute exists - # if hasattr(form, "validated_access_link"): - # access_link = form.validated_access_link - # access_link.record_access() +# # if hasattr(form, "validated_access_link"): +# # access_link = form.validated_access_link +# # access_link.record_access() - # Store assignment in session - # request.session["agency_assignment_id"] = access_link.assignment.id - # request.session["agency_name"] = access_link.assignment.agency.name +# # Store assignment in session +# # request.session["agency_assignment_id"] = access_link.assignment.id +# # request.session["agency_name"] = access_link.assignment.agency.name - messages.success(request, f"Welcome, {access_link.assignment.agency.name}!") - return redirect("agency_portal_dashboard") - else: - messages.error(request, "Invalid token or password.") - else: - form = AgencyLoginForm() +# messages.success(request, f"Welcome, {access_link.assignment.agency.name}!") +# return redirect("agency_portal_dashboard") +# else: +# messages.error(request, "Invalid token or password.") +# else: +# form = AgencyLoginForm() - context = { - "form": form, - } - return render(request, "recruitment/agency_portal_login.html", context) +# context = { +# "form": form, +# } +# return render(request, "recruitment/agency_portal_login.html", context) def portal_login(request): - """Unified portal login for agency and candidate""" + """Unified portal login for agency and applicant""" if request.user.is_authenticated: if request.user.user_type == "agency": return redirect("agency_portal_dashboard") if request.user.user_type == "candidate": print(request.user) - return redirect("candidate_portal_dashboard") + return redirect("applicant_portal_dashboard") if request.method == "POST": form = PortalLoginForm(request.POST) @@ -4039,7 +4053,7 @@ def portal_login(request): # request, # f"Welcome, {user.candidate_profile.first_name}!", # ) - # return redirect("candidate_portal_dashboard") + # return redirect("applicant_portal_dashboard") # else: # messages.error( # request, "No candidate profile found for this user." @@ -4062,25 +4076,25 @@ def portal_login(request): @login_required @candidate_user_required -def candidate_portal_dashboard(request): - """Candidate portal dashboard""" +def applicant_portal_dashboard(request): + """applicant portal dashboard""" if not request.user.is_authenticated: return redirect("account_login") # Get candidate profile (Person record) try: - candidate = request.user.person_profile + applicant = request.user.person_profile except: messages.error(request, "No candidate profile found.") return redirect("account_login") # Get candidate's applications with related job data applications = Application.objects.filter( - person=candidate + person=applicant ).select_related('job').order_by('-created_at') # Get candidate's documents using the Person documents property - documents = candidate.documents.order_by('-created_at') + documents = applicant.documents.order_by('-created_at') # Add password change form for modal password_form = PasswordResetForm() @@ -4090,17 +4104,17 @@ def candidate_portal_dashboard(request): document_form = DocumentUploadForm() context = { - "candidate": candidate, + "applicant": applicant, "applications": applications, "documents": documents, "password_form": password_form, "document_form": document_form, } - return render(request, "recruitment/candidate_profile.html", context) + return render(request, "recruitment/applicant_profile.html", context) @login_required -def candidate_application_detail(request, slug): +def applicant_application_detail(request, slug): """View detailed information about a specific application""" if not request.user.is_authenticated: return redirect("account_login") @@ -4132,7 +4146,7 @@ def candidate_application_detail(request, slug): ai_analysis = None if application.ai_analysis_data: try: - ai_analysis = application.ai_analysis_data.get('analysis_data', {}) + ai_analysis = application.ai_analysis_data.get('analysis_data_en', {}) except (AttributeError, KeyError): ai_analysis = {} @@ -4149,7 +4163,7 @@ def candidate_application_detail(request, slug): "interviews": interviews, "documents": documents, } - return render(request, "recruitment/candidate_application_detail.html", context) + return render(request, "recruitment/applicant_application_detail.html", context) @agency_user_required @@ -4176,13 +4190,13 @@ def agency_portal_persons_list(request): | Q(last_name__icontains=search_query) | Q(email__icontains=search_query) | Q(phone__icontains=search_query) - | Q(job__title__icontains=search_query) + ) # Filter by stage if provided - stage_filter = request.GET.get("stage", "") - if stage_filter: - persons = persons.filter(stage=stage_filter) + # stage_filter = request.GET.get("stage", "") + # if stage_filter: + # persons = persons.filter(stage=stage_filter) # Pagination paginator = Paginator(persons, 20) # Show 20 persons per page @@ -4191,6 +4205,7 @@ def agency_portal_persons_list(request): # Get stage choices for filter dropdown stage_choices = Application.Stage.choices + print(stage_choices) person_form = PersonForm() person_form.initial["agency"] = agency @@ -4198,7 +4213,7 @@ def agency_portal_persons_list(request): "agency": agency, "page_obj": page_obj, "search_query": search_query, - "stage_filter": stage_filter, + # "stage_filter": stage_filter, "stage_choices": stage_choices, "total_persons": persons.count(), "person_form": person_form, @@ -4228,17 +4243,17 @@ def agency_portal_dashboard(request): # Calculate statistics for each assignment assignment_stats = [] for assignment in assignments: - candidates = Application.objects.filter( + applications = Application.objects.filter( hiring_agency=agency, job=assignment.job ).order_by("-created_at") - unread_messages = 0 + unread_messages = Message.objects.filter(job=assignment.job,recipient=agency.user,is_read=False).count() assignment_stats.append( { "assignment": assignment, - "candidates": candidates, - "candidate_count": candidates.count(), + "applications": applications, + "application_count": applications.count(), "unread_messages": unread_messages, "days_remaining": assignment.days_remaining, "is_active": assignment.is_currently_active, @@ -4247,7 +4262,7 @@ def agency_portal_dashboard(request): ) # Get overall statistics - total_candidates = sum(stats["candidate_count"] for stats in assignment_stats) + total_applications = sum(stats["application_count"] for stats in assignment_stats) total_unread_messages = sum(stats["unread_messages"] for stats in assignment_stats) active_assignments = sum(1 for stats in assignment_stats if stats["is_active"]) @@ -4257,15 +4272,15 @@ def agency_portal_dashboard(request): "assignment_stats": assignment_stats, "total_assignments": assignments.count(), "active_assignments": active_assignments, - "total_candidates": total_candidates, + "total_applications": total_applications, "total_unread_messages": total_unread_messages, } return render(request, "recruitment/agency_portal_dashboard.html", context) @agency_user_required -def agency_portal_submit_candidate_page(request, slug): - """Dedicated page for submitting a candidate""" +def agency_portal_submit_application_page(request, slug): + """Dedicated page for submitting a application """ # assignment_id = request.session.get("agency_assignment_id") # if not assignment_id: # return redirect("agency_portal_login") @@ -4281,7 +4296,7 @@ def agency_portal_submit_candidate_page(request, slug): current_job=assignment.job if assignment.is_full: - messages.error(request, "Maximum candidate limit reached for this assignment.") + messages.error(request, "Maximum Application limit reached for this assignment.") return redirect("agency_portal_assignment_detail", slug=assignment.slug) # Verify this assignment belongs to the same agency as the logged-in session if assignment.agency.id != assignment.agency.id: @@ -4294,11 +4309,11 @@ def agency_portal_submit_candidate_page(request, slug): if not assignment.can_submit: messages.error( request, - "Cannot submit candidates: Assignment is not active, expired, or full.", + "Cannot submit applications: Assignment is not active, expired, or full.", ) return redirect("agency_portal_assignment_detail", slug=assignment.slug) - # Get total submitted candidates for this assignment + # Get total submitted applications for this assignment total_submitted = Application.objects.filter( hiring_agency=assignment.agency, job=assignment.job ).count() @@ -4331,7 +4346,7 @@ def agency_portal_submit_candidate_page(request, slug): @agency_user_required -def agency_portal_submit_candidate(request): +def agency_portal_submit_application(request): """Handle candidate submission via AJAX (for embedded form)""" assignment_id = request.session.get("agency_assignment_id") if not assignment_id: @@ -4503,7 +4518,7 @@ def agency_assignment_detail_admin(request, slug): @agency_user_required -def agency_portal_edit_candidate(request, candidate_id): +def agency_portal_edit_application(request, candidate_id): """Edit a candidate for agency portal""" assignment_id = request.session.get("agency_assignment_id") if not assignment_id: @@ -4564,7 +4579,7 @@ def agency_portal_edit_candidate(request, candidate_id): @agency_user_required -def agency_portal_delete_candidate(request, candidate_id): +def agency_portal_delete_application(request, candidate_id): """Delete a candidate for agency portal""" assignment_id = request.session.get("agency_assignment_id") if not assignment_id: @@ -4972,7 +4987,7 @@ def document_upload(request, slug): } }) - return redirect("candidate_portal_dashboard") + return redirect("applicant_portal_dashboard") else: # Create document for Application (existing logic) document = Document.objects.create( @@ -5002,15 +5017,15 @@ def document_upload(request, slug): } }) if upload_target == 'person': - return redirect("candidate_portal_dashboard") + return redirect("applicant_portal_dashboard") else: - return redirect("candidate_application_detail", application_slug=application.slug) + return redirect("applicant_application_detail", application_slug=application.slug) # Handle GET request for AJAX if request.headers.get("X-Requested-With") == "XMLHttpRequest": return JsonResponse({"success": False, "error": "Method not allowed"}) - return redirect("candidate_detail", slug=application.job.slug) + return redirect("application_detail", slug=application.job.slug) @login_required def document_delete(request, document_id): @@ -5029,7 +5044,7 @@ def document_delete(request, document_id): ) return JsonResponse({"success": False, "error": "Permission denied"}) job_slug = document.content_object.job.slug - redirect_url = "candidate_portal_dashboard" if request.user.user_type == "candidate" else "job_detail" + redirect_url = "applicant_portal_dashboard" if request.user.user_type == "candidate" else "job_detail" elif hasattr(document.content_object, "person"): # Person document if request.user.user_type == "candidate": @@ -5039,7 +5054,7 @@ def document_delete(request, document_id): request, "You can only delete your own documents." ) return JsonResponse({"success": False, "error": "Permission denied"}) - redirect_url = "candidate_portal_dashboard" + redirect_url = "applicant_portal_dashboard" else: # Handle other content object types messages.error(request, "You don't have permission to delete this document.") @@ -5056,7 +5071,7 @@ def document_delete(request, document_id): {"success": True, "message": "Document deleted successfully!"} ) else: - return redirect("candidate_detail", slug=job_slug) + return redirect("application_detail", slug=job_slug) return JsonResponse({"success": False, "error": "Method not allowed"}) @@ -5077,7 +5092,7 @@ def document_download(request, document_id): ) return JsonResponse({"success": False, "error": "Permission denied"}) job_slug = document.content_object.job.slug - redirect_url = "candidate_detail" if request.user.user_type == "candidate" else "job_detail" + redirect_url = "application_detail" if request.user.user_type == "candidate" else "job_detail" elif hasattr(document.content_object, "person"): # Person document if request.user.user_type == "candidate": @@ -5087,7 +5102,7 @@ def document_download(request, document_id): request, "You can only download your own documents." ) return JsonResponse({"success": False, "error": "Permission denied"}) - redirect_url = "candidate_portal_dashboard" + redirect_url = "applicant_portal_dashboard" else: # Handle other content object types messages.error(request, "You don't have permission to download this document.") @@ -5196,7 +5211,7 @@ def agency_access_link_reactivate(request, slug): @agency_user_required -def api_candidate_detail(request, candidate_id): +def api_application_detail(request, candidate_id): """API endpoint to get candidate details for agency portal""" try: # Get candidate from session-based agency access @@ -5234,7 +5249,7 @@ def api_candidate_detail(request, candidate_id): @staff_user_required -def compose_candidate_email(request, job_slug): +def compose_application_email(request, job_slug): """Compose email to participants about a candidate""" from .email_service import send_bulk_email @@ -5312,7 +5327,7 @@ def compose_candidate_email(request, job_slug): response = HttpResponse(status=200) response.headers["HX-Refresh"] = "true" return response - # return redirect("candidate_interview_view", slug=job.slug) + # return redirect("applications_interview_view", slug=job.slug) else: messages.error( request, @@ -5542,14 +5557,14 @@ def source_toggle_status(request, slug): return JsonResponse({"success": False, "error": "Method not allowed"}) -def candidate_signup(request, slug): - from .forms import CandidateSignupForm +def application_signup(request, slug): + from .forms import ApplicantSignupForm form_template = get_object_or_404(FormTemplate, slug=slug) job = form_template.job if request.method == "POST": - form = CandidateSignupForm(request.POST) + form = ApplicantSignupForm(request.POST) if form.is_valid(): try: first_name = form.cleaned_data["first_name"] @@ -5585,13 +5600,13 @@ def candidate_signup(request, slug): messages.error(request, f"Error creating application: {str(e)}") return render( request, - "recruitment/candidate_signup.html", + "recruitment/applicant_signup.html", {"form": form, "job": job}, ) - form = CandidateSignupForm() + form = ApplicantSignupForm() return render( - request, "recruitment/candidate_signup.html", {"form": form, "job": job} + request, "recruitment/applicant_signup.html", {"form": form, "job": job} ) @@ -5945,7 +5960,7 @@ def reschedule_onsite_meeting(request, slug, candidate_id, meeting_id): instance.save() messages.success(request, "Onsite meeting successfully rescheduled! ✅") - return redirect(reverse("candidate_interview_view", kwargs={'slug': job.slug})) + return redirect(reverse("applications_interview_view", kwargs={'slug': job.slug})) else: form = OnsiteReshuduleForm(instance=onsite_meeting) @@ -5962,7 +5977,7 @@ def reschedule_onsite_meeting(request, slug, candidate_id, meeting_id): # recruitment/views.py @staff_user_required -def delete_onsite_meeting_for_candidate(request, slug, candidate_pk, meeting_id): +def delete_onsite_meeting_for_application(request, slug, candidate_pk, meeting_id): """ Deletes a specific Onsite Location Details instance. This does not require an external API call. @@ -5979,7 +5994,7 @@ def delete_onsite_meeting_for_candidate(request, slug, candidate_pk, meeting_id) meeting.delete() messages.success(request, f"Onsite meeting for {candidate.name} deleted successfully.") - return redirect(reverse("candidate_interview_view", kwargs={"slug": job.slug})) + return redirect(reverse("applications_interview_view", kwargs={"slug": job.slug})) context = { "job": job, @@ -5987,7 +6002,7 @@ def delete_onsite_meeting_for_candidate(request, slug, candidate_pk, meeting_id) "meeting": meeting, "location_type": "Onsite", "delete_url": reverse( - "delete_onsite_meeting_for_candidate", # Use the specific new URL name + "delete_onsite_meeting_for_application", # Use the specific new URL name kwargs={ "slug": job.slug, "candidate_pk": candidate_pk, @@ -5999,14 +6014,14 @@ def delete_onsite_meeting_for_candidate(request, slug, candidate_pk, meeting_id) -def schedule_onsite_meeting_for_candidate(request, slug, candidate_pk): +def schedule_onsite_meeting_for_application(request, slug, candidate_pk): """ Handles scheduling a NEW Onsite Interview for a candidate using OnsiteScheduleForm. """ job = get_object_or_404(JobPosting, slug=slug) candidate = get_object_or_404(Application, pk=candidate_pk) - action_url = reverse('schedule_onsite_meeting_for_candidate', + action_url = reverse('schedule_onsite_meeting_for_application', kwargs={'slug': job.slug, 'candidate_pk': candidate.pk}) if request.method == 'POST': @@ -6044,7 +6059,7 @@ def schedule_onsite_meeting_for_candidate(request, slug, candidate_pk): ) messages.success(request, "Onsite interview scheduled successfully. ✅") - return redirect(reverse("candidate_interview_view", kwargs={'slug': job.slug})) + return redirect(reverse("applications_interview_view", kwargs={'slug': job.slug})) else: # GET Request: Initialize the hidden fields with the correct objects @@ -6125,7 +6140,7 @@ def meeting_details(request, slug): @login_required -def send_candidate_invitation(request, slug): +def send_application_invitation(request, slug): """Send invitation email to the candidate""" meeting = get_object_or_404(InterviewLocation, slug=slug) diff --git a/recruitment/views_frontend.py b/recruitment/views_frontend.py index a03857a..d833e49 100644 --- a/recruitment/views_frontend.py +++ b/recruitment/views_frontend.py @@ -4,7 +4,7 @@ from datetime import datetime from django.shortcuts import render, get_object_or_404,redirect from django.contrib import messages from django.http import JsonResponse, HttpResponse -from django.db.models.fields.json import KeyTextTransform +from django.db.models.fields.json import KeyTextTransform,KeyTransform from recruitment.utils import json_to_markdown_table from django.db.models import Count, Avg, F, FloatField from django.db.models.functions import Cast @@ -25,14 +25,14 @@ from django.urls import reverse_lazy from django.db.models import FloatField from django.db.models import F, IntegerField, Count, Avg, Sum, Q, ExpressionWrapper, fields, Value,CharField from django.db.models.functions import Cast, Coalesce, TruncDate -from django.contrib.auth.decorators import login_required from django.shortcuts import render from django.utils import timezone from datetime import timedelta import json +from django.utils.translation import gettext_lazy as _ # Add imports for user type restrictions -from recruitment.decorators import StaffRequiredMixin, staff_user_required +from recruitment.decorators import StaffRequiredMixin, staff_user_required,candidate_user_required,staff_or_candidate_required from datastar_py.django import ( @@ -91,7 +91,7 @@ class JobUpdateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, form_class = forms.JobPostingForm template_name = 'jobs/edit_job.html' success_url = reverse_lazy('job_list') - success_message = 'Job updated successfully.' + success_message = _('Job updated successfully.') slug_url_kwarg = 'slug' @@ -99,12 +99,12 @@ class JobDeleteView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, model = models.JobPosting template_name = 'jobs/partials/delete_modal.html' success_url = reverse_lazy('job_list') - success_message = 'Job deleted successfully.' + success_message = _('Job deleted successfully.') slug_url_kwarg = 'slug' class JobApplicationListView(LoginRequiredMixin, StaffRequiredMixin, ListView): model = models.Application - template_name = 'jobs/job_candidates_list.html' + template_name = 'jobs/job_applications_list.html' context_object_name = 'applications' paginate_by = 10 @@ -146,13 +146,13 @@ class JobApplicationListView(LoginRequiredMixin, StaffRequiredMixin, ListView): class ApplicationListView(LoginRequiredMixin, StaffRequiredMixin, ListView): model = models.Application - template_name = 'recruitment/candidate_list.html' + template_name = 'recruitment/applications_list.html' context_object_name = 'applications' paginate_by = 100 def get_queryset(self): queryset = super().get_queryset() - + # Handle search search_query = self.request.GET.get('search', '') job = self.request.GET.get('job', '') @@ -186,10 +186,10 @@ class ApplicationListView(LoginRequiredMixin, StaffRequiredMixin, ListView): class ApplicationCreateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, CreateView): model = models.Application form_class = forms.ApplicationForm - template_name = 'recruitment/candidate_create.html' - success_url = reverse_lazy('candidate_list') - success_message = 'Candidate created successfully.' - + template_name = 'recruitment/application_create.html' + success_url = reverse_lazy('application_list') + success_message = _('Application created successfully.') + def get_initial(self): initial = super().get_initial() if 'slug' in self.kwargs: @@ -215,17 +215,17 @@ class ApplicationCreateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessa class ApplicationUpdateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, UpdateView): model = models.Application form_class = forms.ApplicationForm - template_name = 'recruitment/candidate_update.html' - success_url = reverse_lazy('candidate_list') - success_message = 'Candidate updated successfully.' + template_name = 'recruitment/application_update.html' + success_url = reverse_lazy('application_list') + success_message = _('Application updated successfully.') slug_url_kwarg = 'slug' class ApplicationDeleteView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, DeleteView): model = models.Application - template_name = 'recruitment/candidate_delete.html' - success_url = reverse_lazy('candidate_list') - success_message = 'Candidate deleted successfully.' + template_name = 'recruitment/application_delete.html' + success_url = reverse_lazy('application_list') + success_message = _('Application deleted successfully.') slug_url_kwarg = 'slug' @@ -235,12 +235,12 @@ def retry_scoring_view(request,slug): application = get_object_or_404(models.Application, slug=slug) async_task( - 'recruitment.tasks.handle_reume_parsing_and_scoring', + 'recruitment.tasks.handle_resume_parsing_and_scoring', application.pk, hook='recruitment.hooks.callback_ai_parsing', sync=True, ) - return redirect('candidate_detail', slug=application.slug) + return redirect('application_detail', slug=application.slug) @@ -253,11 +253,11 @@ def training_list(request): @login_required @staff_user_required -def candidate_detail(request, slug): +def application_detail(request, slug): from rich.json import JSON - candidate = get_object_or_404(models.Application, slug=slug) + application = get_object_or_404(models.Application, slug=slug) try: - parsed = ast.literal_eval(candidate.parsed_summary) + parsed = ast.literal_eval(application.parsed_summary) except: parsed = {} @@ -266,12 +266,12 @@ def candidate_detail(request, slug): if request.user.is_staff: stage_form = forms.ApplicationStageForm() - + # parsed = JSON(json.dumps(parsed), indent=2, highlight=True, skip_keys=False, ensure_ascii=False, check_circular=True, allow_nan=True, default=None, sort_keys=False) # parsed = json_to_markdown_table([parsed]) - return render(request, 'recruitment/candidate_detail.html', { - 'candidate': candidate, + return render(request, 'recruitment/application_detail.html', { + 'application': application, 'parsed': parsed, 'stage_form': stage_form, }) @@ -279,21 +279,21 @@ def candidate_detail(request, slug): @login_required @staff_user_required -def candidate_resume_template_view(request, slug): +def application_resume_template_view(request, slug): """Display formatted resume template for a candidate""" application = get_object_or_404(models.Application, slug=slug) if not request.user.is_staff: messages.error(request, _("You don't have permission to view this page.")) - return redirect('candidate_list') + return redirect('application_list') - return render(request, 'recruitment/candidate_resume_template.html', { + return render(request, 'recruitment/application_resume_template.html', { 'application': application }) @login_required @staff_user_required -def candidate_update_stage(request, slug): +def application_update_stage(request, slug): """Handle HTMX stage update requests""" application = get_object_or_404(models.Application, slug=slug) form = forms.ApplicationStageForm(request.POST, instance=application) @@ -301,8 +301,8 @@ def candidate_update_stage(request, slug): stage_value = form.cleaned_data['stage'] application.stage = stage_value application.save(update_fields=['stage']) - messages.success(request,"application Stage Updated") - return redirect("candidate_detail",slug=application.slug) + messages.success(request,_("application Stage Updated")) + return redirect("application_detail",slug=application.slug) class TrainingListView(LoginRequiredMixin, StaffRequiredMixin, ListView): model = models.TrainingMaterial @@ -386,7 +386,7 @@ def dashboard_view(request): # --- 1. BASE QUERYSETS & GLOBAL METRICS (UNFILTERED) --- all_jobs_queryset = models.JobPosting.objects.all().order_by('-created_at') - all_candidates_queryset = models.Application.objects.all() + all_applications_queryset = models.Application.objects.all() # Global KPI Card Metrics total_jobs_global = all_jobs_queryset.count() @@ -400,7 +400,7 @@ def dashboard_view(request): # --- 2. TIME SERIES: GLOBAL DAILY APPLICANTS --- # Group ALL candidates by creation date - global_daily_applications_qs = all_candidates_queryset.annotate( + global_daily_applications_qs = all_applications_queryset.annotate( date=TruncDate('created_at') ).values('date').annotate( count=Count('pk') @@ -412,14 +412,14 @@ def dashboard_view(request): # --- 3. FILTERING LOGIC: Determine the scope for scoped metrics --- - candidate_queryset = all_candidates_queryset + application_queryset = all_applications_queryset job_scope_queryset = all_jobs_queryset interview_queryset = models.ScheduledInterview.objects.all() current_job = None if selected_job_pk: # Filter all base querysets - candidate_queryset = candidate_queryset.filter(job__pk=selected_job_pk) + application_queryset = application_queryset.filter(job__pk=selected_job_pk) interview_queryset = interview_queryset.filter(job__pk=selected_job_pk) try: @@ -434,7 +434,7 @@ def dashboard_view(request): scoped_dates = [] scoped_counts = [] if selected_job_pk: - scoped_daily_applications_qs = candidate_queryset.annotate( + scoped_daily_applications_qs = application_queryset.annotate( date=TruncDate('created_at') ).values('date').annotate( count=Count('pk') @@ -446,17 +446,27 @@ def dashboard_view(request): # --- 5. SCOPED CORE AGGREGATIONS (FILTERED OR ALL) --- - total_candidates = candidate_queryset.count() + total_applications = application_queryset.count() - candidates_with_score_query = candidate_queryset.filter( - is_resume_parsed=True - ).annotate( - annotated_match_score=Coalesce( - Cast(SCORE_PATH, output_field=IntegerField()), - 0 - ) + + score_expression = Cast( + Coalesce( + KeyTextTransform( + 'match_score', + KeyTransform('analysis_data_en', 'ai_analysis_data') + ), + Value('0'), + ), + output_field=IntegerField() ) + # 2. ANNOTATE the queryset with the new field + applications_with_score_query = application_queryset.annotate( + annotated_match_score=score_expression + ) + + + # safe_match_score_cast = Cast( # # 3. If the result after stripping quotes is an empty string (''), convert it to NULL. # NullIf( @@ -483,24 +493,24 @@ def dashboard_view(request): # A. Pipeline & Volume Metrics (Scoped) total_active_jobs = job_scope_queryset.filter(status="ACTIVE").count() last_week = timezone.now() - timedelta(days=7) - new_candidates_7days = candidate_queryset.filter(created_at__gte=last_week).count() + new_applications_7days = application_queryset.filter(created_at__gte=last_week).count() open_positions_agg = job_scope_queryset.filter(status="ACTIVE").aggregate(total_open=Sum('open_positions')) total_open_positions = open_positions_agg['total_open'] or 0 average_applications_result = job_scope_queryset.annotate( - candidate_count=Count('applications', distinct=True) - ).aggregate(avg_apps=Avg('candidate_count'))['avg_apps'] + applications_count=Count('applications', distinct=True) + ).aggregate(avg_apps=Avg('applications_count'))['avg_apps'] average_applications = round(average_applications_result or 0, 2) # B. Efficiency & Conversion Metrics (Scoped) - hired_candidates = candidate_queryset.filter( + hired_applications = application_queryset.filter( stage='Hired' ) - lst=[c.time_to_hire_days for c in hired_candidates] + lst=[c.time_to_hire_days for c in hired_applications] - time_to_hire_query = hired_candidates.annotate( + time_to_hire_query = hired_applications.annotate( time_diff=ExpressionWrapper( F('join_date') - F('created_at__date'), output_field=fields.DurationField() @@ -517,11 +527,11 @@ def dashboard_view(request): ) print(avg_time_to_hire_days) - applied_count = candidate_queryset.filter(stage='Applied').count() - advanced_count = candidate_queryset.filter(stage__in=['Exam', 'Interview', 'Offer']).count() + applied_count = application_queryset.filter(stage='Applied').count() + advanced_count = application_queryset.filter(stage__in=['Exam', 'Interview', 'Offer']).count() screening_pass_rate = round( (advanced_count / applied_count) * 100, 1 ) if applied_count > 0 else 0 - offers_extended_count = candidate_queryset.filter(stage='Offer').count() - offers_accepted_count = candidate_queryset.filter(offer_status='Accepted').count() + offers_extended_count = application_queryset.filter(stage='Offer').count() + offers_accepted_count = application_queryset.filter(offer_status='Accepted').count() offers_accepted_rate = round( (offers_accepted_count / offers_extended_count) * 100, 1 ) if offers_extended_count > 0 else 0 filled_positions = offers_accepted_count vacancy_fill_rate = round( (filled_positions / total_open_positions) * 100, 1 ) if total_open_positions > 0 else 0 @@ -532,21 +542,21 @@ def dashboard_view(request): meetings_scheduled_this_week = interview_queryset.filter( interview_date__week=current_week, interview_date__year=current_year ).count() - avg_match_score_result = candidates_with_score_query.aggregate(avg_score=Avg('annotated_match_score'))['avg_score'] + avg_match_score_result = applications_with_score_query.aggregate(avg_score=Avg('annotated_match_score'))['avg_score'] avg_match_score = round(avg_match_score_result or 0, 1) - high_potential_count = candidates_with_score_query.filter(annotated_match_score__gte=HIGH_POTENTIAL_THRESHOLD).count() - high_potential_ratio = round( (high_potential_count / total_candidates) * 100, 1 ) if total_candidates > 0 else 0 - total_scored_candidates = candidates_with_score_query.count() - scored_ratio = round( (total_scored_candidates / total_candidates) * 100, 1 ) if total_candidates > 0 else 0 + high_potential_count = applications_with_score_query.filter(annotated_match_score__gte=HIGH_POTENTIAL_THRESHOLD).count() + high_potential_ratio = round( (high_potential_count / total_applications) * 100, 1 ) if total_applications > 0 else 0 + total_scored_candidates = applications_with_score_query.count() + scored_ratio = round( (total_scored_candidates / total_applications) * 100, 1 ) if total_applications > 0 else 0 # --- 6. CHART DATA PREPARATION --- # A. Pipeline Funnel (Scoped) - stage_counts = candidate_queryset.values('stage').annotate(count=Count('stage')) + stage_counts = application_queryset.values('stage').annotate(count=Count('stage')) stage_map = {item['stage']: item['count'] for item in stage_counts} - candidate_stage = ['Applied', 'Exam', 'Interview', 'Offer', 'Hired'] - candidates_count = [ + application_stage = ['Applied', 'Exam', 'Interview', 'Offer', 'Hired'] + application_count = [ stage_map.get('Applied', 0), stage_map.get('Exam', 0), stage_map.get('Interview', 0), stage_map.get('Offer', 0), stage_map.get('Hired',0) ] @@ -560,9 +570,9 @@ def dashboard_view(request): rotation_degrees_final = round(min(rotation_degrees, 180), 1) # Ensure max 180 degrees # - hiring_source_counts = candidate_queryset.values('hiring_source').annotate(count=Count('stage')) + hiring_source_counts = application_queryset.values('hiring_source').annotate(count=Count('stage')) source_map= {item['hiring_source']: item['count'] for item in hiring_source_counts} - candidates_count_in_each_source = [ + applications_count_in_each_source = [ source_map.get('Public', 0), source_map.get('Internal', 0), source_map.get('Agency', 0), ] @@ -579,8 +589,8 @@ def dashboard_view(request): # Scoped KPIs 'total_active_jobs': total_active_jobs, - 'total_candidates': total_candidates, - 'new_candidates_7days': new_candidates_7days, + 'total_applications': total_applications, + 'new_applications_7days': new_applications_7days, 'total_open_positions': total_open_positions, 'average_applications': average_applications, 'avg_time_to_hire_days': avg_time_to_hire_days, @@ -594,8 +604,8 @@ def dashboard_view(request): 'scored_ratio': scored_ratio, # Chart Data - 'candidate_stage': json.dumps(candidate_stage), - 'candidates_count': json.dumps(candidates_count), + 'application_stage': json.dumps(application_stage), + 'application_count': json.dumps(application_count), 'job_titles': json.dumps(job_titles), 'job_app_counts': json.dumps(job_app_counts), # 'source_volume_chart_data' is intentionally REMOVED @@ -618,7 +628,7 @@ def dashboard_view(request): 'current_job': current_job, - 'candidates_count_in_each_source': json.dumps(candidates_count_in_each_source), + 'applications_count_in_each_source': json.dumps(applications_count_in_each_source), 'all_hiring_sources': json.dumps(all_hiring_sources), } @@ -627,100 +637,100 @@ def dashboard_view(request): @login_required @staff_user_required -def candidate_offer_view(request, slug): +def applications_offer_view(request, slug): """View for candidates in the Offer stage""" job = get_object_or_404(models.JobPosting, slug=slug) # Filter candidates for this specific job and stage - candidates = job.offer_candidates + applications = job.offer_applications # Handle search search_query = request.GET.get('search', '') if search_query: - candidates = candidates.filter( + applications = applications.filter( Q(first_name__icontains=search_query) | Q(last_name__icontains=search_query) | Q(email__icontains=search_query) | Q(phone__icontains=search_query) ) - candidates = candidates.order_by('-created_at') + applications = applications.order_by('-created_at') context = { 'job': job, - 'candidates': candidates, + 'applications': applications, 'search_query': search_query, 'current_stage': 'Offer', } - return render(request, 'recruitment/candidate_offer_view.html', context) + return render(request, 'recruitment/applications_offer_view.html', context) @login_required @staff_user_required -def candidate_hired_view(request, slug): - """View for hired candidates""" +def applications_hired_view(request, slug): + """View for hired applications""" job = get_object_or_404(models.JobPosting, slug=slug) - # Filter candidates with offer_status = 'Accepted' - candidates = job.hired_candidates + # Filter applications with offer_status = 'Accepted' + applications = job.hired_applications # Handle search search_query = request.GET.get('search', '') if search_query: - candidates = candidates.filter( + applications = applications.filter( Q(first_name__icontains=search_query) | Q(last_name__icontains=search_query) | Q(email__icontains=search_query) | Q(phone__icontains=search_query) ) - candidates = candidates.order_by('-created_at') + applications = applications.order_by('-created_at') context = { 'job': job, - 'candidates': candidates, + 'applications': applications, 'search_query': search_query, 'current_stage': 'Hired', } - return render(request, 'recruitment/candidate_hired_view.html', context) + return render(request, 'recruitment/applications_hired_view.html', context) @login_required @staff_user_required -def update_candidate_status(request, job_slug, candidate_slug, stage_type, status): +def update_application_status(request, job_slug, application_slug, stage_type, status): """Handle exam/interview/offer status updates""" from django.utils import timezone job = get_object_or_404(models.JobPosting, slug=job_slug) - candidate = get_object_or_404(models.Application, slug=candidate_slug, job=job) + application = get_object_or_404(models.Application, slug=application_slug, job=job) if request.method == "POST": if stage_type == 'exam': status = request.POST.get("exam_status") score = request.POST.get("exam_score") - candidate.exam_status = status - candidate.exam_score = score - candidate.exam_date = timezone.now() - candidate.save(update_fields=['exam_status','exam_score', 'exam_date']) - return render(request,'recruitment/partials/exam-results.html',{'candidate':candidate,'job':job}) + application.exam_status = status + application.exam_score = score + application.exam_date = timezone.now() + application.save(update_fields=['exam_status','exam_score', 'exam_date']) + return render(request,'recruitment/partials/exam-results.html',{'application':application,'job':job}) elif stage_type == 'interview': - candidate.interview_status = status - candidate.interview_date = timezone.now() - candidate.save(update_fields=['interview_status', 'interview_date']) - return render(request,'recruitment/partials/interview-results.html',{'candidate':candidate,'job':job}) + application.interview_status = status + application.interview_date = timezone.now() + application.save(update_fields=['interview_status', 'interview_date']) + return render(request,'recruitment/partials/interview-results.html',{'application':application,'job':job}) elif stage_type == 'offer': - candidate.offer_status = status - candidate.offer_date = timezone.now() - candidate.save(update_fields=['offer_status', 'offer_date']) - return render(request,'recruitment/partials/offer-results.html',{'candidate':candidate,'job':job}) - return redirect('candidate_detail', candidate.slug) + application.offer_status = status + application.offer_date = timezone.now() + application.save(update_fields=['offer_status', 'offer_date']) + return render(request,'recruitment/partials/offer-results.html',{'application':application,'job':job}) + return redirect('application_detail', application.slug) else: if stage_type == 'exam': - return render(request,"includes/candidate_update_exam_form.html",{'candidate':candidate,'job':job}) + return render(request,"includes/applications_update_exam_form.html",{'application':application,'job':job}) elif stage_type == 'interview': - return render(request,"includes/candidate_update_interview_form.html",{'candidate':candidate,'job':job}) + return render(request,"includes/applications_update_interview_form.html",{'application':application,'job':job}) elif stage_type == 'offer': - return render(request,"includes/candidate_update_offer_form.html",{'candidate':candidate,'job':job}) + return render(request,"includes/applications_update_offer_form.html",{'application':application,'job':job}) # Stage configuration for CSV export @@ -755,8 +765,8 @@ STAGE_CONFIG = { @login_required @staff_user_required -def export_candidates_csv(request, job_slug, stage): - """Export candidates for a specific stage as CSV""" +def export_applications_csv(request, job_slug, stage): + """Export applications for a specific stage as CSV""" job = get_object_or_404(models.JobPosting, slug=job_slug) # Validate stage @@ -766,23 +776,23 @@ def export_candidates_csv(request, job_slug, stage): config = STAGE_CONFIG[stage] - # Filter candidates based on stage + # Filter applications based on stage if stage == 'hired': - candidates = job.applications.filter(**config['filter']) + applications = job.applications.filter(**config['filter']) else: - candidates = job.applications.filter(**config['filter']) + applications = job.applications.filter(**config['filter']) # Handle search if provided search_query = request.GET.get('search', '') if search_query: - candidates = candidates.filter( + applications = applications.filter( Q(first_name__icontains=search_query) | Q(last_name__icontains=search_query) | Q(email__icontains=search_query) | Q(phone__icontains=search_query) ) - candidates = candidates.order_by('-created_at') + applications = applications.order_by('-created_at') # Create CSV response response = HttpResponse(content_type='text/csv') @@ -799,87 +809,87 @@ def export_candidates_csv(request, job_slug, stage): headers.extend(['Job Title', 'Department']) writer.writerow(headers) - # Write candidate data - for candidate in candidates: + # Write application data + for application in applications: row = [] # Extract data based on stage configuration for field in config['fields']: if field == 'name': - row.append(candidate.name) + row.append(application.name) elif field == 'email': - row.append(candidate.email) + row.append(application.email) elif field == 'phone': - row.append(candidate.phone) + row.append(application.phone) elif field == 'created_at': - row.append(candidate.created_at.strftime('%Y-%m-%d %H:%M') if candidate.created_at else '') + row.append(application.created_at.strftime('%Y-%m-%d %H:%M') if application.created_at else '') elif field == 'stage': - row.append(candidate.stage or '') + row.append(application.stage or '') elif field == 'exam_status': - row.append(candidate.exam_status or '') + row.append(application.exam_status or '') elif field == 'exam_date': - row.append(candidate.exam_date.strftime('%Y-%m-%d %H:%M') if candidate.exam_date else '') + row.append(application.exam_date.strftime('%Y-%m-%d %H:%M') if application.exam_date else '') elif field == 'interview_status': - row.append(candidate.interview_status or '') + row.append(application.interview_status or '') elif field == 'interview_date': - row.append(candidate.interview_date.strftime('%Y-%m-%d %H:%M') if candidate.interview_date else '') + row.append(application.interview_date.strftime('%Y-%m-%d %H:%M') if application.interview_date else '') elif field == 'offer_status': - row.append(candidate.offer_status or '') + row.append(application.offer_status or '') elif field == 'offer_date': - row.append(candidate.offer_date.strftime('%Y-%m-%d %H:%M') if candidate.offer_date else '') + row.append(application.offer_date.strftime('%Y-%m-%d %H:%M') if application.offer_date else '') elif field == 'ai_score': # Extract AI score using model property try: - score = candidate.match_score + score = application.match_score row.append(f"{score}%" if score else '') except: row.append('') elif field == 'years_experience': # Extract years of experience using model property try: - years = candidate.years_of_experience + years = application.years_of_experience row.append(f"{years}" if years else '') except: row.append('') elif field == 'screening_rating': # Extract screening rating using model property try: - rating = candidate.screening_stage_rating + rating = application.screening_stage_rating row.append(rating if rating else '') except: row.append('') elif field == 'professional_category': # Extract professional category using model property try: - category = candidate.professional_category + category = application.professional_category row.append(category if category else '') except: row.append('') elif field == 'top_skills': # Extract top 3 skills using model property try: - skills = candidate.top_3_keywords + skills = application.top_3_keywords row.append(', '.join(skills) if skills else '') except: row.append('') elif field == 'strengths': # Extract strengths using model property try: - strengths = candidate.strengths + strengths = application.strengths row.append(strengths if strengths else '') except: row.append('') elif field == 'weaknesses': # Extract weaknesses using model property try: - weaknesses = candidate.weaknesses + weaknesses = application.weaknesses row.append(weaknesses if weaknesses else '') except: row.append('') elif field == 'join_date': - row.append(candidate.join_date.strftime('%Y-%m-%d') if candidate.join_date else '') + row.append(application.join_date.strftime('%Y-%m-%d') if application.join_date else '') else: - row.append(getattr(candidate, field, '')) + row.append(getattr(application, field, '')) # Add job information row.extend([job.title, job.department or '']) @@ -895,8 +905,8 @@ def export_candidates_csv(request, job_slug, stage): @login_required @staff_user_required -def sync_hired_candidates(request, job_slug): - """Sync hired candidates to external sources using Django-Q""" +def sync_hired_applications(request, job_slug): + """Sync hired applications to external sources using Django-Q""" from django_q.tasks import async_task from .tasks import sync_hired_candidates_task diff --git a/static/image/hospital_logo copy.png b/static/image/hospital_logo copy.png deleted file mode 100644 index 4250a35..0000000 Binary files a/static/image/hospital_logo copy.png and /dev/null differ diff --git a/static/image/hospital_logo_1 copy.png b/static/image/hospital_logo_1 copy.png deleted file mode 100644 index ff44820..0000000 Binary files a/static/image/hospital_logo_1 copy.png and /dev/null differ diff --git a/static/image/hospital_logo_2 copy.png b/static/image/hospital_logo_2 copy.png deleted file mode 100644 index a1698d4..0000000 Binary files a/static/image/hospital_logo_2 copy.png and /dev/null differ diff --git a/static/image/hospital_logo_3 copy.png b/static/image/hospital_logo_3 copy.png deleted file mode 100644 index 5a9e883..0000000 Binary files a/static/image/hospital_logo_3 copy.png and /dev/null differ diff --git a/static/image/vision copy.svg b/static/image/vision copy.svg deleted file mode 100644 index 97124a3..0000000 --- a/static/image/vision copy.svg +++ /dev/null @@ -1,120 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/templates/applicant/career.html b/templates/applicant/career.html index e0853fc..2d6e22f 100644 --- a/templates/applicant/career.html +++ b/templates/applicant/career.html @@ -191,7 +191,7 @@ {% for job in active_jobs %} {# Optimized Job Listing Card #} -
diff --git a/templates/applicant/application_detail.html b/templates/applicant/job_application_detail.html similarity index 100% rename from templates/applicant/application_detail.html rename to templates/applicant/job_application_detail.html diff --git a/templates/applicant/partials/candidate_facing_base.html b/templates/applicant/partials/candidate_facing_base.html index b2e1c1b..e9ed45e 100644 --- a/templates/applicant/partials/candidate_facing_base.html +++ b/templates/applicant/partials/candidate_facing_base.html @@ -323,7 +323,7 @@ {% translate "Applications" %} {% endcomment %}
  • {% trans "Settings" %}
  • -
  • {% trans "Integration" %}
  • +
  • {% trans "Staff Settings" %}
  • +
  • {% trans "Integration Settings" %}
  • {% trans "Activity Log" %}
  • {% comment %}
  • {% trans "Help & Support" %}
  • {% endcomment %} {% endif %} @@ -253,7 +253,7 @@
    diff --git a/templates/includes/candidate_exam_status_form.html b/templates/includes/application_exam_status_form.html similarity index 75% rename from templates/includes/candidate_exam_status_form.html rename to templates/includes/application_exam_status_form.html index 0a4fdf1..4efdf6d 100644 --- a/templates/includes/candidate_exam_status_form.html +++ b/templates/includes/application_exam_status_form.html @@ -1,5 +1,5 @@ {% load i18n %} -{% url 'update_candidate_exam_status' slug=candidate.slug as url %} +{% url 'update_application_exam_status' slug=application.slug as url %}
    {% csrf_token %} {{ form.as_p }} diff --git a/templates/includes/application_modal_body.html b/templates/includes/application_modal_body.html new file mode 100644 index 0000000..3c0b7b7 --- /dev/null +++ b/templates/includes/application_modal_body.html @@ -0,0 +1,252 @@ +{% load i18n %} +{% get_current_language as LANGUAGE_CODE %} + +{% if LANGUAGE_CODE == 'en' %} +
    {% trans "AI Score" %}: {{ application.match_score }}% {{ application.professional_category }}
    + +
    +
    +
    + + {% trans "Job Fit" %} +
    +

    {{ application.job_fit_narrative }}

    +
    +
    +
    + + {% trans "Top Keywords" %} +
    +
    + {% for keyword in application.top_3_keywords %} + {{ keyword }} + {% endfor %} +
    +
    +
    + +
    +
    +
    + + {% trans "Experience" %} +
    +

    {{ application.years_of_experience }} {% trans "years" %}

    +

    {% trans "Recent Role:" %} {{ application.most_recent_job_title }}

    +
    +
    +
    + + {% trans "Skills" %} +
    +

    {% trans "Soft Skills:" %} {{ application.soft_skills_score }}%

    +

    {% trans "Industry Match:" %} + + {{ application.industry_match_score }}% + +

    +
    +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + +
    + + + + + + + + + {% for criterion, status in application.criteria_checklist.items %} + + + + + {% endfor %} + +
    {% trans "Criteria" %}{% trans "Status" %}
    {{ criterion }} + {% if status == "Met" %} + {% trans "Met" %} + {% elif status == "Not Met" %} + {% trans "Not Met" %} + {% else %} + {{ status }} + {% endif %} +
    +
    +
    + +
    +
    +
    + + {% trans "Minimum Requirements" %} +
    + {% if application.min_requirements_met %} + {% trans "Met" %} + {% else %} + {% trans "Not Met" %} + {% endif %} +
    +
    +
    + + {% trans "Screening Rating" %} +
    + {{ application.screening_stage_rating }} +
    +
    + + {% if application.language_fluency %} +
    + +
    + {% for language in application.language_fluency %} + {{ language }} + {% endfor %} +
    +
    + {% endif %} + +{% else %} +
    {% trans "AI Score" %}: {{ application.match_score }}% {{ application.professional_category_ar }}
    + +
    +
    +
    + + {% trans "Job Fit" %} +
    +

    {{ application.job_fit_narrative_ar }}

    +
    +
    +
    + + {% trans "Top Keywords" %} +
    +
    + {% for keyword in application.top_3_keywords_ar %} + {{ keyword }} + {% endfor %} +
    +
    +
    + +
    +
    +
    + + {% trans "Experience" %} +
    +

    {{ application.years_of_experience }} {% trans "years" %}

    +

    {% trans "Recent Role:" %} {{ application.most_recent_job_title_ar }}

    +
    +
    +
    + + {% trans "Skills" %} +
    +

    {% trans "Soft Skills:" %} {{ application.soft_skills_score }}%

    +

    {% trans "Industry Match:" %} + + {{ application.industry_match_score }}% + +

    +
    +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + +
    + + + + + + + + + {% for criterion, status in application.criteria_checklist_ar.items %} + + + + + {% endfor %} + +
    {% trans "Criteria" %}{% trans "Status" %}
    {{ criterion }} + {% if status == "Met" %} + {% trans "Met" %} + {% elif status == "Not Met" %} + {% trans "Not Met" %} + {% else %} + {{ status }} + {% endif %} +
    +
    +
    + +
    +
    +
    + + {% trans "Minimum Requirements" %} +
    + {% if application.min_requirements_met_ar %} + {% trans "Met" %} + {% else %} + {% trans "Not Met" %} + {% endif %} +
    +
    +
    + + {% trans "Screening Rating" %} +
    + {{ application.screening_stage_rating_ar }} +
    +
    + + {% if application.language_fluency_ar %} +
    + +
    + {% for language in application.language_fluency_ar %} + {{ language }} + {% endfor %} +
    +
    + {% endif %} + +{% endif %} \ No newline at end of file diff --git a/templates/includes/candidate_update_exam_form.html b/templates/includes/applications_update_exam_form.html similarity index 77% rename from templates/includes/candidate_update_exam_form.html rename to templates/includes/applications_update_exam_form.html index 859b4ea..790de7d 100644 --- a/templates/includes/candidate_update_exam_form.html +++ b/templates/includes/applications_update_exam_form.html @@ -1,15 +1,15 @@ {% load i18n %} -
    - +
    - + @@ -20,7 +20,7 @@
    - +
    diff --git a/templates/includes/candidate_update_interview_form.html b/templates/includes/applications_update_interview_form.html similarity index 51% rename from templates/includes/candidate_update_interview_form.html rename to templates/includes/applications_update_interview_form.html index 0c67787..74cfd47 100644 --- a/templates/includes/candidate_update_interview_form.html +++ b/templates/includes/applications_update_interview_form.html @@ -1,10 +1,10 @@ {% load i18n %} - \ No newline at end of file diff --git a/templates/includes/candidate_update_offer_form.html b/templates/includes/applications_update_offer_form.html similarity index 52% rename from templates/includes/candidate_update_offer_form.html rename to templates/includes/applications_update_offer_form.html index c81d526..abcd742 100644 --- a/templates/includes/candidate_update_offer_form.html +++ b/templates/includes/applications_update_offer_form.html @@ -1,10 +1,10 @@ {% load i18n %} - \ No newline at end of file diff --git a/templates/includes/candidate_modal_body.html b/templates/includes/candidate_modal_body.html deleted file mode 100644 index 11d426f..0000000 --- a/templates/includes/candidate_modal_body.html +++ /dev/null @@ -1,123 +0,0 @@ -{% load i18n %} -
    {% trans "AI Score" %}: {{ candidate.match_score }}% {{ candidate.professional_category }}
    - -
    -
    -
    - - {% trans "Job Fit" %} -
    -

    {{ candidate.job_fit_narrative }}

    -
    -
    -
    - - {% trans "Top Keywords" %} -
    -
    - {% for keyword in candidate.top_3_keywords %} - {{ keyword }} - {% endfor %} -
    -
    -
    - -
    -
    -
    - - {% trans "Experience" %} -
    -

    {{ candidate.years_of_experience }} {% trans "years" %}

    -

    {% trans "Recent Role:" %} {{ candidate.most_recent_job_title }}

    -
    -
    -
    - - {% trans "Skills" %} -
    -

    {% trans "Soft Skills:" %} {{ candidate.soft_skills_score }}%

    -

    {% trans "Industry Match:" %} - - {{ candidate.industry_match_score }}% - -

    -
    -
    - -
    - - -
    - -
    - - -
    - -
    - - -
    - -
    - -
    - - - - - - - - - {% for criterion, status in candidate.criteria_checklist.items %} - - - - - {% endfor %} - -
    {% trans "Criteria" %}{% trans "Status" %}
    {{ criterion }} - {% if status == "Met" %} - {% trans "Met" %} - {% elif status == "Not Met" %} - {% trans "Not Met" %} - {% else %} - {{ status }} - {% endif %} -
    -
    -
    - -
    -
    -
    - - {% trans "Minimum Requirements" %} -
    - {% if candidate.min_requirements_met %} - {% trans "Met" %} - {% else %} - {% trans "Not Met" %} - {% endif %} -
    -
    -
    - - {% trans "Screening Rating" %} -
    - {{ candidate.screening_stage_rating }} -
    -
    - -{% if candidate.language_fluency %} -
    - -
    - {% for language in candidate.language_fluency %} - {{ language }} - {% endfor %} -
    -
    -{% endif %} diff --git a/templates/includes/document_list.html b/templates/includes/document_list.html index 481d94f..e6ad3ad 100644 --- a/templates/includes/document_list.html +++ b/templates/includes/document_list.html @@ -26,7 +26,7 @@ - {% if user.is_superuser or candidate.job.assigned_to == user %} + {% if user.is_superuser or application.job.assigned_to == user %}
    {% endif %} diff --git a/templates/includes/email_compose_form.html b/templates/includes/email_compose_form.html index 68ac5ff..269ad2b 100644 --- a/templates/includes/email_compose_form.html +++ b/templates/includes/email_compose_form.html @@ -17,7 +17,7 @@
    -
    - {{ interview.candidate.name }} + {{ interview.candidate.name }}
    {{ interview.status|title }} @@ -169,7 +169,7 @@ - {{ interview.candidate.name }} + {{ interview.candidate.name }} diff --git a/templates/jobs/edit_job.html b/templates/jobs/edit_job.html index 2ca2e86..23a6138 100644 --- a/templates/jobs/edit_job.html +++ b/templates/jobs/edit_job.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% load static i18n %} -{% block title %}Create New Job Post - {{ block.super }}{% endblock %} +{% block title %}{% trans "Create New Job Post" %} - {{ block.super }}{% endblock %} {% block customCSS %} diff --git a/templates/jobs/job_candidates_list.html b/templates/jobs/job_applications_list.html similarity index 96% rename from templates/jobs/job_candidates_list.html rename to templates/jobs/job_applications_list.html index a7aacd6..0cf86bf 100644 --- a/templates/jobs/job_candidates_list.html +++ b/templates/jobs/job_applications_list.html @@ -127,7 +127,7 @@
    @@ -186,7 +186,7 @@
    @@ -250,16 +250,16 @@ {{ candidate.created_at|date:"M d, Y" }}
    - + {% if user.is_staff %} - + @@ -345,17 +345,17 @@