diff --git a/NorahUniversity/__pycache__/settings.cpython-313.pyc b/NorahUniversity/__pycache__/settings.cpython-313.pyc index 9395c44..73b3ff6 100644 Binary files a/NorahUniversity/__pycache__/settings.cpython-313.pyc and b/NorahUniversity/__pycache__/settings.cpython-313.pyc differ diff --git a/NorahUniversity/__pycache__/urls.cpython-313.pyc b/NorahUniversity/__pycache__/urls.cpython-313.pyc index e7abc55..180c130 100644 Binary files a/NorahUniversity/__pycache__/urls.cpython-313.pyc and b/NorahUniversity/__pycache__/urls.cpython-313.pyc differ diff --git a/NorahUniversity/settings.py b/NorahUniversity/settings.py index 6bb6388..e2d8d8f 100644 --- a/NorahUniversity/settings.py +++ b/NorahUniversity/settings.py @@ -223,6 +223,7 @@ ZOOM_ACCOUNT_ID = 'HoGikHXsQB2GNDC5Rvyw9A' ZOOM_CLIENT_ID = 'brC39920R8C8azfudUaQgA' ZOOM_CLIENT_SECRET = 'rvfhjlbID4ychXPOvZ2lYsoAC0B0Ny2L' SECRET_TOKEN = '6KdTGyF0SSCSL_V4Xa34aw' +ZOOM_WEBHOOK_API_KEY = "2GNDC5Rvyw9AHoGikHXsQB" # Maximum file upload size (in bytes) DATA_UPLOAD_MAX_MEMORY_SIZE = 10485760 # 10MB @@ -245,7 +246,7 @@ LINKEDIN_REDIRECT_URI = 'http://127.0.0.1:8000/jobs/linkedin/callback/' Q_CLUSTER = { 'name': 'KAAUH_CLUSTER', - 'workers': 4, + 'workers': 8, 'recycle': 500, 'timeout': 60, 'compress': True, diff --git a/NorahUniversity/urls.py b/NorahUniversity/urls.py index d96e6c8..ce84662 100644 --- a/NorahUniversity/urls.py +++ b/NorahUniversity/urls.py @@ -28,6 +28,7 @@ urlpatterns = [ path('api/templates/save/', views.save_form_template, name='save_form_template'), path('api/templates//', views.load_form_template, name='load_form_template'), path('api/templates//delete/', views.delete_form_template, name='delete_form_template'), + path('api/webhook/',views.zoom_webhook_view,name='zoom_webhook_view') ] urlpatterns += i18n_patterns( diff --git a/recruitment/__pycache__/forms.cpython-313.pyc b/recruitment/__pycache__/forms.cpython-313.pyc index c5aac7d..606d3d5 100644 Binary files a/recruitment/__pycache__/forms.cpython-313.pyc and b/recruitment/__pycache__/forms.cpython-313.pyc differ diff --git a/recruitment/__pycache__/models.cpython-313.pyc b/recruitment/__pycache__/models.cpython-313.pyc index ddb6d5e..ebc6d92 100644 Binary files a/recruitment/__pycache__/models.cpython-313.pyc and b/recruitment/__pycache__/models.cpython-313.pyc differ diff --git a/recruitment/__pycache__/urls.cpython-313.pyc b/recruitment/__pycache__/urls.cpython-313.pyc index eda9760..46150fb 100644 Binary files a/recruitment/__pycache__/urls.cpython-313.pyc and b/recruitment/__pycache__/urls.cpython-313.pyc differ diff --git a/recruitment/__pycache__/utils.cpython-313.pyc b/recruitment/__pycache__/utils.cpython-313.pyc index d6f5ba5..3218023 100644 Binary files a/recruitment/__pycache__/utils.cpython-313.pyc and b/recruitment/__pycache__/utils.cpython-313.pyc differ diff --git a/recruitment/__pycache__/views.cpython-313.pyc b/recruitment/__pycache__/views.cpython-313.pyc index 415073d..fe6f2a1 100644 Binary files a/recruitment/__pycache__/views.cpython-313.pyc and b/recruitment/__pycache__/views.cpython-313.pyc differ diff --git a/recruitment/__pycache__/views_frontend.cpython-313.pyc b/recruitment/__pycache__/views_frontend.cpython-313.pyc index 61611f1..961ccff 100644 Binary files a/recruitment/__pycache__/views_frontend.cpython-313.pyc and b/recruitment/__pycache__/views_frontend.cpython-313.pyc differ diff --git a/recruitment/forms.py b/recruitment/forms.py index 9c3c193..a43ace2 100644 --- a/recruitment/forms.py +++ b/recruitment/forms.py @@ -197,8 +197,8 @@ class JobPostingForm(forms.ModelForm): 'location_city', 'location_state', 'location_country', 'description', 'qualifications', 'salary_range', 'benefits','application_start_date' ,'application_deadline', 'application_instructions', - 'position_number', 'reporting_to', 'joining_date', 'status', - 'created_by','open_positions','hash_tags' + 'position_number', 'reporting_to', 'joining_date', + 'created_by','open_positions','hash_tags','max_applications' ] widgets = { # Basic Information @@ -285,6 +285,11 @@ class JobPostingForm(forms.ModelForm): 'class': 'form-control', 'placeholder': 'University Administrator' }), + 'max_applications': forms.NumberInput(attrs={ + 'class': 'form-control', + 'min': 1, + 'placeholder': 'Maximum number of applicants' + }), } def __init__(self,*args,**kwargs): @@ -297,7 +302,7 @@ class JobPostingForm(forms.ModelForm): if not self.instance.pk:# Creating new job posting if not self.is_anonymous_user: self.fields['created_by'].initial = 'University Administrator' - self.fields['status'].initial = 'Draft' + # self.fields['status'].initial = 'Draft' self.fields['location_city'].initial='Riyadh' self.fields['location_state'].initial='Riyadh Province' self.fields['location_country'].initial='Saudi Arabia' @@ -409,64 +414,64 @@ class FormTemplateForm(forms.ModelForm): Field('is_active', css_class='form-check-input'), Submit('submit', _('Create Template'), css_class='btn btn-primary mt-3') ) -class BreakTimeForm(forms.ModelForm): - class Meta: - model = BreakTime - fields = ['start_time', 'end_time'] - widgets = { - 'start_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}), - 'end_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}), - } +# class BreakTimeForm(forms.ModelForm): +# class Meta: +# model = BreakTime +# fields = ['start_time', 'end_time'] +# widgets = { +# 'start_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}), +# 'end_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}), +# } -BreakTimeFormSet = formset_factory(BreakTimeForm, extra=1, can_delete=True) +# BreakTimeFormSet = formset_factory(BreakTimeForm, extra=1, can_delete=True) -class InterviewScheduleForm(forms.ModelForm): - candidates = forms.ModelMultipleChoiceField( - queryset=Candidate.objects.none(), - widget=forms.CheckboxSelectMultiple, - required=True - ) - working_days = forms.MultipleChoiceField( - choices=[ - (0, 'Monday'), - (1, 'Tuesday'), - (2, 'Wednesday'), - (3, 'Thursday'), - (4, 'Friday'), - (5, 'Saturday'), - (6, 'Sunday'), - ], - widget=forms.CheckboxSelectMultiple, - required=True - ) +# class InterviewScheduleForm(forms.ModelForm): +# candidates = forms.ModelMultipleChoiceField( +# queryset=Candidate.objects.none(), +# widget=forms.CheckboxSelectMultiple, +# required=True +# ) +# working_days = forms.MultipleChoiceField( +# choices=[ +# (0, 'Monday'), +# (1, 'Tuesday'), +# (2, 'Wednesday'), +# (3, 'Thursday'), +# (4, 'Friday'), +# (5, 'Saturday'), +# (6, 'Sunday'), +# ], +# widget=forms.CheckboxSelectMultiple, +# required=True +# ) - class Meta: - model = InterviewSchedule - fields = [ - 'candidates', 'start_date', 'end_date', 'working_days', - 'start_time', 'end_time', 'interview_duration', 'buffer_time' - ] - widgets = { - 'start_date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}), - 'end_date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}), - 'start_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}), - 'end_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}), - 'interview_duration': forms.NumberInput(attrs={'class': 'form-control'}), - 'buffer_time': forms.NumberInput(attrs={'class': 'form-control'}), - } +# class Meta: +# model = InterviewSchedule +# fields = [ +# 'candidates', 'start_date', 'end_date', 'working_days', +# 'start_time', 'end_time', 'interview_duration', 'buffer_time' +# ] +# widgets = { +# 'start_date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}), +# 'end_date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}), +# 'start_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}), +# 'end_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}), +# 'interview_duration': forms.NumberInput(attrs={'class': 'form-control'}), +# 'buffer_time': forms.NumberInput(attrs={'class': 'form-control'}), +# } - def __init__(self, slug, *args, **kwargs): - super().__init__(*args, **kwargs) - # Filter candidates based on the selected job - self.fields['candidates'].queryset = Candidate.objects.filter( - job__slug=slug, - stage='Interview' - ) +# def __init__(self, slug, *args, **kwargs): +# super().__init__(*args, **kwargs) +# # Filter candidates based on the selected job +# self.fields['candidates'].queryset = Candidate.objects.filter( +# job__slug=slug, +# stage='Interview' +# ) - def clean_working_days(self): - working_days = self.cleaned_data.get('working_days') - # Convert string values to integers - return [int(day) for day in working_days] +# def clean_working_days(self): +# working_days = self.cleaned_data.get('working_days') +# # Convert string values to integers +# return [int(day) for day in working_days] class JobPostingCancelReasonForm(forms.ModelForm): @@ -494,7 +499,74 @@ class CandidateExamDateForm(forms.ModelForm): } +class BreakTimeForm(forms.Form): + """ + A simple Form used for the BreakTimeFormSet. + It is not a ModelForm because the data is stored directly in InterviewSchedule's JSONField, + not in a separate BreakTime model instance. + """ + start_time = forms.TimeField( + widget=forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}), + label="Start Time" + ) + end_time = forms.TimeField( + widget=forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}), + label="End Time" + ) + +# Use the non-model form for the formset factory +BreakTimeFormSet = formset_factory(BreakTimeForm, extra=1, can_delete=True) + +# --- InterviewScheduleForm remains unchanged --- +class InterviewScheduleForm(forms.ModelForm): + candidates = forms.ModelMultipleChoiceField( + queryset=Candidate.objects.none(), + widget=forms.CheckboxSelectMultiple, + required=True + ) + working_days = forms.MultipleChoiceField( + choices=[ + (0, 'Monday'), (1, 'Tuesday'), (2, 'Wednesday'), (3, 'Thursday'), + (4, 'Friday'), (5, 'Saturday'), (6, 'Sunday'), + ], + widget=forms.CheckboxSelectMultiple, + required=True + ) + + class Meta: + model = InterviewSchedule + fields = [ + 'candidates', 'start_date', 'end_date', 'working_days', + 'start_time', 'end_time', 'interview_duration', 'buffer_time', + 'break_start_time', 'break_end_time' + ] + widgets = { + 'start_date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}), + 'end_date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}), + 'start_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}), + 'end_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}), + 'interview_duration': forms.NumberInput(attrs={'class': 'form-control'}), + 'buffer_time': forms.NumberInput(attrs={'class': 'form-control'}), + 'break_start_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}), + 'break_end_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}), + } + + def __init__(self, slug, *args, **kwargs): + super().__init__(*args, **kwargs) + # Filter candidates based on the selected job + self.fields['candidates'].queryset = Candidate.objects.filter( + job__slug=slug, + stage='Interview' + ) + + def clean_working_days(self): + working_days = self.cleaned_data.get('working_days') + # Convert string values to integers + return [int(day) for day in working_days] + +# --- ScheduleInterviewForCandiateForm remains unchanged --- class ScheduleInterviewForCandiateForm(forms.ModelForm): + class Meta: model = InterviewSchedule fields = ['start_date', 'end_date', 'start_time', 'end_time', 'interview_duration', 'buffer_time'] @@ -505,4 +577,6 @@ class ScheduleInterviewForCandiateForm(forms.ModelForm): 'end_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}), 'interview_duration': forms.NumberInput(attrs={'class': 'form-control'}), 'buffer_time': forms.NumberInput(attrs={'class': 'form-control'}), - } \ No newline at end of file + 'break_start_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}), + 'break_end_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}), + } diff --git a/recruitment/hooks.py b/recruitment/hooks.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/recruitment/hooks.py @@ -0,0 +1 @@ + diff --git a/recruitment/management/commands/generate_data.py b/recruitment/management/commands/generate_data.py deleted file mode 100644 index 2275a07..0000000 --- a/recruitment/management/commands/generate_data.py +++ /dev/null @@ -1,48 +0,0 @@ -import os -import random -from django.core.management.base import BaseCommand -from faker import Faker -from recruitment.models import Job, Candidate - -fake = Faker() - -class Command(BaseCommand): - help = 'Generate 20 fake jobs and 50 fake candidates' - - def handle(self, *args, **kwargs): - # Clear existing fake data (optional) - Job.objects.filter(title__startswith='Fake Job').delete() - Candidate.objects.filter(name__startswith='Candidate ').delete() - - self.stdout.write("Creating fake jobs...") - jobs = [] - for i in range(20): - job = Job.objects.create( - title=f"Fake Job {i+1}", - description_en=fake.paragraph(nb_sentences=5), - description_ar=fake.text(max_nb_chars=200), - is_published=True, - posted_to_linkedin=random.choice([True, False]) - ) - jobs.append(job) - - self.stdout.write("Creating fake candidates...") - for i in range(50): - job = random.choice(jobs) - resume_path = f"resumes/fake_resume_{i+1}.pdf" - parsed = { - 'name': fake.name(), - 'skills': [fake.job() for _ in range(5)], - 'summary': fake.text(max_nb_chars=300) - } - - Candidate.objects.create( - job=job, - name=f"Candidate {i+1}", - email=fake.email(), - resume=resume_path, # You can create dummy files if needed - parsed_summary=str(parsed), - applied=random.choice([True, False]) - ) - - self.stdout.write(self.style.SUCCESS("✔️ Successfully generated 20 jobs and 50 candidates")) \ No newline at end of file diff --git a/recruitment/management/commands/seed.py b/recruitment/management/commands/seed.py new file mode 100644 index 0000000..7989456 --- /dev/null +++ b/recruitment/management/commands/seed.py @@ -0,0 +1,156 @@ +import uuid +import random +from datetime import date, timedelta +from django.core.management.base import BaseCommand +from django.utils import timezone + +from faker import Faker + +from recruitment.models import JobPosting, Candidate, Source, FormTemplate + +class Command(BaseCommand): + help = 'Seeds the database with initial JobPosting and Candidate data using Faker.' + + def add_arguments(self, parser): + # Add argument for the number of jobs to create, default is 5 + parser.add_argument( + '--jobs', + type=int, + help='The number of JobPostings to create.', + default=5, + ) + # Add argument for the number of candidates to create, default is 20 + parser.add_argument( + '--candidates', + type=int, + help='The number of Candidate applications to create.', + default=20, + ) + + def handle(self, *args, **options): + # Get the desired counts from command line arguments + jobs_count = options['jobs'] + candidates_count = options['candidates'] + + # Initialize Faker + fake = Faker('en_US') # Using en_US for general data, can be changed if needed + + self.stdout.write("--- Starting Database Seeding ---") + self.stdout.write(f"Preparing to create {jobs_count} jobs and {candidates_count} candidates.") + + # 1. Clear existing data (Optional, but useful for clean seeding) + Candidate.objects.all().delete() + JobPosting.objects.all().delete() + Source.objects.all().delete() + self.stdout.write(self.style.WARNING("Existing JobPostings and Candidates cleared.")) + + # 2. Create Foreign Key dependency: Source + default_source, created = Source.objects.get_or_create( + name="Career Website", + defaults={'name': 'Career Website'} + ) + self.stdout.write(f"Using Source: {default_source.name}") + + # --- Helper Chooser Lists --- + JOB_TYPES = [choice[0] for choice in JobPosting.JOB_TYPES] + WORKPLACE_TYPES = [choice[0] for choice in JobPosting.WORKPLACE_TYPES] + STATUS_CHOICES = [choice[0] for choice in JobPosting.STATUS_CHOICES] + DEPARTMENTS = ["Technology", "Marketing", "Finance", "HR", "Sales", "Research", "Operations"] + REPORTING_TO = ["CTO", "HR Manager", "Department Head", "VP of Sales"] + + + # 3. Generate JobPostings + created_jobs = [] + for i in range(jobs_count): + # Dynamic job details + title = fake.job() + department = random.choice(DEPARTMENTS) + is_faculty = random.random() < 0.1 # 10% chance of being a faculty job + job_type = "FACULTY" if is_faculty else random.choice([t for t in JOB_TYPES if t != "FACULTY"]) + + # Generate realistic salary range + base_salary = random.randint(50, 200) * 1000 + salary_range = f"${base_salary:,.0f} - ${base_salary + random.randint(10, 50) * 1000:,.0f}" + + # Random dates + start_date = fake.date_object() + deadline_date = start_date + timedelta(days=random.randint(14, 60)) + joining_date = deadline_date + timedelta(days=random.randint(30, 90)) + + # Use Faker's HTML generation for CKEditor5 fields + description_html = f"

