` bullet points. Encapsulate the entire formatted block within a single `
`.
+ 2. **Format the Qualifications:** Organize and format the raw QUALIFICATIONS data into clear, readable sections using `
` headings and `
`/`
` bullet points. Encapsulate the entire formatted block within a single `
`.
+ 3. **Format the Benefits:** Organize and format the raw Requirements data into clear, readable sections using `
` headings and `
`/`
` bullet points. Encapsulate the entire formatted block within a single `
`.
+ 4. **Application Instructions:** Organize and format the raw Requirements data into clear, readable sections using `
` headings and `
`/`
` bullet points. Encapsulate the entire formatted block within a single `
`.
+
+
+ **TASK 2: LinkedIn Post Creation**
+ 1. **Write the Post:** Create an engaging, professional, and concise LinkedIn post (maximum 1300 characters) summarizing the opportunity.
+ 2. **Encourage Action:** The post must have a strong call-to-action (CTA) encouraging applications.
+ 3. **Use Hashtags:** Integrate relevant industry, role, and company hashtags (including any provided in the raw input) naturally at the end of the post.
+
+ **STRICT JSON OUTPUT INSTRUCTIONS:**
+ Output a **single, valid JSON object** with **ONLY** the following three top-level key-value pairs.
+
+ * The values for `html_job_description` and `html_qualifications` MUST be the complete, formatted HTML strings (including all tags).
+ * The value for `linkedin_post` MUST be the complete, final LinkedIn post as a single string not greater than 3000 characters.
+
+ **Output Keys:**
+ 1. `html_job_description`
+ 2. `html_qualifications`
+ 3. 'html_benefits'
+ 4. 'html_application_instructions'
+ 5. `linkedin_post_data`
+
+ **Do not include any other text, explanation, or markdown outside of the final JSON object.**
"""
result = ai_handler(prompt)
-
+ print(f"REsults: {result}")
if result['status'] == 'error':
logger.error(f"AI handler returned error for candidate {job_posting.pk}")
print(f"AI handler returned error for candidate {job_posting.pk}")
@@ -144,9 +180,12 @@ def format_job_description(pk):
data = json.loads(data)
print(data)
- job_posting.description = data.get('job_description')
- job_posting.qualifications = data.get('job_qualifications')
- job_posting.save(update_fields=['description', 'qualifications'])
+ job_posting.description = data.get('html_job_description')
+ job_posting.qualifications = data.get('html_qualifications')
+ job_posting.benefits=data.get('html_benefits')
+ job_posting.application_instructions=data.get('html_application_instruction')
+ job_posting.linkedin_post_formated_data=data.get('linkedin_post_data')
+ job_posting.save(update_fields=['description', 'qualifications','linkedin_post_formated_data'])
def ai_handler(prompt):
@@ -400,6 +439,8 @@ def handle_reume_parsing_and_scoring(pk):
logger.info(f"Successfully scored and saved analysis for candidate {instance.id}")
print(f"Successfully scored and saved analysis for candidate {instance.id}")
+
+
def create_interview_and_meeting(
candidate_id,
job_id,
diff --git a/recruitment/urls.py b/recruitment/urls.py
index 5ea13e2..49fb42a 100644
--- a/recruitment/urls.py
+++ b/recruitment/urls.py
@@ -65,7 +65,8 @@ urlpatterns = [
path('forms/builder//', views.form_builder, name='form_builder'),
path('forms/', views.form_templates_list, name='form_templates_list'),
path('forms/create-template/', views.create_form_template, name='create_form_template'),
-
+
+ path('jobs//edit_linkedin_post_content/',views.edit_linkedin_post_content,name='edit_linkedin_post_content'),
path('jobs//candidate_screening_view/', views.candidate_screening_view, name='candidate_screening_view'),
path('jobs//candidate_exam_view/', views.candidate_exam_view, name='candidate_exam_view'),
path('jobs//candidate_interview_view/', views.candidate_interview_view, name='candidate_interview_view'),
@@ -217,4 +218,12 @@ urlpatterns = [
path('notifications//delete/', views.notification_delete, name='notification_delete'),
path('notifications/mark-all-read/', views.notification_mark_all_read, name='notification_mark_all_read'),
path('api/notification-count/', views.api_notification_count, name='api_notification_count'),
+
+
+ #participants urls
+ path('participants/', views_frontend.ParticipantsListView.as_view(), name='participants_list'),
+ path('participants/create/', views_frontend.ParticipantsCreateView.as_view(), name='participants_create'),
+ path('participants//', views_frontend.ParticipantsDetailView.as_view(), name='participants_detail'),
+ path('participants//update/', views_frontend.ParticipantsUpdateView.as_view(), name='participants_update'),
+ path('participants//delete/', views_frontend.ParticipantsDeleteView.as_view(), name='participants_delete'),
]
diff --git a/recruitment/utils.py b/recruitment/utils.py
index 4b24c1a..af759df 100644
--- a/recruitment/utils.py
+++ b/recruitment/utils.py
@@ -612,4 +612,8 @@ def update_meeting(instance, updated_data):
return {"status": "success", "message": "Zoom meeting updated successfully."}
logger.warning(f"Failed to update Zoom meeting {instance.meeting_id}. Error: {result.get('message', 'Unknown error')}")
- return {"status": "error", "message": result.get("message", "Zoom meeting update failed.")}
\ No newline at end of file
+ return {"status": "error", "message": result.get("message", "Zoom meeting update failed.")}
+
+
+
+
diff --git a/recruitment/views.py b/recruitment/views.py
index 5b004e5..b2d40cf 100644
--- a/recruitment/views.py
+++ b/recruitment/views.py
@@ -17,7 +17,8 @@ from django.urls import reverse
from django.conf import settings
from django.utils import timezone
from django.db.models import FloatField,CharField, DurationField
-from django.db.models.functions import Cast
+from django.db.models import F, IntegerField, Count, Avg, Sum, Q, ExpressionWrapper, fields
+from django.db.models.functions import Cast, Coalesce, TruncDate
from django.db.models.fields.json import KeyTextTransform
from django.db.models.expressions import ExpressionWrapper
from django.db.models import Count, Avg, F,Q
@@ -38,7 +39,9 @@ from .forms import (
AgencyCandidateSubmissionForm,
AgencyLoginForm,
AgencyAccessLinkForm,
- AgencyJobAssignmentForm
+ AgencyJobAssignmentForm,
+ LinkedPostContentForm,
+ ParticipantsSelectForm
)
from easyaudit.models import CRUDEvent, LoginEvent, RequestEvent
from rest_framework import viewsets
@@ -332,6 +335,8 @@ def edit_job(request, slug):
return render(request, "jobs/edit_job.html", {"form": form, "job": job})
+SCORE_PATH = 'ai_analysis_data__analysis_data__match_score'
+HIGH_POTENTIAL_THRESHOLD=75
@login_required
def job_detail(request, slug):
"""View details of a specific job"""
@@ -351,6 +356,7 @@ def job_detail(request, slug):
offer_count = applicants.filter(stage="Offer").count()
status_form = JobPostingStatusForm(instance=job)
+ linkedin_content_form=LinkedPostContentForm(instance=job)
try:
# If the related object exists, use its instance data
image_upload_form = JobPostingImageForm(instance=job.post_images)
@@ -365,6 +371,15 @@ def job_detail(request, slug):
status_form = JobPostingStatusForm(request.POST, instance=job)
if status_form.is_valid():
+ job_status=status_form.cleaned_data['status']
+ form_template=job.form_template
+ if job_status=='ACTIVE':
+ form_template.is_active=True
+ form_template.save(update_fields=['is_active'])
+ else:
+ form_template.is_active=False
+ form_template.save(update_fields=['is_active'])
+
status_form.save()
# Add a success message
@@ -381,29 +396,31 @@ def job_detail(request, slug):
# --- 2. Quality Metrics (JSON Aggregation) ---
# Filter for candidates who have been scored and annotate with a sortable score
- candidates_with_score = applicants.filter(is_resume_parsed=True).annotate(
- # Extract the score as TEXT
- score_as_text=KeyTextTransform(
- 'match_score',
- KeyTextTransform('resume_data', F('ai_analysis_data'))
- )
+ # candidates_with_score = applicants.filter(is_resume_parsed=True).annotate(
+ # # Extract the score as TEXT
+ # score_as_text=KeyTextTransform(
+ # 'match_score',
+ # KeyTextTransform('resume_data', F('ai_analysis_data'))
+ # )
+ # ).annotate(
+ # # Cast the extracted text score to a FloatField for numerical operations
+ # sortable_score=Cast('score_as_text', output_field=FloatField())
+ # )
+ candidates_with_score = applicants.filter(
+ is_resume_parsed=True
).annotate(
- # Cast the extracted text score to a FloatField for numerical operations
- sortable_score=Cast('score_as_text', output_field=FloatField())
+ annotated_match_score=Coalesce(
+ Cast(SCORE_PATH, output_field=IntegerField()),
+ 0
+ )
)
+ 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(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
- # Aggregate: Average Match Score
- avg_match_score_result = candidates_with_score.aggregate(
- avg_score=Avg('sortable_score')
- )['avg_score']
- avg_match_score = round(avg_match_score_result or 0, 1)
-
- # Metric: High Potential Count (Score >= 75)
- high_potential_count = candidates_with_score.filter(
- sortable_score__gte=75
- ).count()
- high_potential_ratio = round((high_potential_count / total_applicant) * 100, 1) if total_applicant > 0 else 0
-
+
# --- 3. Time Metrics (Duration Aggregation) ---
# Metric: Average Time from Applied to Interview (T2I)
@@ -470,6 +487,7 @@ def job_detail(request, slug):
'high_potential_ratio': high_potential_ratio,
'avg_t2i_days': avg_t2i_days,
'avg_t_in_exam_days': avg_t_in_exam_days,
+ 'linkedin_content_form':linkedin_content_form
}
return render(request, "jobs/job_detail.html", context)
@@ -507,6 +525,30 @@ def job_image_upload(request, slug):
return redirect('job_detail', slug=job.slug)
+@login_required
+def edit_linkedin_post_content(request,slug):
+
+ job=get_object_or_404(JobPosting,slug=slug)
+ linkedin_content_form=LinkedPostContentForm(instance=job)
+ if request.method=='POST':
+ linkedin_content_form=LinkedPostContentForm(request.POST,instance=job)
+ if linkedin_content_form.is_valid():
+ linkedin_content_form.save()
+ messages.success(request,"Linked post content updated successfully!")
+ return redirect('job_detail',job.slug)
+ else:
+ messages.error(request,"Error update the Linkedin Post content")
+ return redirect('job_detail',job.slug)
+
+ else:
+ linkedin_content_form=LinkedPostContentForm()
+ return redirect('job_detail',job.slug)
+
+
+
+
+
+
def kaauh_career(request):
active_jobs = JobPosting.objects.select_related(
'form_template'
@@ -1391,8 +1433,43 @@ def candidate_update_status(request, slug):
@login_required
def candidate_interview_view(request,slug):
- job = get_object_or_404(JobPosting, slug=slug)
- context = {"job":job,"candidates":job.interview_candidates,'current_stage':'Interview'}
+ job = get_object_or_404(JobPosting,slug=slug)
+
+ if request.method == "POST":
+ form = ParticipantsSelectForm(request.POST, instance=job)
+ print(form.errors)
+
+ if form.is_valid():
+
+ # Save the main instance (JobPosting)
+ job_instance = form.save(commit=False)
+ job_instance.save()
+
+ # MANUALLY set the M2M relationships based on submitted data
+ job_instance.participants.set(form.cleaned_data['participants'])
+ job_instance.users.set(form.cleaned_data['users'])
+
+ messages.success(request, "Interview participants updated successfully.")
+ return redirect("candidate_interview_view", slug=job.slug)
+
+ else:
+ # π FIX: Explicitly pass the initial data for M2M fields
+ initial_data = {
+ 'participants': job.participants.all(),
+ 'users': job.users.all(),
+ }
+ form = ParticipantsSelectForm(instance=job, initial=initial_data)
+
+ else:
+ form = ParticipantsSelectForm(instance=job)
+
+
+ context = {
+ "job":job,
+ "candidates":job.interview_candidates,
+ 'current_stage':'Interview',
+ 'form':form
+ }
return render(request,"recruitment/candidate_interview_view.html",context)
@login_required
diff --git a/recruitment/views_frontend.py b/recruitment/views_frontend.py
index ff1a119..f0b25cc 100644
--- a/recruitment/views_frontend.py
+++ b/recruitment/views_frontend.py
@@ -243,7 +243,7 @@ def candidate_detail(request, slug):
stage_form = None
if request.user.is_staff:
stage_form = forms.CandidateStageForm()
-
+
# 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', {
@@ -339,110 +339,227 @@ class TrainingDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView):
success_url = reverse_lazy('training_list')
success_message = 'Training material deleted successfully.'
+from django.db.models import F, IntegerField, Count, Avg, Sum, Q, ExpressionWrapper, fields
+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
+
+# IMPORTANT: Ensure 'models' correctly refers to your Django models file
+# Example: from . import models
+
+# --- Constants ---
+SCORE_PATH = 'ai_analysis_data__analysis_data__match_score'
+HIGH_POTENTIAL_THRESHOLD = 75
+MAX_TIME_TO_HIRE_DAYS = 90
+TARGET_TIME_TO_HIRE_DAYS = 45 # Used for the template visualization
+
@login_required
def dashboard_view(request):
- # --- Performance Optimization: Aggregate Data in ONE Query ---
+
+ selected_job_pk = request.GET.get('selected_job_pk')
+ today = timezone.now().date()
+
+ # --- 1. BASE QUERYSETS & GLOBAL METRICS (UNFILTERED) ---
+
+ all_jobs_queryset = models.JobPosting.objects.all().order_by('-created_at')
+ all_candidates_queryset = models.Candidate.objects.all()
- # 1. Base Job Query: Get all jobs and annotate with candidate count
- jobs_with_counts = models.JobPosting.objects.annotate(
- candidate_count=Count('candidates')
- ).order_by('-candidate_count')
+ # Global KPI Card Metrics
+ total_jobs_global = all_jobs_queryset.count()
+ total_participants = models.Participants.objects.count()
+ total_jobs_posted_linkedin = all_jobs_queryset.filter(linkedin_post_id__isnull=False).count()
+
+ # Data for Job App Count Chart (always for ALL jobs)
+ job_titles = [job.title for job in all_jobs_queryset]
+ job_app_counts = [job.candidates.count() for job in all_jobs_queryset]
- total_jobs = jobs_with_counts.count()
- total_candidates = models.Candidate.objects.count()
+ # --- 2. TIME SERIES: GLOBAL DAILY APPLICANTS ---
+
+ # Group ALL candidates by creation date
+ global_daily_applications_qs = all_candidates_queryset.annotate(
+ date=TruncDate('created_at')
+ ).values('date').annotate(
+ count=Count('pk')
+ ).order_by('date')
- job_titles = [job.title for job in jobs_with_counts]
- job_app_counts = [job.candidate_count for job in jobs_with_counts]
+ global_dates = [item['date'].strftime('%Y-%m-%d') for item in global_daily_applications_qs]
+ global_counts = [item['count'] for item in global_daily_applications_qs]
- average_applications = round(jobs_with_counts.aggregate(
- avg_apps=Avg('candidate_count')
- )['avg_apps'] or 0, 2)
- # 5. New: Candidate Quality & Funnel Metrics
+ # --- 3. FILTERING LOGIC: Determine the scope for scoped metrics ---
+
+ candidate_queryset = all_candidates_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)
+ interview_queryset = interview_queryset.filter(job__pk=selected_job_pk)
+
+ try:
+ current_job = all_jobs_queryset.get(pk=selected_job_pk)
+ job_scope_queryset = models.JobPosting.objects.filter(pk=selected_job_pk)
+ except models.JobPosting.DoesNotExist:
+ pass
- # Assuming 'match_score' is a direct IntegerField/FloatField on the Candidate model
- # (based on the final, optimized version of handle_reume_parsing_and_scoring)
+ # --- 4. TIME SERIES: SCOPED DAILY APPLICANTS ---
- # Average Match Score (Overall Quality)
- candidates_with_score = models.Candidate.objects.filter(
- # Filter only candidates that have been parsed/scored
+ # Only run if a specific job is selected
+ scoped_dates = []
+ scoped_counts = []
+ if selected_job_pk:
+ scoped_daily_applications_qs = candidate_queryset.annotate(
+ date=TruncDate('created_at')
+ ).values('date').annotate(
+ count=Count('pk')
+ ).order_by('date')
+
+ scoped_dates = [item['date'].strftime('%Y-%m-%d') for item in scoped_daily_applications_qs]
+ scoped_counts = [item['count'] for item in scoped_daily_applications_qs]
+
+
+ # --- 5. SCOPED CORE AGGREGATIONS (FILTERED OR ALL) ---
+
+ total_candidates = candidate_queryset.count()
+
+ candidates_with_score_query = candidate_queryset.filter(
is_resume_parsed=True
).annotate(
- score_as_text=KeyTextTransform(
- 'match_score',
- KeyTextTransform('scoring_data', F('ai_analysis_data'))
+ annotated_match_score=Coalesce(
+ Cast(SCORE_PATH, output_field=IntegerField()),
+ 0
)
- ).annotate(
- # Cast the extracted text score to a FloatField so AVG() can operate on it.
- sortable_score=Cast('score_as_text', output_field=FloatField())
)
- # 2b. AGGREGATE using the newly created 'sortable_score' field
- avg_match_score_result = candidates_with_score.aggregate(
- avg_score=Avg('sortable_score')
- )['avg_score']
+ # 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()
+
+ open_positions_agg = job_scope_queryset.filter(status="ACTIVE").aggregate(total_open=Sum('open_positions'))
+ total_open_positions = open_positions_agg['total_open'] or 0
+ average_applications_result = job_scope_queryset.annotate(
+ candidate_count=Count('candidates', distinct=True)
+ ).aggregate(avg_apps=Avg('candidate_count'))['avg_apps']
+ average_applications = round(average_applications_result or 0, 2)
- avg_match_score = round(avg_match_score_result or 0, 1)
- # 2c. Use the annotated QuerySet for other metrics
+ # B. Efficiency & Conversion Metrics (Scoped)
+ hired_candidates = candidate_queryset.filter(
+ Q(offer_status="Accepted") | Q(stage='HIRED'),
+ join_date__isnull=False
+ )
+ time_to_hire_query = hired_candidates.annotate(
+ time_diff=ExpressionWrapper(
+ F('join_date') - F('created_at__date'),
+ output_field=fields.DurationField()
+ )
+ ).aggregate(avg_time_to_hire=Avg('time_diff'))
+ avg_time_to_hire_days = (
+ time_to_hire_query.get('avg_time_to_hire').days
+ if time_to_hire_query.get('avg_time_to_hire') else 0
+ )
+
+ applied_count = candidate_queryset.filter(stage='Applied').count()
+ advanced_count = candidate_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_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
- # Scored Candidates Ratio (Now simpler, as we filtered the QuerySet)
- total_scored = candidates_with_score.count()
- scored_ratio = round((total_scored / total_candidates) * 100, 1) if total_candidates > 0 else 0
- # High Potential Candidates (Filter the annotated QuerySet)
- high_potential_count = candidates_with_score.filter(
- sortable_score__gte=75
+ # C. Activity & Quality Metrics (Scoped)
+ current_year, current_week, _ = today.isocalendar()
+ meetings_scheduled_this_week = interview_queryset.filter(
+ interview_date__week=current_week, interview_date__year=current_year
).count()
- high_potential_ratio = round((high_potential_count / total_candidates) * 100, 1) if total_candidates > 0 else 0
+ avg_match_score_result = candidates_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
- jobs=models.JobPosting.objects.all().order_by('internal_job_id')
- selected_job_id=request.GET.get('selected_job_id','')
- candidate_stage=['APPLIED','EXAM','INTERVIEW','OFFER']
- apply_count,exam_count,interview_count,offer_count=[0]*4
- if selected_job_id:
- job=jobs.get(internal_job_id=selected_job_id)
- apply_count=job.screening_candidates_count
- exam_count=job.exam_candidates_count
- interview_count=job.interview_candidates_count
- offer_count=job.offer_candidates_count
- all_candidates_count=job.all_candidates_count
+ # --- 6. CHART DATA PREPARATION ---
- else: #default job
- job=jobs.first()
- apply_count=job.screening_candidates_count
- exam_count=job.exam_candidates_count
- interview_count=job.interview_candidates_count
- offer_count=job.offer_candidates_count
- all_candidates_count=job.all_candidates_count
- candidates_count=[ apply_count,exam_count,interview_count,offer_count ]
+ # A. Pipeline Funnel (Scoped)
+ stage_counts = candidate_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 = [
+ stage_map.get('Applied', 0), stage_map.get('Exam', 0), stage_map.get('Interview', 0),
+ stage_map.get('Offer', 0), filled_positions
+ ]
+ # --- 7. GAUGE CHART CALCULATION (Time-to-Hire) ---
+
+ current_days = avg_time_to_hire_days
+ rotation_percent = current_days / MAX_TIME_TO_HIRE_DAYS if MAX_TIME_TO_HIRE_DAYS > 0 else 0
+ rotation_degrees = rotation_percent * 180
+ rotation_degrees_final = round(min(rotation_degrees, 180), 1) # Ensure max 180 degrees
+
+
+ # --- 8. CONTEXT RETURN ---
+
context = {
- 'total_jobs': total_jobs,
+ # Global KPIs
+ 'total_jobs_global': total_jobs_global,
+ 'total_participants': total_participants,
+ 'total_jobs_posted_linkedin': total_jobs_posted_linkedin,
+
+ # Scoped KPIs
+ 'total_active_jobs': total_active_jobs,
'total_candidates': total_candidates,
+ 'new_candidates_7days': new_candidates_7days,
+ 'total_open_positions': total_open_positions,
'average_applications': average_applications,
-
- # Chart Data
- 'job_titles': json.dumps(job_titles),
- 'job_app_counts': json.dumps(job_app_counts),
-
- # New Analytical Metrics (FIXED)
- 'avg_match_score': avg_match_score,
+ 'avg_time_to_hire_days': avg_time_to_hire_days,
+ 'screening_pass_rate': screening_pass_rate,
+ 'offers_accepted_rate': offers_accepted_rate,
+ 'vacancy_fill_rate': vacancy_fill_rate,
+ 'meetings_scheduled_this_week': meetings_scheduled_this_week,
+ 'avg_match_score': avg_match_score,
'high_potential_count': high_potential_count,
'high_potential_ratio': high_potential_ratio,
'scored_ratio': scored_ratio,
- 'current_job_id':selected_job_id,
- 'jobs':jobs,
- 'all_candidates_count':all_candidates_count,
- 'candidate_stage':json.dumps(candidate_stage),
- 'candidates_count':json.dumps(candidates_count)
- ,'my_job':job
+
+ # Chart Data
+ 'candidate_stage': json.dumps(candidate_stage),
+ 'candidates_count': json.dumps(candidates_count),
+ 'job_titles': json.dumps(job_titles),
+ 'job_app_counts': json.dumps(job_app_counts),
+ # 'source_volume_chart_data' is intentionally REMOVED
+ # Time Series Data
+ 'global_dates': json.dumps(global_dates),
+ 'global_counts': json.dumps(global_counts),
+ 'scoped_dates': json.dumps(scoped_dates),
+ 'scoped_counts': json.dumps(scoped_counts),
+ 'is_job_scoped': bool(selected_job_pk),
+ # Gauge Data
+ 'gauge_max_days': MAX_TIME_TO_HIRE_DAYS,
+ 'gauge_target_days': TARGET_TIME_TO_HIRE_DAYS,
+ 'gauge_rotation_degrees': rotation_degrees_final,
+
+ # UI Control
+ 'jobs': all_jobs_queryset,
+ 'current_job_id': selected_job_pk,
+ 'current_job': current_job,
}
+
return render(request, 'recruitment/dashboard.html', context)
+
+
@login_required
def candidate_offer_view(request, slug):
"""View for candidates in the Offer stage"""
@@ -859,3 +976,71 @@ def sync_history(request, job_slug=None):
}
return render(request, 'recruitment/sync_history.html', context)
+
+
+#participants views
+class ParticipantsListView(LoginRequiredMixin, ListView):
+ model = models.Participants
+ template_name = 'participants/participants_list.html'
+ context_object_name = 'participants'
+ paginate_by = 10
+
+ def get_queryset(self):
+ queryset = super().get_queryset()
+
+ # Handle search
+ search_query = self.request.GET.get('search', '')
+ if search_query:
+ queryset = queryset.filter(
+ Q(name__icontains=search_query) |
+ Q(email__icontains=search_query) |
+ Q(phone__icontains=search_query) |
+ Q(designation__icontains=search_query)
+ )
+
+ # Filter for non-staff users
+ if not self.request.user.is_staff:
+ return models.Participants.objects.none() # Restrict for non-staff
+
+ return queryset.order_by('-created_at')
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context['search_query'] = self.request.GET.get('search', '')
+ return context
+class ParticipantsDetailView(LoginRequiredMixin, DetailView):
+ model = models.Participants
+ template_name = 'participants/participants_detail.html'
+ context_object_name = 'participant'
+ slug_url_kwarg = 'slug'
+
+class ParticipantsCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView):
+ model = models.Participants
+ form_class = forms.ParticipantsForm
+ template_name = 'participants/participants_create.html'
+ success_url = reverse_lazy('job_list')
+ success_message = 'Participant created successfully.'
+
+ # def get_initial(self):
+ # initial = super().get_initial()
+ # if 'slug' in self.kwargs:
+ # job = get_object_or_404(models.JobPosting, slug=self.kwargs['slug'])
+ # initial['jobs'] = [job]
+ # return initial
+
+
+
+class ParticipantsUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView):
+ model = models.Participants
+ form_class = forms.ParticipantsForm
+ template_name = 'participants/participants_create.html'
+ success_url = reverse_lazy('job_list')
+ success_message = 'Participant updated successfully.'
+ slug_url_kwarg = 'slug'
+
+class ParticipantsDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView):
+ model = models.Participants
+
+ success_url = reverse_lazy('participants_list') # Redirect to the participants list after success
+ success_message = 'Participant deleted successfully.'
+ slug_url_kwarg = 'slug'
\ No newline at end of file
diff --git a/templates/account/email/email_confirmation_message.html b/templates/account/email/email_confirmation_message.html
index fe01de7..ef7f706 100644
--- a/templates/account/email/email_confirmation_message.html
+++ b/templates/account/email/email_confirmation_message.html
@@ -6,7 +6,7 @@
- {% blocktrans %}Thank you for choosing **KAAUH ATS**. To verify the ownership of your email address, please click the confirmation link below:{% endblocktrans %}
+ {% blocktrans %}To verify the ownership of your email address, please click the confirmation link below:{% endblocktrans %}
diff --git a/templates/account/email/email_confirmation_message.txt b/templates/account/email/email_confirmation_message.txt
new file mode 100644
index 0000000..5dd0cb4
--- /dev/null
+++ b/templates/account/email/email_confirmation_message.txt
@@ -0,0 +1,18 @@
+{% load account i18n %}
+{% autoescape off %}
+
+{% blocktrans %}Hello,{% endblocktrans %}
+
+{% blocktrans %}To verify the ownership of your email address, please click the confirmation link below:{% endblocktrans %}
+
+
+{% trans "Confirm My KAAUH ATS Email" %}
+{{ activate_url }}
+
+
+{% blocktrans %}If you did not request this verification, you can safely ignore this email.{% endblocktrans %}
+
+{% blocktrans %}Alternatively, copy and paste this link into your browser:{% endblocktrans %}
+{{ activate_url }}
+
+{% endautoescape %}
\ No newline at end of file
diff --git a/templates/account/email/password_reset_key_message.txt b/templates/account/email/password_reset_key_message.txt
new file mode 100644
index 0000000..7930487
--- /dev/null
+++ b/templates/account/email/password_reset_key_message.txt
@@ -0,0 +1,27 @@
+{% load i18n %}
+{% load static %}
+{% autoescape off %}
+
+{% trans "Password Reset Request" %}
+
+{% trans "Hello," %}
+
+{% blocktrans %}You are receiving this email because you or someone else has requested a password reset for your account at{% endblocktrans %} {{ current_site.name }}.
+
+------------------------------------------------------
+{% trans "Click Here to Reset Your Password" %}
+{{ password_reset_url }}
+------------------------------------------------------
+
+{% trans "This link is only valid for a limited time." %}
+
+{% trans "If you did not request a password reset, please ignore this email. Your password will remain unchanged." %}
+
+{% trans "Thank you," %}
+{% trans "KAAUH ATS Team" %}
+
+---
+{% trans "If the button above does not work, copy and paste the following link into your browser:" %}
+{{ password_reset_url }}
+
+{% endautoescape %}
\ No newline at end of file
diff --git a/templates/account/login.html b/templates/account/login.html
index b7e66c7..5cf52e8 100644
--- a/templates/account/login.html
+++ b/templates/account/login.html
@@ -1,6 +1,8 @@
-{% load static %}
+{% load static i18n %}
+{% get_current_language_bidi as LANGUAGE_BIDI %}
+{% get_current_language as LANGUAGE_CODE %}
-
+
@@ -146,34 +148,34 @@
-
- {# --- API RESPONSE CARD (Full width, hidden by default) --- #}
- {% if meeting.zoom_gateway_response %}
-
-
-
{% trans "API Gateway Response" %}
-
{{ meeting.zoom_gateway_response|safe }}
-
-
- {% endif %}
-
-
-
-{# MODALS (KEEP OUTSIDE OF THE MAIN LAYOUT ROWS) #}
-{% comment %} {% include 'modals/delete_modal.html' with item_name="Meeting" delete_url_name='delete_meeting' %} {% endcomment %}
-
+ {% url 'participant_list' as participant_list_url %}
+
+
+
+
+
+
+
+ {% if participants %}
+
+ {# View Switcher - list_id must match the container ID #}
+ {% include "includes/_list_view_switcher.html" with list_id="participant-list" %}
+
+ {# Table View (Default) #}
+
{{ comment.content|safe }}
-+
{{ comment.content|linebreaksbr }}
{% trans "No comments yet. Be the first to comment!" %}
{% endif %}- {# --- RIGHT COLUMN (MAIN DETAILS & JOIN INFO) - Takes 50% of the screen #} -
{% trans "Associated Record" %}
- - - {{ meeting.interview.candidate.name }} - - - {{ meeting.interview.job_position }} + {# 2. NEW COMMENT SUBMISSION (Remains the same) #} +{% trans "Add a New Comment" %}
+ {% if user.is_authenticated %} +- - {{ meeting.topic }} -
-{% trans "Connection Details" %}
-{% trans "You must be logged in to add a comment." %}
+ {% endif %}{% trans "Join Information" %}
- - - {% trans "Join Meeting Now" %} - - -{% trans "API Gateway Response" %}
-{{ meeting.zoom_gateway_response|safe }}-