{title} Role

" + "".join(f"

{fake.paragraph(nb_sentences=3, variable_nb_sentences=True)}

" for _ in range(3)) + qualifications_html = "" + benefits_html = f"

Standard benefits include: {fake.sentence(nb_words=8)}

" + instructions_html = f"

To apply, visit: {fake.url()} and follow the steps below.

" + + job_data = { + "title": title, + "department": department, + "job_type": job_type, + "workplace_type": random.choice(WORKPLACE_TYPES), + "location_city": fake.city(), + "location_state": fake.state_abbr(), + "location_country": "Saudia Arabia", + "description": description_html, + "qualifications": qualifications_html, + "salary_range": salary_range, + "benefits": benefits_html, + "application_url": fake.url(), + "application_start_date": start_date, + "application_deadline": deadline_date, + "application_instructions": instructions_html, + "created_by": "Faker Script", + "status": random.choice(STATUS_CHOICES), + "hash_tags": f"#{department.lower().replace(' ', '')},#jobopening,#{fake.word()}", + "position_number": f"{department[:3].upper()}{random.randint(100, 999)}", + "reporting_to": random.choice(REPORTING_TO), + "joining_date": joining_date, + "open_positions": random.randint(1, 5), + "source": default_source, + "published_at": timezone.now() if random.random() < 0.7 else None, + } + + job = JobPosting.objects.create( + **job_data + ) + FormTemplate.objects.create(job=job, name=f"{job.title} Form", description=f"Form for {job.title}",is_active=True) + created_jobs.append(job) + self.stdout.write(self.style.SUCCESS(f'Created JobPosting {i+1}/{jobs_count}: {job.title}')) + + + # 4. Generate Candidates + if created_jobs: + for i in range(candidates_count): + # Link candidate to a random job + target_job = random.choice(created_jobs) + + first_name = fake.first_name() + last_name = fake.last_name() + + candidate_data = { + "first_name": first_name, + "last_name": last_name, + # Create a plausible email based on name + "email": f"{first_name.lower()}.{last_name.lower()}@{fake.domain_name()}", + "phone": fake.phone_number(), + "address": fake.address(), + # Placeholder resume path + 'match_score': random.randint(0, 100), + "resume": f"resumes/{last_name.lower()}_{target_job.internal_job_id}_{fake.file_name(extension='pdf')}", + "job": target_job, + } + + Candidate.objects.create(**candidate_data) + self.stdout.write(self.style.NOTICE( + f'Created Candidate {i+1}/{candidates_count}: {first_name} for {target_job.title[:30]}...' + )) + + else: + self.stdout.write(self.style.WARNING("No jobs created, skipping candidate generation.")) + + + self.stdout.write(self.style.SUCCESS('\n--- Database Seeding Complete! ---')) + + # Summary output + self.stdout.write(f"Total JobPostings created: {JobPosting.objects.count()}") + self.stdout.write(f"Total Candidates created: {Candidate.objects.count()}") \ No newline at end of file diff --git a/recruitment/migrations/0014_formtemplate_close_at_formtemplate_max_applications_and_more.py b/recruitment/migrations/0014_formtemplate_close_at_formtemplate_max_applications_and_more.py new file mode 100644 index 0000000..ac56849 --- /dev/null +++ b/recruitment/migrations/0014_formtemplate_close_at_formtemplate_max_applications_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 5.2.6 on 2025-10-15 10:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0013_alter_formtemplate_created_by'), + ] + + operations = [ + migrations.AddField( + model_name='formtemplate', + name='close_at', + field=models.DateTimeField(blank=True, help_text='Date and time at which applications close', null=True), + ), + migrations.AddField( + model_name='formtemplate', + name='max_applications', + field=models.PositiveIntegerField(default=1000, help_text='Maximum number of applications allowed'), + ), + migrations.AlterField( + model_name='formtemplate', + name='created_at', + field=models.DateTimeField(auto_now_add=True), + ), + migrations.AlterField( + model_name='formtemplate', + name='updated_at', + field=models.DateTimeField(auto_now=True), + ), + ] diff --git a/recruitment/migrations/0015_remove_formtemplate_close_at_and_more.py b/recruitment/migrations/0015_remove_formtemplate_close_at_and_more.py new file mode 100644 index 0000000..b519f91 --- /dev/null +++ b/recruitment/migrations/0015_remove_formtemplate_close_at_and_more.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.6 on 2025-10-15 10:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0014_formtemplate_close_at_formtemplate_max_applications_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='formtemplate', + name='close_at', + ), + migrations.AlterField( + model_name='formtemplate', + name='created_at', + field=models.DateTimeField(auto_now_add=True, verbose_name='Created at'), + ), + migrations.AlterField( + model_name='formtemplate', + name='updated_at', + field=models.DateTimeField(auto_now=True, verbose_name='Updated at'), + ), + ] diff --git a/recruitment/migrations/0016_remove_formtemplate_max_applications_and_more.py b/recruitment/migrations/0016_remove_formtemplate_max_applications_and_more.py new file mode 100644 index 0000000..e660370 --- /dev/null +++ b/recruitment/migrations/0016_remove_formtemplate_max_applications_and_more.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2.6 on 2025-10-15 10:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0015_remove_formtemplate_close_at_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='formtemplate', + name='max_applications', + ), + migrations.AddField( + model_name='jobposting', + name='max_applications', + field=models.PositiveIntegerField(default=1000, help_text='Maximum number of applications allowed'), + ), + ] diff --git a/recruitment/migrations/0017_remove_interviewschedule_breaks_and_more.py b/recruitment/migrations/0017_remove_interviewschedule_breaks_and_more.py new file mode 100644 index 0000000..4961d4d --- /dev/null +++ b/recruitment/migrations/0017_remove_interviewschedule_breaks_and_more.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.6 on 2025-10-15 15:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0016_remove_formtemplate_max_applications_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='interviewschedule', + name='breaks', + ), + migrations.AddField( + model_name='interviewschedule', + name='break_end', + field=models.TimeField(blank=True, null=True, verbose_name='Break End Time'), + ), + migrations.AddField( + model_name='interviewschedule', + name='break_start', + field=models.TimeField(blank=True, null=True, verbose_name='Break Start Time'), + ), + ] diff --git a/recruitment/migrations/0018_rename_break_end_interviewschedule_break_end_time_and_more.py b/recruitment/migrations/0018_rename_break_end_interviewschedule_break_end_time_and_more.py new file mode 100644 index 0000000..16b2ed2 --- /dev/null +++ b/recruitment/migrations/0018_rename_break_end_interviewschedule_break_end_time_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.6 on 2025-10-15 15:55 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0017_remove_interviewschedule_breaks_and_more'), + ] + + operations = [ + migrations.RenameField( + model_name='interviewschedule', + old_name='break_end', + new_name='break_end_time', + ), + migrations.RenameField( + model_name='interviewschedule', + old_name='break_start', + new_name='break_start_time', + ), + ] diff --git a/recruitment/migrations/0019_alter_interviewschedule_candidates.py b/recruitment/migrations/0019_alter_interviewschedule_candidates.py new file mode 100644 index 0000000..d10d068 --- /dev/null +++ b/recruitment/migrations/0019_alter_interviewschedule_candidates.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.6 on 2025-10-15 16:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0018_rename_break_end_interviewschedule_break_end_time_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='interviewschedule', + name='candidates', + field=models.ManyToManyField(blank=True, null=True, related_name='interview_schedules', to='recruitment.candidate'), + ), + ] diff --git a/recruitment/migrations/0020_alter_interviewschedule_created_at.py b/recruitment/migrations/0020_alter_interviewschedule_created_at.py new file mode 100644 index 0000000..3824c35 --- /dev/null +++ b/recruitment/migrations/0020_alter_interviewschedule_created_at.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.6 on 2025-10-15 16:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0019_alter_interviewschedule_candidates'), + ] + + operations = [ + migrations.AlterField( + model_name='interviewschedule', + name='created_at', + field=models.DateTimeField(auto_now_add=True, verbose_name='Created at'), + ), + ] diff --git a/recruitment/models.py b/recruitment/models.py index be2c445..0b6bb54 100644 --- a/recruitment/models.py +++ b/recruitment/models.py @@ -1,21 +1,15 @@ from django.db import models +from django.urls import reverse from django.utils import timezone -from .validators import validate_hash_tags, validate_image_size +from django.db.models import JSONField from django.contrib.auth.models import User from django.core.validators import URLValidator +from django_countries.fields import CountryField +from django.core.exceptions import ValidationError +from django_ckeditor_5.fields import CKEditor5Field from django.utils.translation import gettext_lazy as _ from django_extensions.db.fields import RandomCharField -from django.core.exceptions import ValidationError -from django_countries.fields import CountryField -from django.urls import reverse -# from ckeditor.fields import RichTextField -from django_ckeditor_5.fields import CKEditor5Field - - - -class Profile(models.Model): - profile_image = models.ImageField(null=True, blank=True, upload_to="profile_pic/") - user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile") +from .validators import validate_hash_tags, validate_image_size class Base(models.Model): @@ -28,24 +22,9 @@ class Base(models.Model): class Meta: abstract = True - -# # Create your models here. -# class Job(Base): -# title = models.CharField(max_length=255, verbose_name=_('Title')) -# description_en = models.TextField(verbose_name=_('Description English')) -# description_ar = models.TextField(verbose_name=_('Description Arabic')) -# is_published = models.BooleanField(default=False, verbose_name=_('Published')) -# posted_to_linkedin = models.BooleanField(default=False, verbose_name=_('Posted to LinkedIn')) -# created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('Created at')) -# updated_at = models.DateTimeField(auto_now=True, verbose_name=_('Updated at')) - -# class Meta: -# verbose_name = _('Job') -# verbose_name_plural = _('Jobs') - -# def __str__(self): -# return self.title - +class Profile(models.Model): + profile_image = models.ImageField(null=True, blank=True, upload_to="profile_pic/") + user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile") class JobPosting(Base): # Basic Job Information @@ -164,7 +143,9 @@ class JobPosting(Base): blank=True, help_text="The system or channel from which this job posting originated or was first published.", ) - + max_applications = models.PositiveIntegerField( + default=1000, help_text="Maximum number of applications allowed" + ) hiring_agency = models.ManyToManyField( "HiringAgency", blank=True, @@ -246,6 +227,18 @@ class JobPosting(Base): "form_wizard", kwargs={"slug": self.form_template.slug} ) self.save() + @property + def current_applications_count(self): + """Returns the current number of candidates associated with this job.""" + return self.candidates.count() + + @property + def is_application_limit_reached(self): + """Checks if the current application count meets or exceeds the max limit.""" + if self.max_applications == 0: + return True + + return self.current_applications_count >= self.max_applications class JobPostingImage(models.Model): @@ -375,43 +368,12 @@ class Candidate(Base): return self.resume.size return 0 - # def clean(self): - # """Validate stage transitions""" - # # Only validate if this is an existing record (not being created) - # if self.pk and self.stage != self.__class__.objects.get(pk=self.pk).stage: - # old_stage = self.__class__.objects.get(pk=self.pk).stage - # allowed_next_stages = self.STAGE_SEQUENCE.get(old_stage, []) - - # if self.stage not in allowed_next_stages: - # raise ValidationError( - # { - # "stage": f'Cannot transition from "{old_stage}" to "{self.stage}". ' - # f"Allowed transitions: {', '.join(allowed_next_stages) or 'None (final stage)'}" - # } - # ) - - # # Validate that the stage is a valid choice - # if self.stage not in [choice[0] for choice in self.Stage.choices]: - # raise ValidationError( - # { - # "stage": f"Invalid stage. Must be one of: {', '.join(choice[0] for choice in self.Stage.choices)}" - # } - # ) def save(self, *args, **kwargs): """Override save to ensure validation is called""" self.clean() # Call validation before saving super().save(*args, **kwargs) - # def can_transition_to(self, new_stage): - # """Check if a stage transition is allowed""" - # if not self.pk: # New record - can be in Applied stage - # return new_stage == "Applied" - - # old_stage = self.__class__.objects.get(pk=self.pk).stage - # allowed_next_stages = self.STAGE_SEQUENCE.get(old_stage, []) - # return new_stage in allowed_next_stages - def get_available_stages(self): """Get list of stages this candidate can transition to""" if not self.pk: # New record @@ -489,6 +451,7 @@ class ZoomMeeting(Base): SCHEDULED = "scheduled", _("Scheduled") STARTED = "started", _("Started") ENDED = "ended", _("Ended") + CANCELLED = "cancelled",_("Cancelled") # Basic meeting details topic = models.CharField(max_length=255, verbose_name=_("Topic")) meeting_id = models.CharField( @@ -989,7 +952,7 @@ class InterviewSchedule(Base): job = models.ForeignKey( JobPosting, on_delete=models.CASCADE, related_name="interview_schedules" ) - candidates = models.ManyToManyField(Candidate, related_name="interview_schedules") + candidates = models.ManyToManyField(Candidate, related_name="interview_schedules", blank=True,null=True) start_date = models.DateField(verbose_name=_("Start Date")) end_date = models.DateField(verbose_name=_("End Date")) working_days = models.JSONField( @@ -998,7 +961,8 @@ class InterviewSchedule(Base): start_time = models.TimeField(verbose_name=_("Start Time")) end_time = models.TimeField(verbose_name=_("End Time")) - breaks = models.JSONField(default=list, blank=True, verbose_name=_('Break Times')) + break_start_time = models.TimeField(verbose_name=_("Break Start Time"),null=True,blank=True) + break_end_time = models.TimeField(verbose_name=_("Break End Time"),null=True,blank=True) interview_duration = models.PositiveIntegerField( verbose_name=_("Interview Duration (minutes)") @@ -1007,7 +971,6 @@ class InterviewSchedule(Base): verbose_name=_("Buffer Time (minutes)"), default=0 ) created_by = models.ForeignKey(User, on_delete=models.CASCADE) - created_at = models.DateTimeField(auto_now_add=True) def __str__(self): return f"Interview Schedule for {self.job.title}" @@ -1030,6 +993,7 @@ class ScheduledInterview(Base): schedule = models.ForeignKey( InterviewSchedule, on_delete=models.CASCADE, related_name="interviews",null=True,blank=True ) + interview_date = models.DateField(verbose_name=_("Interview Date")) interview_time = models.TimeField(verbose_name=_("Interview Time")) status = models.CharField( diff --git a/recruitment/tasks.py b/recruitment/tasks.py index abfa760..103fc4e 100644 --- a/recruitment/tasks.py +++ b/recruitment/tasks.py @@ -3,7 +3,10 @@ import json import logging import requests from PyPDF2 import PdfReader +from datetime import datetime +from .utils import create_zoom_meeting from recruitment.models import Candidate +from .models import ScheduledInterview, ZoomMeeting, Candidate, JobPosting, InterviewSchedule logger = logging.getLogger(__name__) @@ -153,3 +156,124 @@ def handle_reume_parsing_and_scoring(pk): except Exception as e: logger.error(f"Failed to score resume for candidate {instance.id}: {e}") + + +def create_interview_and_meeting( + candidate_id, + job_id, + schedule_id, + slot_date, + slot_time, + duration +): + """ + Synchronous task for a single interview slot, dispatched by django-q. + """ + try: + candidate = Candidate.objects.get(pk=candidate_id) + job = JobPosting.objects.get(pk=job_id) + schedule = InterviewSchedule.objects.get(pk=schedule_id) + + interview_datetime = datetime.combine(slot_date, slot_time) + meeting_topic = f"Interview for {job.title} - {candidate.name}" + + # 1. External API Call (Slow) + result = create_zoom_meeting(meeting_topic, interview_datetime, duration) + + if result["status"] == "success": + # 2. Database Writes (Slow) + zoom_meeting = ZoomMeeting.objects.create( + topic=meeting_topic, + start_time=interview_datetime, + duration=duration, + meeting_id=result["meeting_details"]["meeting_id"], + join_url=result["meeting_details"]["join_url"], + zoom_gateway_response=result["zoom_gateway_response"], + ) + ScheduledInterview.objects.create( + candidate=candidate, + job=job, + zoom_meeting=zoom_meeting, + schedule=schedule, + interview_date=slot_date, + interview_time=slot_time + ) + # Log success or use Django-Q result system for monitoring + logger.info(f"Successfully scheduled interview for {candidate.name}") + return True # Task succeeded + else: + # Handle Zoom API failure (e.g., log it or notify administrator) + logger.error(f"Zoom API failed for {candidate.name}: {result['message']}") + return False # Task failed + + except Exception as e: + # Catch any unexpected errors during database lookups or processing + logger.error(f"Critical error scheduling interview: {e}") + return False # Task failed + + +def handle_zoom_webhook_event(payload): + """ + Background task to process a Zoom webhook event and update the local ZoomMeeting status. + It handles: created, updated, started, ended, and deleted events. + """ + event_type = payload.get('event') + object_data = payload['payload']['object'] + + # Zoom often uses a long 'id' for the scheduled meeting and sometimes a 'uuid'. + # We rely on the unique 'id' that maps to your ZoomMeeting.meeting_id field. + meeting_id_zoom = str(object_data.get('id')) + print(meeting_id_zoom) + if not meeting_id_zoom: + logger.warning(f"Webhook received without a valid Meeting ID: {event_type}") + return False + + try: + # Use filter().first() to avoid exceptions if the meeting doesn't exist yet, + # and to simplify the logic flow. + meeting_instance = ZoomMeeting.objects.filter(meeting_id=meeting_id_zoom).first() + print(meeting_instance) + # --- 1. Creation and Update Events --- + if event_type == 'meeting.updated': + if meeting_instance: + # Update key fields from the webhook payload + meeting_instance.topic = object_data.get('topic', meeting_instance.topic) + + # Check for and update status and time details + # if event_type == 'meeting.created': + # meeting_instance.status = 'scheduled' + # elif event_type == 'meeting.updated': + # Only update time fields if they are in the payload + print(object_data) + meeting_instance.start_time = object_data.get('start_time', meeting_instance.start_time) + meeting_instance.duration = object_data.get('duration', meeting_instance.duration) + meeting_instance.timezone = object_data.get('timezone', meeting_instance.timezone) + + # Also update join_url, password, etc., if needed based on the payload structure + meeting_instance.status = 'scheduled' + + meeting_instance.save(update_fields=['topic', 'start_time', 'duration', 'timezone', 'status']) + + # --- 2. Status Change Events (Start/End) --- + elif event_type == 'meeting.started': + if meeting_instance: + meeting_instance.status = 'started' + meeting_instance.save(update_fields=['status']) + + elif event_type == 'meeting.ended': + if meeting_instance: + meeting_instance.status = 'ended' + meeting_instance.save(update_fields=['status']) + + # --- 3. Deletion Event (User Action) --- + elif event_type == 'meeting.deleted': + if meeting_instance: + # Mark as cancelled/deleted instead of physically deleting for audit trail + meeting_instance.status = 'cancelled' + meeting_instance.save(update_fields=['status']) + + return True + + except Exception as e: + logger.error(f"Failed to process Zoom webhook for {event_type} (ID: {meeting_id_zoom}): {e}", exc_info=True) + return False \ No newline at end of file diff --git a/recruitment/templatetags/__pycache__/form_filters.cpython-313.pyc b/recruitment/templatetags/__pycache__/form_filters.cpython-313.pyc index 9436ae7..0d10186 100644 Binary files a/recruitment/templatetags/__pycache__/form_filters.cpython-313.pyc and b/recruitment/templatetags/__pycache__/form_filters.cpython-313.pyc differ diff --git a/recruitment/templatetags/form_filters.py b/recruitment/templatetags/form_filters.py index 33d5293..0572851 100644 --- a/recruitment/templatetags/form_filters.py +++ b/recruitment/templatetags/form_filters.py @@ -72,3 +72,12 @@ def to_list(data): Usage: {% to_list "item1,item2,item3" as list %} """ return data.split(",") if data else [] + +@register.filter +def get_schedule_candidate_ids(session, slug): + """ + Retrieves the list of candidate IDs stored in the session for a specific job slug. + """ + session_key = f"schedule_candidate_ids_{slug}" + # Returns the list of IDs (or an empty list if not found) + return session.get(session_key, []) \ No newline at end of file diff --git a/recruitment/urls.py b/recruitment/urls.py index e48911c..c20dd58 100644 --- a/recruitment/urls.py +++ b/recruitment/urls.py @@ -23,6 +23,7 @@ urlpatterns = [ path('jobs/linkedin/callback/', views.linkedin_callback, name='linkedin_callback'), path('jobs//schedule-interviews/', views.schedule_interviews_view, name='schedule_interviews'), + path('jobs//confirm-schedule-interviews/', views.confirm_schedule_interviews_view, name='confirm_schedule_interviews_view'), # Candidate URLs path('candidates/', views_frontend.CandidateListView.as_view(), name='candidate_list'), path('candidates/create/', views_frontend.CandidateCreateView.as_view(), name='candidate_create'), @@ -106,5 +107,5 @@ urlpatterns = [ path('jobs//candidates//delete_meeting_for_candidate//', views.delete_meeting_for_candidate, name='delete_meeting_for_candidate'), # users urls - path('user/',views.user_detail,name='user_detail') + path('user/',views.user_detail,name='user_detail'), ] diff --git a/recruitment/utils.py b/recruitment/utils.py index b7b19a6..4b24c1a 100644 --- a/recruitment/utils.py +++ b/recruitment/utils.py @@ -416,13 +416,12 @@ def schedule_interviews(schedule): Returns the number of interviews successfully scheduled. """ candidates = list(schedule.candidates.all()) - print(candidates) if not candidates: return 0 # Calculate available time slots available_slots = get_available_time_slots(schedule) - print(available_slots) + if len(available_slots) < len(candidates): raise ValueError(f"Not enough available slots. Required: {len(candidates)}, Available: {len(available_slots)}") diff --git a/recruitment/views.py b/recruitment/views.py index 58b4be2..bc6899b 100644 --- a/recruitment/views.py +++ b/recruitment/views.py @@ -59,6 +59,8 @@ from datastar_py.django import ( ServerSentEventGenerator as SSE, read_signals, ) +from django.db import transaction +from django_q.tasks import async_task logger = logging.getLogger(__name__) @@ -91,7 +93,6 @@ class ZoomMeetingCreateView(CreateView): duration = instance.duration result = create_zoom_meeting(topic, start_time, duration) - print(result) if result["status"] == "success": instance.meeting_id = result["meeting_details"]["meeting_id"] instance.join_url = result["meeting_details"]["join_url"] @@ -119,18 +120,44 @@ class ZoomMeetingListView(ListView): def get_queryset(self): queryset = super().get_queryset().order_by("-start_time") - # Handle search - search_query = self.request.GET.get("search", "") + # Prefetch related interview data efficiently + from django.db.models import Prefetch + queryset = queryset.prefetch_related( + Prefetch( + 'interview', # related_name from ZoomMeeting to ScheduledInterview + queryset=ScheduledInterview.objects.select_related('candidate', 'job'), + to_attr='interview_details' # Changed to not start with underscore + ) + ) + + # Handle search by topic or meeting_id + search_query = self.request.GET.get("q", "") # Renamed from 'search' to 'q' for consistency if search_query: queryset = queryset.filter( Q(topic__icontains=search_query) | Q(meeting_id__icontains=search_query) ) + # Handle filter by status + status_filter = self.request.GET.get("status", "") + if status_filter: + queryset = queryset.filter(status=status_filter) + + # Handle search by candidate name + candidate_name = self.request.GET.get("candidate_name", "") + if candidate_name: + # Filter based on the name of the candidate associated with the meeting's interview + queryset = queryset.filter( + Q(interview__candidate__first_name__icontains=candidate_name) | + Q(interview__candidate__last_name__icontains=candidate_name) + ) + return queryset def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - context["search_query"] = self.request.GET.get("search", "") + context["search_query"] = self.request.GET.get("q", "") + context["status_filter"] = self.request.GET.get("status", "") + context["candidate_name_filter"] = self.request.GET.get("candidate_name", "") return context @@ -922,27 +949,18 @@ def form_wizard_view(request, template_id): @require_POST def submit_form(request, template_id): """Handle form submission""" - print(f"Request method: {request}") - print(f"CSRF token in POST: {'csrfmiddlewaretoken' in request.POST}") - print(f"CSRF token value: {request.POST.get('csrfmiddlewaretoken', 'NOT FOUND')}") - print(f"POST data: {request.POST}") - print(f"FILES data: {request.FILES}") + template = get_object_or_404(FormTemplate, id=template_id) if request.method == "POST": try: - template = get_object_or_404(FormTemplate, id=template_id) + with transaction.atomic(): + job_posting = JobPosting.objects.select_for_update().get(form_template=template) - # # Create form submission - # print({key: value for key, value in request.POST.items()}) - # first_name = next((value for key, value in request.POST.items() if key == 'First Name'), None) - # last_name = next((value for key, value in request.POST.items() if key == 'Last Name'), None) - # email = next((value for key, value in request.POST.items() if key == 'Email Address'), None) - # phone = next((value for key, value in request.POST.items() if key == 'Phone Number'), None) - # address = next((value for key, value in request.POST.items() if key == 'Address'), None) - # resume = next((value for key, value in request.POST.items() if key == 'Resume Upload'), None) - # print(first_name, last_name, email, phone, address, resume) - # create candidate - - submission = FormSubmission.objects.create(template=template) + current_count = job_posting.candidates.count() + if current_count >= job_posting.max_applications: + return JsonResponse( + {"success": False, "message": "Application limit reached for this job."} + ) + submission = FormSubmission.objects.create(template=template) # Process field responses for field_id, value in request.POST.items(): if field_id.startswith("field_"): @@ -1099,237 +1117,577 @@ def form_submission_details(request, template_id, slug): }, ) -def schedule_interviews_view(request, slug): - job = get_object_or_404(JobPosting, slug=slug) - if request.method == "POST": - form = InterviewScheduleForm(slug, request.POST) - break_formset = BreakTimeFormSet(request.POST) +def _handle_get_request(request, slug, job): + """ + Handles GET requests, setting up forms and restoring candidate selections + from the session for persistence. + """ + SESSION_KEY = f"schedule_candidate_ids_{slug}" + form = InterviewScheduleForm(slug=slug) + # break_formset = BreakTimeFormSet(prefix='breaktime') - # Check if this is a confirmation request - if "confirm_schedule" in request.POST: - # Get the schedule data from session - schedule_data = request.session.get("interview_schedule_data") - if not schedule_data: - messages.error(request, "Session expired. Please try again.") - return redirect("schedule_interviews", slug=slug) + selected_ids = [] - # Create the interview schedule - schedule = InterviewSchedule.objects.create( - job=job, - created_by=request.user, - start_date=datetime.fromisoformat(schedule_data["start_date"]).date(), - end_date=datetime.fromisoformat(schedule_data["end_date"]).date(), - working_days=schedule_data["working_days"], - start_time=time.fromisoformat(schedule_data["start_time"]), - end_time=time.fromisoformat(schedule_data["end_time"]), - interview_duration=schedule_data["interview_duration"], - buffer_time=schedule_data["buffer_time"], - breaks=schedule_data["breaks"], - ) + # 1. Capture IDs from HTMX request and store in session (when first clicked) + if "HX-Request" in request.headers: + candidate_ids = request.GET.getlist("candidate_ids") + if candidate_ids: + request.session[SESSION_KEY] = candidate_ids + selected_ids = candidate_ids - # Add candidates to the schedule - candidates = Candidate.objects.filter(id__in=schedule_data["candidate_ids"]) - schedule.candidates.set(candidates) + # 2. Restore IDs from session (on refresh or navigation) + if not selected_ids: + selected_ids = request.session.get(SESSION_KEY, []) - # Create temporary break time objects for slot calculation - temp_breaks = [] - for break_data in schedule_data["breaks"]: - temp_breaks.append( - BreakTime( - start_time=datetime.strptime( - break_data["start_time"], "%H:%M:%S" - ).time(), - end_time=datetime.strptime( - break_data["end_time"], "%H:%M:%S" - ).time(), - ) - ) - - # Get available slots - available_slots = get_available_time_slots(schedule) - - # Create scheduled interviews - scheduled_count = 0 - for i, candidate in enumerate(candidates): - if i < len(available_slots): - slot = available_slots[i] - interview_datetime = datetime.combine(slot['date'], slot['time']) - - # Create Zoom meeting - meeting_topic = f"Interview for {job.title} - {candidate.name}" - - start_time = interview_datetime - - # zoom_meeting = create_zoom_meeting( - # topic=meeting_topic, - # start_time=start_time, - # duration=schedule.interview_duration - # ) - - result = create_zoom_meeting(meeting_topic, start_time, schedule.interview_duration) - if result["status"] == "success": - zoom_meeting = ZoomMeeting.objects.create( - topic=meeting_topic, - start_time=interview_datetime, - duration=schedule.interview_duration, - meeting_id=result["meeting_details"]["meeting_id"], - join_url=result["meeting_details"]["join_url"], - zoom_gateway_response=result["zoom_gateway_response"], - ) - # Create scheduled interview record - ScheduledInterview.objects.create( - candidate=candidate, - job=job, - zoom_meeting=zoom_meeting, - schedule=schedule, - interview_date=slot['date'], - interview_time=slot['time'] - ) - else: - messages.error(request, result["message"]) - schedule.delete() - return redirect("candidate_interview_view", slug=slug) - - # Send email to candidate - # try: - # send_interview_email(scheduled_interview) - # except Exception as e: - # messages.warning( - # request, - # f"Interview scheduled for {candidate.name}, but failed to send email: {str(e)}" - # ) - - scheduled_count += 1 - - messages.success( - request, f"Successfully scheduled {scheduled_count} interviews." - ) - - # Clear the session data - if "interview_schedule_data" in request.session: - del request.session["interview_schedule_data"] - - return redirect("job_detail", slug=slug) - - # This is the initial form submission - if form.is_valid() and break_formset.is_valid(): - # Get the form data - candidates = form.cleaned_data["candidates"] - start_date = form.cleaned_data["start_date"] - end_date = form.cleaned_data["end_date"] - working_days = form.cleaned_data["working_days"] - start_time = form.cleaned_data["start_time"] - end_time = form.cleaned_data["end_time"] - interview_duration = form.cleaned_data["interview_duration"] - buffer_time = form.cleaned_data["buffer_time"] - - # Process break times - breaks = [] - for break_form in break_formset: - if break_form.cleaned_data and not break_form.cleaned_data.get( - "DELETE" - ): - breaks.append( - { - "start_time": break_form.cleaned_data[ - "start_time" - ].strftime("%H:%M:%S"), - "end_time": break_form.cleaned_data["end_time"].strftime("%H:%M:%S"), - } - ) - - # Create a temporary schedule object (not saved to DB) - temp_schedule = InterviewSchedule( - job=job, - start_date=start_date, - end_date=end_date, - working_days=working_days, - start_time=start_time, - end_time=end_time, - interview_duration=interview_duration, - buffer_time=buffer_time, - breaks=breaks, - ) - - # Create temporary break time objects - temp_breaks = [] - for break_data in breaks: - temp_breaks.append( - BreakTime( - start_time=datetime.strptime( - break_data["start_time"], "%H:%M:%S" - ).time(), - end_time=datetime.strptime( - break_data["end_time"], "%H:%M:%S" - ).time(), - ) - ) - - # Get available slots - available_slots = get_available_time_slots(temp_schedule) - - if len(available_slots) < len(candidates): - messages.error( - request, - f"Not enough available slots. Required: {len(candidates)}, Available: {len(available_slots)}", - ) - return render( - request, - "interviews/schedule_interviews.html", - {"form": form, "break_formset": break_formset, "job": job}, - ) - - # Create a preview schedule - preview_schedule = [] - for i, candidate in enumerate(candidates): - slot = available_slots[i] - preview_schedule.append( - {"candidate": candidate, "date": slot["date"], "time": slot["time"]} - ) - - # Save the form data to session for later use - schedule_data = { - "start_date": start_date.isoformat(), - "end_date": end_date.isoformat(), - "working_days": working_days, - "start_time": start_time.isoformat(), - "end_time": end_time.isoformat(), - "interview_duration": interview_duration, - "buffer_time": buffer_time, - "candidate_ids": [c.id for c in candidates], - "breaks": breaks, - } - request.session["interview_schedule_data"] = schedule_data - - # Render the preview page - return render( - request, - "interviews/preview_schedule.html", - { - "job": job, - "schedule": preview_schedule, - "start_date": start_date, - "end_date": end_date, - "working_days": working_days, - "start_time": start_time, - "end_time": end_time, - "breaks": breaks, - "interview_duration": interview_duration, - "buffer_time": buffer_time, - }, - ) - else: - form = InterviewScheduleForm(slug=slug) - break_formset = BreakTimeFormSet() - if "HX-Request" in request.headers: - candidate_ids = request.GET.getlist("candidate_ids") - form.initial["candidates"] = Candidate.objects.filter(pk__in = candidate_ids) + # 3. Use the list of IDs to initialize the form + if selected_ids: + candidates_to_load = Candidate.objects.filter(pk__in=selected_ids) + form.initial["candidates"] = candidates_to_load return render( request, "interviews/schedule_interviews.html", - {"form": form, "break_formset": break_formset, "job": job}, + {"form": form, "job": job}, ) + + +def _handle_preview_submission(request, slug, job): + """ + Handles the initial POST request (Preview Schedule). + Validates forms, calculates slots, saves data to session, and renders preview. + """ + SESSION_DATA_KEY = "interview_schedule_data" + form = InterviewScheduleForm(slug, request.POST) + # break_formset = BreakTimeFormSet(request.POST,prefix='breaktime') + + if form.is_valid(): + # Get the form data + candidates = form.cleaned_data["candidates"] + start_date = form.cleaned_data["start_date"] + end_date = form.cleaned_data["end_date"] + working_days = form.cleaned_data["working_days"] + start_time = form.cleaned_data["start_time"] + end_time = form.cleaned_data["end_time"] + interview_duration = form.cleaned_data["interview_duration"] + buffer_time = form.cleaned_data["buffer_time"] + break_start_time = form.cleaned_data["break_start_time"] + break_end_time = form.cleaned_data["break_end_time"] + + # Process break times + # breaks = [] + # for break_form in break_formset: + # print(break_form.cleaned_data) + # if break_form.cleaned_data and not break_form.cleaned_data.get("DELETE"): + # breaks.append( + # { + # "start_time": break_form.cleaned_data["start_time"].strftime("%H:%M:%S"), + # "end_time": break_form.cleaned_data["end_time"].strftime("%H:%M:%S"), + # } + # ) + + # Create a temporary schedule object (not saved to DB) + temp_schedule = InterviewSchedule( + job=job, + start_date=start_date, + end_date=end_date, + working_days=working_days, + start_time=start_time, + end_time=end_time, + interview_duration=interview_duration, + buffer_time=buffer_time, + break_start_time=break_start_time, + break_end_time=break_end_time + ) + + # Get available slots (temp_breaks logic moved into get_available_time_slots if needed) + available_slots = get_available_time_slots(temp_schedule) + + if len(available_slots) < len(candidates): + messages.error( + request, + f"Not enough available slots. Required: {len(candidates)}, Available: {len(available_slots)}", + ) + return render( + request, + "interviews/schedule_interviews.html", + {"form": form, "job": job}, + ) + + # Create a preview schedule + preview_schedule = [] + for i, candidate in enumerate(candidates): + slot = available_slots[i] + preview_schedule.append( + {"candidate": candidate, "date": slot["date"], "time": slot["time"]} + ) + + # Save the form data to session for later use + schedule_data = { + "start_date": start_date.isoformat(), + "end_date": end_date.isoformat(), + "working_days": working_days, + "start_time": start_time.isoformat(), + "end_time": end_time.isoformat(), + "interview_duration": interview_duration, + "buffer_time": buffer_time, + "break_start_time": break_start_time.isoformat(), + "break_end_time": break_end_time.isoformat(), + "candidate_ids": [c.id for c in candidates], + } + request.session[SESSION_DATA_KEY] = schedule_data + + # Render the preview page + return render( + request, + "interviews/preview_schedule.html", + { + "job": job, + "schedule": preview_schedule, + "start_date": start_date, + "end_date": end_date, + "working_days": working_days, + "start_time": start_time, + "end_time": end_time, + "break_start_time": break_start_time, + "break_end_time": break_end_time, + "interview_duration": interview_duration, + "buffer_time": buffer_time, + }, + ) + else: + # Re-render the form if validation fails + return render( + request, + "interviews/schedule_interviews.html", + {"form": form, "job": job}, + ) + + +def _handle_confirm_schedule(request, slug, job): + """ + Handles the final POST request (Confirm Schedule). + Creates the main schedule record and queues individual interviews asynchronously. + """ + + + SESSION_DATA_KEY = "interview_schedule_data" + SESSION_ID_KEY = f"schedule_candidate_ids_{slug}" + + # 1. Get schedule data from session + schedule_data = request.session.get(SESSION_DATA_KEY) + + if not schedule_data: + messages.error(request, "Session expired. Please try again.") + return redirect("schedule_interviews", slug=slug) + + # 2. Create the Interview Schedule (Parent Record) + # NOTE: You MUST convert the time strings back to Python time objects here. + try: + schedule = InterviewSchedule.objects.create( + job=job, + created_by=request.user, + start_date=datetime.fromisoformat(schedule_data["start_date"]).date(), + end_date=datetime.fromisoformat(schedule_data["end_date"]).date(), + working_days=schedule_data["working_days"], + start_time=time.fromisoformat(schedule_data["start_time"]), + end_time=time.fromisoformat(schedule_data["end_time"]), + interview_duration=schedule_data["interview_duration"], + buffer_time=schedule_data["buffer_time"], + + # Use the simple break times saved in the session + # If the value is None (because required=False in form), handle it gracefully + break_start_time=schedule_data.get("break_start_time"), + break_end_time=schedule_data.get("break_end_time"), + ) + except Exception as e: + # Handle database creation error + messages.error(request, f"Error creating schedule: {e}") + if SESSION_ID_KEY in request.session: del request.session[SESSION_ID_KEY] + return redirect("schedule_interviews", slug=slug) + + + # 3. Setup candidates and get slots + candidates = Candidate.objects.filter(id__in=schedule_data["candidate_ids"]) + schedule.candidates.set(candidates) + available_slots = get_available_time_slots(schedule) # This should still be synchronous and fast + + # 4. Queue scheduled interviews asynchronously (FAST RESPONSE) + queued_count = 0 + for i, candidate in enumerate(candidates): + if i < len(available_slots): + slot = available_slots[i] + + # Dispatch the individual creation task to the background queue + async_task( + "recruitment.tasks.create_interview_and_meeting", + candidate.pk, + job.pk, + schedule.pk, + slot['date'], + slot['time'], + schedule.interview_duration, + ) + queued_count += 1 + + # 5. Success and Cleanup (IMMEDIATE RESPONSE) + messages.success( + request, + f"Schedule successfully created. Queued {queued_count} interviews to be booked asynchronously. Check back in a moment!" + ) + + # Clear both session data keys upon successful completion + if SESSION_DATA_KEY in request.session: del request.session[SESSION_DATA_KEY] + if SESSION_ID_KEY in request.session: del request.session[SESSION_ID_KEY] + + return redirect("job_detail", slug=slug) +# def _handle_confirm_schedule(request, slug, job): +# """ +# Handles the final POST request (Confirm Schedule). +# Creates all database records (Schedule, Meetings, Interviews) and clears sessions. +# """ +# SESSION_DATA_KEY = "interview_schedule_data" +# SESSION_ID_KEY = f"schedule_candidate_ids_{slug}" + +# # 1. Get schedule data from session +# schedule_data = request.session.get(SESSION_DATA_KEY) + +# if not schedule_data: +# messages.error(request, "Session expired. Please try again.") +# return redirect("schedule_interviews", slug=slug) +# # 2. Create the Interview Schedule (Your existing logic) +# schedule = InterviewSchedule.objects.create( +# job=job, +# created_by=request.user, +# start_date=datetime.fromisoformat(schedule_data["start_date"]).date(), +# end_date=datetime.fromisoformat(schedule_data["end_date"]).date(), +# working_days=schedule_data["working_days"], +# start_time=time.fromisoformat(schedule_data["start_time"]), +# end_time=time.fromisoformat(schedule_data["end_time"]), +# interview_duration=schedule_data["interview_duration"], +# buffer_time=schedule_data["buffer_time"], +# break_start_time=schedule_data["break_start_time"], +# break_end_time=schedule_data["break_end_time"], +# ) + +# # 3. Setup candidates and get slots +# candidates = Candidate.objects.filter(id__in=schedule_data["candidate_ids"]) +# schedule.candidates.set(candidates) +# available_slots = get_available_time_slots(schedule) + +# # 4. Create scheduled interviews +# scheduled_count = 0 +# for i, candidate in enumerate(candidates): +# if i < len(available_slots): +# slot = available_slots[i] +# interview_datetime = datetime.combine(slot['date'], slot['time']) + +# meeting_topic = f"Interview for {job.title} - {candidate.name}" +# result = create_zoom_meeting(meeting_topic, interview_datetime, schedule.interview_duration) + +# if result["status"] == "success": +# zoom_meeting = ZoomMeeting.objects.create( +# topic=meeting_topic, +# start_time=interview_datetime, +# duration=schedule.interview_duration, +# meeting_id=result["meeting_details"]["meeting_id"], +# join_url=result["meeting_details"]["join_url"], +# zoom_gateway_response=result["zoom_gateway_response"], +# ) +# ScheduledInterview.objects.create( +# candidate=candidate, +# job=job, +# zoom_meeting=zoom_meeting, +# schedule=schedule, +# interview_date=slot['date'], +# interview_time=slot['time'] +# ) +# scheduled_count += 1 +# else: +# messages.error(request, result["message"]) +# schedule.delete() +# # Clear candidate IDs session key only on error return +# if SESSION_ID_KEY in request.session: del request.session[SESSION_ID_KEY] +# return redirect("candidate_interview_view", slug=slug) + +# # 5. Success and Cleanup +# messages.success( +# request, f"Successfully scheduled {scheduled_count} interviews." +# ) + +# # Clear both session data keys upon successful completion +# if SESSION_DATA_KEY in request.session: +# del request.session[SESSION_DATA_KEY] +# if SESSION_ID_KEY in request.session: +# del request.session[SESSION_ID_KEY] + +# return redirect("job_detail", slug=slug) + + +# --- Main View Function --- + +def schedule_interviews_view(request, slug): + job = get_object_or_404(JobPosting, slug=slug) + if request.method == "POST": + # return _handle_confirm_schedule(request, slug, job) + return _handle_preview_submission(request, slug, job) + else: + return _handle_get_request(request, slug, job) +def confirm_schedule_interviews_view(request, slug): + job = get_object_or_404(JobPosting, slug=slug) + if request.method == "POST": + return _handle_confirm_schedule(request, slug, job) +# def schedule_interviews_view(request, slug): +# job = get_object_or_404(JobPosting, slug=slug) +# SESSION_KEY = f"schedule_candidate_ids_{slug}" + +# if request.method == "POST": +# form = InterviewScheduleForm(slug, request.POST) +# break_formset = BreakTimeFormSet(request.POST) + +# # Check if this is a confirmation request +# if "confirm_schedule" in request.POST: +# # Get the schedule data from session +# schedule_data = request.session.get("interview_schedule_data") +# if not schedule_data: +# messages.error(request, "Session expired. Please try again.") +# return redirect("schedule_interviews", slug=slug) + +# # Create the interview schedule +# schedule = InterviewSchedule.objects.create( +# job=job, +# created_by=request.user, +# start_date=datetime.fromisoformat(schedule_data["start_date"]).date(), +# end_date=datetime.fromisoformat(schedule_data["end_date"]).date(), +# working_days=schedule_data["working_days"], +# start_time=time.fromisoformat(schedule_data["start_time"]), +# end_time=time.fromisoformat(schedule_data["end_time"]), +# interview_duration=schedule_data["interview_duration"], +# buffer_time=schedule_data["buffer_time"], +# breaks=schedule_data["breaks"], +# ) + +# # Add candidates to the schedule +# candidates = Candidate.objects.filter(id__in=schedule_data["candidate_ids"]) +# schedule.candidates.set(candidates) + +# # Create temporary break time objects for slot calculation +# temp_breaks = [] +# for break_data in schedule_data["breaks"]: +# temp_breaks.append( +# BreakTime( +# start_time=datetime.strptime( +# break_data["start_time"], "%H:%M:%S" +# ).time(), +# end_time=datetime.strptime( +# break_data["end_time"], "%H:%M:%S" +# ).time(), +# ) +# ) + +# # Get available slots +# available_slots = get_available_time_slots(schedule) + +# # Create scheduled interviews +# scheduled_count = 0 +# for i, candidate in enumerate(candidates): +# if i < len(available_slots): +# slot = available_slots[i] +# interview_datetime = datetime.combine(slot['date'], slot['time']) + +# # Create Zoom meeting +# meeting_topic = f"Interview for {job.title} - {candidate.name}" + +# start_time = interview_datetime + +# # zoom_meeting = create_zoom_meeting( +# # topic=meeting_topic, +# # start_time=start_time, +# # duration=schedule.interview_duration +# # ) + +# result = create_zoom_meeting(meeting_topic, start_time, schedule.interview_duration) +# if result["status"] == "success": +# zoom_meeting = ZoomMeeting.objects.create( +# topic=meeting_topic, +# start_time=interview_datetime, +# duration=schedule.interview_duration, +# meeting_id=result["meeting_details"]["meeting_id"], +# join_url=result["meeting_details"]["join_url"], +# zoom_gateway_response=result["zoom_gateway_response"], +# ) +# # Create scheduled interview record +# ScheduledInterview.objects.create( +# candidate=candidate, +# job=job, +# zoom_meeting=zoom_meeting, +# schedule=schedule, +# interview_date=slot['date'], +# interview_time=slot['time'] +# ) + +# else: +# messages.error(request, result["message"]) +# schedule.delete() +# return redirect("candidate_interview_view", slug=slug) + +# # Send email to candidate +# # try: +# # send_interview_email(scheduled_interview) +# # except Exception as e: +# # messages.warning( +# # request, +# # f"Interview scheduled for {candidate.name}, but failed to send email: {str(e)}" +# # ) + +# scheduled_count += 1 + +# messages.success( +# request, f"Successfully scheduled {scheduled_count} interviews." +# ) + +# # Clear the session data +# if "interview_schedule_data" in request.session: +# del request.session["interview_schedule_data"] + +# return redirect("job_detail", slug=slug) + +# # This is the initial form submission +# if form.is_valid() and break_formset.is_valid(): +# # Get the form data +# candidates = form.cleaned_data["candidates"] +# start_date = form.cleaned_data["start_date"] +# end_date = form.cleaned_data["end_date"] +# working_days = form.cleaned_data["working_days"] +# start_time = form.cleaned_data["start_time"] +# end_time = form.cleaned_data["end_time"] +# interview_duration = form.cleaned_data["interview_duration"] +# buffer_time = form.cleaned_data["buffer_time"] + +# # Process break times +# breaks = [] +# for break_form in break_formset: +# if break_form.cleaned_data and not break_form.cleaned_data.get( +# "DELETE" +# ): +# breaks.append( +# { +# "start_time": break_form.cleaned_data[ +# "start_time" +# ].strftime("%H:%M:%S"), +# "end_time": break_form.cleaned_data["end_time"].strftime("%H:%M:%S"), +# } +# ) + +# # Create a temporary schedule object (not saved to DB) +# temp_schedule = InterviewSchedule( +# job=job, +# start_date=start_date, +# end_date=end_date, +# working_days=working_days, +# start_time=start_time, +# end_time=end_time, +# interview_duration=interview_duration, +# buffer_time=buffer_time, +# breaks=breaks, +# ) + +# # Create temporary break time objects +# temp_breaks = [] +# for break_data in breaks: +# temp_breaks.append( +# BreakTime( +# start_time=datetime.strptime( +# break_data["start_time"], "%H:%M:%S" +# ).time(), +# end_time=datetime.strptime( +# break_data["end_time"], "%H:%M:%S" +# ).time(), +# ) +# ) + +# # Get available slots +# available_slots = get_available_time_slots(temp_schedule) + +# if len(available_slots) < len(candidates): +# messages.error( +# request, +# f"Not enough available slots. Required: {len(candidates)}, Available: {len(available_slots)}", +# ) +# return render( +# request, +# "interviews/schedule_interviews.html", +# {"form": form, "break_formset": break_formset, "job": job}, +# ) + +# # Create a preview schedule +# preview_schedule = [] +# for i, candidate in enumerate(candidates): +# slot = available_slots[i] +# preview_schedule.append( +# {"candidate": candidate, "date": slot["date"], "time": slot["time"]} +# ) + +# # Save the form data to session for later use +# schedule_data = { +# "start_date": start_date.isoformat(), +# "end_date": end_date.isoformat(), +# "working_days": working_days, +# "start_time": start_time.isoformat(), +# "end_time": end_time.isoformat(), +# "interview_duration": interview_duration, +# "buffer_time": buffer_time, +# "candidate_ids": [c.id for c in candidates], +# "breaks": breaks, +# } +# request.session["interview_schedule_data"] = schedule_data + +# # Render the preview page +# return render( +# request, +# "interviews/preview_schedule.html", +# { +# "job": job, +# "schedule": preview_schedule, +# "start_date": start_date, +# "end_date": end_date, +# "working_days": working_days, +# "start_time": start_time, +# "end_time": end_time, +# "breaks": breaks, +# "interview_duration": interview_duration, +# "buffer_time": buffer_time, +# }, +# ) +# else: +# form = InterviewScheduleForm(slug=slug) +# break_formset = BreakTimeFormSet() + +# selected_ids = [] + +# # 1. Capture IDs from HTMX request and store in session (when first clicked from timeline) +# if "HX-Request" in request.headers: +# candidate_ids = request.GET.getlist("candidate_ids") +# if candidate_ids: +# request.session[SESSION_KEY] = candidate_ids +# selected_ids = candidate_ids + +# # 2. Restore IDs from session (on refresh or navigation) +# if not selected_ids: +# selected_ids = request.session.get(SESSION_KEY, []) + +# # 3. Use the list of IDs to initialize the form +# if selected_ids: +# # Load Candidate objects corresponding to the IDs +# candidates_to_load = Candidate.objects.filter(pk__in=selected_ids) +# # This line sets the selected values for {{ form.candidates }} +# form.initial["candidates"] = candidates_to_load + +# return render( +# request, +# "interviews/schedule_interviews.html", +# {"form": form, "break_formset": break_formset, "job": job}, +# ) + # def schedule_interviews_view(request, slug): # job = get_object_or_404(JobPosting, slug=slug) @@ -1487,7 +1845,7 @@ def candidate_screening_view(request, slug): job = get_object_or_404(JobPosting, slug=slug) applied_count=job.candidates.filter(stage='Applied').count() exam_count=job.candidates.filter(stage='Exam').count() - interview_count=job.candidates.filter(stage='interview').count() + interview_count=job.candidates.filter(stage='Interview').count() offer_count=job.candidates.filter(stage='Offer').count() # Get all candidates for this job, ordered by match score (descending) candidates = job.candidates.filter(stage="Applied").order_by("-match_score") @@ -1635,8 +1993,8 @@ def candidate_screening_view(request, slug): 'applied_count':applied_count, 'exam_count':exam_count, 'interview_count':interview_count, - 'offer_count':offer_count - + 'offer_count':offer_count, + "current_stage" : "Applied" } return render(request, "recruitment/candidate_screening_view.html", context) @@ -1647,9 +2005,21 @@ def candidate_exam_view(request, slug): Manage candidate tiers and stage transitions """ job = get_object_or_404(JobPosting, slug=slug) + applied_count=job.candidates.filter(stage='Applied').count() + exam_count=job.candidates.filter(stage='Exam').count() + interview_count=job.candidates.filter(stage='Interview').count() + offer_count=job.candidates.filter(stage='Offer').count() candidates = job.candidates.filter(stage="Exam").order_by("-match_score") - - return render(request, "recruitment/candidate_exam_view.html", {"job": job, "candidates": candidates}) + context = { + "job": job, + "candidates": candidates, + 'applied_count':applied_count, + 'exam_count':exam_count, + 'interview_count':interview_count, + 'offer_count':offer_count, + 'current_stage' : "Exam" + } + return render(request, "recruitment/candidate_exam_view.html", context) def update_candidate_exam_status(request, slug): candidate = get_object_or_404(Candidate, slug=slug) @@ -1704,7 +2074,11 @@ def candidate_update_status(request, slug): def candidate_interview_view(request,slug): job = get_object_or_404(JobPosting,slug=slug) - context = {"job":job,"candidates":job.candidates.filter(stage="Interview").order_by("-match_score")} + applied_count=job.candidates.filter(stage='Applied').count() + exam_count=job.candidates.filter(stage='Exam').count() + interview_count=job.candidates.filter(stage='Interview').count() + offer_count=job.candidates.filter(stage='Offer').count() + context = {"job":job,"candidates":job.candidates.filter(stage="Interview").order_by("-match_score"),'applied_count':applied_count,'exam_count':exam_count,'interview_count':interview_count,'offer_count':offer_count,"current_stage":"Interview"} return render(request,"recruitment/candidate_interview_view.html",context) def reschedule_meeting_for_candidate(request,slug,candidate_id,meeting_id): @@ -2334,9 +2708,27 @@ def schedule_meeting_for_candidate(request, slug, candidate_pk): 'job': job, 'candidate': candidate }) - + def user_detail(requests,pk): user=get_object_or_404(User,pk=pk) return render(requests,'user/profile.html') + + +@csrf_exempt +def zoom_webhook_view(request): + print(request.headers) + print(settings.ZOOM_WEBHOOK_API_KEY) + # if api_key != settings.ZOOM_WEBHOOK_API_KEY: + # return HttpResponse(status=405) + if request.method == 'POST': + try: + payload = json.loads(request.body) + async_task("recruitment.tasks.handle_zoom_webhook_event", payload) + return HttpResponse(status=200) + + except Exception: + # Bad data or internal server error + return HttpResponse(status=400) + return HttpResponse(status=405) # Method Not Allowed \ No newline at end of file diff --git a/recruitment/views_frontend.py b/recruitment/views_frontend.py index 34d3f55..51bf44f 100644 --- a/recruitment/views_frontend.py +++ b/recruitment/views_frontend.py @@ -134,13 +134,15 @@ class CandidateListView(LoginRequiredMixin, ListView): model = models.Candidate template_name = 'recruitment/candidate_list.html' context_object_name = 'candidates' - paginate_by = 10 + 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', '') + stage = self.request.GET.get('stage', '') if search_query: queryset = queryset.filter( Q(first_name__icontains=search_query) | @@ -150,7 +152,10 @@ class CandidateListView(LoginRequiredMixin, ListView): Q(stage__icontains=search_query) | Q(job__title__icontains=search_query) ) - + if job: + queryset = queryset.filter(job__slug=job) + if stage: + queryset = queryset.filter(stage=stage) # Filter for non-staff users if not self.request.user.is_staff: return models.Candidate.objects.none() # Restrict for non-staff @@ -160,6 +165,9 @@ class CandidateListView(LoginRequiredMixin, ListView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context['search_query'] = self.request.GET.get('search', '') + context['job_filter'] = self.request.GET.get('job', '') + context['stage_filter'] = self.request.GET.get('stage', '') + context['available_jobs'] = models.JobPosting.objects.all().order_by('created_at').distinct() return context diff --git a/templates/icons/video.html b/templates/icons/video.html new file mode 100644 index 0000000..b7640f6 --- /dev/null +++ b/templates/icons/video.html @@ -0,0 +1,3 @@ + + + diff --git a/templates/includes/paginator.html b/templates/includes/paginator.html new file mode 100644 index 0000000..3d60ba4 --- /dev/null +++ b/templates/includes/paginator.html @@ -0,0 +1,27 @@ +{% if is_paginated %} + + {% endif %} diff --git a/templates/interviews/preview_schedule.html b/templates/interviews/preview_schedule.html index 0cdae99..28fa4e0 100644 --- a/templates/interviews/preview_schedule.html +++ b/templates/interviews/preview_schedule.html @@ -1,18 +1,22 @@ - {% extends "base.html" %} {% load static %} {% block content %}
-

Interview Schedule Preview for {{ job.title }}

+
+

+ Interview Schedule Preview for {{ job.title }} +

+
-
+
-
Schedule Details
+
Schedule Details

Period: {{ start_date|date:"F j, Y" }} to {{ end_date|date:"F j, Y" }}

-

Working Days: +

+ Working Days: {% for day_id in working_days %} {% if day_id == 0 %}Monday{% endif %} {% if day_id == 1 %}Tuesday{% endif %} @@ -25,26 +29,32 @@ {% endfor %}

Working Hours: {{ start_time|time:"g:i A" }} to {{ end_time|time:"g:i A" }}

-
-
- {% if breaks %} -

Break Times:

-
    - {% for break in breaks %} -
  • {{ break.start_time|time:"g:i A" }} to {{ break.end_time|time:"g:i A" }}
  • - {% endfor %} -
- {% endif %}

Interview Duration: {{ interview_duration }} minutes

Buffer Time: {{ buffer_time }} minutes

+
+

Daily Break Times:

+ {% if breaks %} + +
+ {% for break in breaks %} + + + {{ break.start_time|time:"g:i A" }} — {{ break.end_time|time:"g:i A" }} + + {% endfor %} +
+ {% else %} +

No daily breaks scheduled.

+ {% endif %} +
-
+
-
Scheduled Interviews
+
Scheduled Interviews
@@ -75,7 +85,7 @@
-
+ {% csrf_token %} -
+
+
+ + {{ form.break_start_time }}
- {% endfor %} +
+ + {{ form.break_end_time }} +
+
- +
@@ -249,12 +229,13 @@ document.addEventListener('DOMContentLoaded', function() { const addBreakBtn = document.getElementById('add-break'); const breakTimesContainer = document.getElementById('break-times-container'); - // The ID is now guaranteed to be 'id_breaks-TOTAL_FORMS' thanks to the template fix - const totalFormsInput = document.getElementById('id_breaks-TOTAL_FORMS'); - // Safety check added, though the template fix should resolve the core issue + // *** FIX: Hardcode formset prefix for reliability (requires matching Python change) *** + const FORMSET_PREFIX = 'breaktime'; + const totalFormsInput = document.querySelector(`input[name="${FORMSET_PREFIX}-TOTAL_FORMS"]`); + if (!totalFormsInput) { - console.error("TOTAL_FORMS input not found. Cannot add break dynamically."); + console.error(`TOTAL_FORMS input with name ${FORMSET_PREFIX}-TOTAL_FORMS not found. Cannot add break dynamically. Please ensure formset prefix is set to '${FORMSET_PREFIX}' in the Python view and management_form is rendered.`); return; } @@ -262,20 +243,22 @@ document.addEventListener('DOMContentLoaded', function() { const formCount = parseInt(totalFormsInput.value); // Template for a new form, ensuring the correct classes are applied + // Use the hardcoded prefix in all new form fields const newFormHtml = `
- - + +
- - + +
- - - + + + + @@ -298,12 +281,17 @@ document.addEventListener('DOMContentLoaded', function() { if (form) { const deleteCheckbox = form.querySelector('input[name$="-DELETE"]'); if (deleteCheckbox) { + // Check the DELETE box and hide the form deleteCheckbox.checked = true; + form.style.display = 'none'; + } else { + // If it's a new form, remove it entirely + form.remove(); } - form.style.display = 'none'; } } }); }); + -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/jobs/edit_job.html b/templates/jobs/edit_job.html index a5abbed..cfade4d 100644 --- a/templates/jobs/edit_job.html +++ b/templates/jobs/edit_job.html @@ -73,7 +73,7 @@ border: 1px solid #ced4da; width: 100%; padding: 0.375rem 0.75rem; - box-sizing: border-box; + box-sizing: border-box; } /* ================================================= */ @@ -88,11 +88,11 @@ margin-right: -0.75rem !important; /* General cleanup to maintain look */ - box-sizing: border-box; + box-sizing: border-box; margin-bottom: 0 !important; border-radius: 0.5rem; } - + /* Set minimum heights for specific fields using sibling selector */ #id_description + .note-editor { min-height: 300px; } #id_qualifications + .note-editor { min-height: 200px; } @@ -103,7 +103,7 @@ {% endblock %} {% block content %} -
+

{# UPDATED TITLE FOR EDIT CONTEXT #} {% trans "Edit Job Posting" %} @@ -151,6 +151,20 @@ {% if form.workplace_type.errors %}
{{ form.workplace_type.errors }}
{% endif %}

+
+
+ + {{ form.application_deadline }} + {% if form.application_deadline.errors %}
{{ form.application_deadline.errors }}
{% endif %} +
+
+
+
+ + {{ form.max_applications }} + {% if form.max_applications.errors %}
{{ form.max_applications.errors }}
{% endif %} +
+
@@ -257,14 +271,6 @@ {% if form.location_country.errors %}
{{ form.location_country.errors }}
{% endif %} - -
-
- - {{ form.application_deadline }} - {% if form.application_deadline.errors %}
{{ form.application_deadline.errors }}
{% endif %} -
-
@@ -272,13 +278,6 @@ {% if form.start_date.errors %}
{{ form.start_date.errors }}
{% endif %}
-
-
- - {{ form.status }} - {% if form.status.errors %}
{{ form.status.errors }}
{% endif %} -
-
diff --git a/templates/jobs/job_list.html b/templates/jobs/job_list.html index 4b366bc..9cb014c 100644 --- a/templates/jobs/job_list.html +++ b/templates/jobs/job_list.html @@ -11,8 +11,8 @@ --kaauh-teal-dark: #004a53; --kaauh-border: #eaeff3; --kaauh-primary-text: #343a40; - --kaauh-success: #28a745; - --kaauh-danger: #dc3545; + --kaauh-success: #28a745; + --kaauh-danger: #dc3545; } /* Primary Color Overrides */ @@ -81,42 +81,42 @@ /* --- TABLE ALIGNMENT AND SIZING FIXES --- */ .table { - table-layout: fixed; + table-layout: fixed; width: 100%; - border-collapse: collapse; + border-collapse: collapse; } .table thead th { color: var(--kaauh-primary-text); - font-weight: 600; - font-size: 0.85rem; + font-weight: 600; + font-size: 0.85rem; vertical-align: middle; border-bottom: 2px solid var(--kaauh-border); - padding: 0.5rem 0.25rem; + padding: 0.5rem 0.25rem; } .table-hover tbody tr:hover { background-color: #f3f7f9; } /* Optimized Main Table Column Widths (Total must be 100%) */ - .table th:nth-child(1) { width: 22%; } - .table th:nth-child(2) { width: 12%; } - .table th:nth-child(3) { width: 8%; } - .table th:nth-child(4) { width: 8%; } - .table th:nth-child(5) { width: 50%; } + .table th:nth-child(1) { width: 22%; } + .table th:nth-child(2) { width: 12%; } + .table th:nth-child(3) { width: 8%; } + .table th:nth-child(4) { width: 8%; } + .table th:nth-child(5) { width: 50%; } /* Candidate Management Header Row (The one with P/F) */ .nested-metrics-row th { font-weight: 500; color: #6c757d; - font-size: 0.75rem; + font-size: 0.75rem; padding: 0.3rem 0; - border-bottom: 2px solid var(--kaauh-teal); + border-bottom: 2px solid var(--kaauh-teal); text-align: center; border-left: 1px solid var(--kaauh-border); } - + .nested-metrics-row th { - width: calc(50% / 7); + width: calc(50% / 7); } .nested-metrics-row th[colspan="2"] { width: calc(50% / 7 * 2); @@ -148,10 +148,10 @@ text-align: center; vertical-align: middle; font-weight: 600; - font-size: 0.9rem; - padding: 0; + font-size: 0.9rem; + padding: 0; } - .table tbody td.candidate-data-cell:not(:first-child) { + .table tbody td.candidate-data-cell:not(:first-child) { border-left: 1px solid var(--kaauh-border); } .table tbody tr td:nth-child(5) { @@ -161,15 +161,15 @@ .candidate-data-cell a { display: block; text-decoration: none; - padding: 0.4rem 0.25rem; + padding: 0.4rem 0.25rem; } - + /* Fix action button sizing */ .btn-group-sm > .btn { padding: 0.2rem 0.4rem; font-size: 0.75rem; } - + /* Additional CSS for Card View layout */ .card-view .card { height: 100%; @@ -227,7 +227,7 @@
- + @@ -236,8 +236,8 @@ {% trans "Clear" %} {% endif %} - - + +
@@ -249,19 +249,21 @@ {# --- START OF JOB LIST CONTAINER --- #}
{# View Switcher (Contains the Card/Table buttons and JS/CSS logic) #} - {% include "includes/_list_view_switcher.html" with list_id="job-list" %} + {% include "includes/_list_view_switcher.html" with list_id="job-list" %} {# 1. TABLE VIEW (Default Active) #}
- + {# --- Corrected Multi-Row Header Structure --- #} + + @@ -269,7 +271,7 @@ {% trans "Applicants Metrics" %} - + @@ -290,7 +292,7 @@ - + {% for job in jobs %} @@ -301,6 +303,8 @@ {{ job.status }} + +
{% trans "Job Title / ID" %} {% trans "Source" %}{% trans "Number Of Applicants" %}{% trans "Application Deadline" %} {% trans "Actions" %} {% trans "Manage Forms" %}
{% trans "Applied" %} {% trans "Screened" %}{% trans "Offer" %}
{{ job.get_source }}{{ job.max_applications }}{{ job.application_deadline|date:"d-m-Y" }} - + {# 2. CARD VIEW (Previously Missing) - Added Bootstrap row/col structure for layout #} -
+
{% for job in jobs %}
@@ -389,8 +393,7 @@ {# --- END CARD VIEW --- #}
{# --- END OF JOB LIST CONTAINER --- #} - - {% comment %} Fallback/Empty State {% endcomment %} + {% include "includes/paginator.html" %} {% if not jobs and not job_list_data and not page_obj %}
diff --git a/templates/jobs/partials/applicant_tracking.html b/templates/jobs/partials/applicant_tracking.html index 382652c..9200925 100644 --- a/templates/jobs/partials/applicant_tracking.html +++ b/templates/jobs/partials/applicant_tracking.html @@ -1,4 +1,4 @@ -{% load static i18n %} +{% load static i18n %} +
- {% comment %} STAGE 1: Applied {% endcomment %} -
- +
+
{% trans "Screened" %}
{{ applied_count|default:"0" }}
@@ -143,7 +142,7 @@ {% comment %} STAGE 2: Exam {% endcomment %}
@@ -157,7 +156,7 @@ {% comment %} STAGE 3: Interview {% endcomment %}
diff --git a/templates/meetings/list_meetings.html b/templates/meetings/list_meetings.html index a602ac3..baca877 100644 --- a/templates/meetings/list_meetings.html +++ b/templates/meetings/list_meetings.html @@ -139,6 +139,26 @@ .text-muted.fa-3x { color: var(--kaauh-teal-dark) !important; } + @keyframes svg-pulse { + 0% { + transform: scale(0.9); + opacity: 0.8; + } + 50% { + transform: scale(1.1); + opacity: 1; + } + 100% { + transform: scale(0.9); + opacity: 0.8; + } + } + + /* Apply the animation to the custom class */ + .svg-pulse { + animation: svg-pulse 2s infinite ease-in-out; + transform-origin: center; /* Ensure scaling is centered */ + } {% endblock %} @@ -167,6 +187,7 @@
{% if search_query %}{% endif %} + {% if status_filter %}{% endif %}
@@ -177,13 +198,16 @@
- +
+ + +

+ {% trans "Candidate" %}: {% with interview=meeting.interview_details.first %}{% if interview %}{{ interview.candidate.name }}{% else %}{% trans "N/A" %}{% endif %}{% endwith %}
+ {% trans "Job" %}: {% with interview=meeting.interview_details.first %}{% if interview %}{{ interview.job.title }}{% else %}{% trans "N/A" %}{% endif %}{% endwith %}
{% trans "ID" %}: {{ meeting.meeting_id|default:meeting.id }}
- {% trans "Start" %}: {{ meeting.start_time|date:"M d, Y H:i" }}
- {% trans "Duration" %}: {{ meeting.duration }} minutes + {% trans "Start" %}: {{ meeting.start_time|date:"M d, Y H:i" }} ({{ meeting.timezone }})
+ {% trans "Duration" %}: {{ meeting.duration }} minutes{% if meeting.password %}
{% trans "Password" %}: Yes{% endif %}

@@ -254,25 +280,40 @@ - - - - - - + + + + + + + + {% for meeting in meetings %} - - - + + + + +
{% trans "Topic" %}{% trans "ID" %}{% trans "Start Time" %}{% trans "Duration" %}{% trans "Status" %}{% trans "Actions" %}{% trans "Topic" %}{% trans "Candidate" %}{% trans "Job" %}{% trans "ID" %}{% trans "Start Time" %}{% trans "Duration" %}{% trans "Status" %}{% trans "Actions" %}
{{ meeting.topic }}{{ meeting.meeting_id|default:meeting.id }}{{ meeting.start_time|date:"M d, Y H:i" }}{{ meeting.duration }} min - - {{ meeting.status|title }} - + {% with interview=meeting.interview_details.first %} + {% if interview %}{{ interview.candidate.name }}{% else %}{% trans "N/A" %}{% endif %} + {% endwith %} + + {% with interview=meeting.interview_details.first %} + {% if interview %}{{ interview.job.title }}{% else %}{% trans "N/A" %}{% endif %} + {% endwith %} + {{ meeting.meeting_id|default:meeting.id }}{{ meeting.start_time|date:"M d, Y H:i" }} ({{ meeting.timezone }}){{ meeting.duration }} min{% if meeting.password %} ({% trans "Password" %}){% endif %} + {% if meeting.status == "started" %} + + {{ meeting.status|title }} + {% include "icons/video.html" %} + + {% endif %}
@@ -344,4 +385,4 @@
{% endif %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/recruitment/candidate_list.html b/templates/recruitment/candidate_list.html index 8c350b8..8588135 100644 --- a/templates/recruitment/candidate_list.html +++ b/templates/recruitment/candidate_list.html @@ -67,7 +67,7 @@ color: white; border-color: var(--kaauh-teal-dark); } - + /* Card Specifics (Adapted from Job Card to Candidate Card) */ .candidate-card .card-title { color: var(--kaauh-teal-dark); @@ -76,7 +76,7 @@ } .candidate-card .card-text i { color: var(--kaauh-teal); - width: 1.25rem; + width: 1.25rem; } /* Table & Card Badge Styling (Unified) */ @@ -90,7 +90,7 @@ /* Status Badge Mapping (Using standard Bootstrap names where possible) */ .bg-primary { background-color: var(--kaauh-teal) !important; color: white !important;} /* Main job/stage badge */ - .bg-success { background-color: #28a745 !important; color: white !important;} + .bg-success { background-color: #28a745 !important; color: white !important;} .bg-warning { background-color: #ffc107 !important; color: #343a40 !important;} /* Table Styling (Consistent with Reference) */ @@ -112,7 +112,7 @@ .table-view .table tbody tr:hover { background-color: var(--kaauh-gray-light); } - + /* Pagination Link Styling (Consistent) */ .pagination .page-item .page-link { color: var(--kaauh-teal-dark); @@ -126,7 +126,7 @@ .pagination .page-item:hover .page-link:not(.active) { background-color: #e9ecef; } - + /* Filter & Search Layout Adjustments */ .filter-buttons { display: flex; @@ -161,18 +161,27 @@
{% url 'candidate_list' as candidate_list_url %} - + {% if search_query %}{% endif %} -
+
- + + {% for job in available_jobs %} - {% endfor %} + {% endfor %} + +
@@ -260,7 +269,7 @@
{{ candidate.name }}
{{ candidate.stage }}
- +

{{ candidate.email }}
{{ candidate.phone|default:"N/A" }}
@@ -293,33 +302,7 @@

{# Pagination (Standardized to Reference) #} - {% if is_paginated %} - - {% endif %} + {% include "includes/paginator.html" %} {% else %}