diff --git a/NorahUniversity/__pycache__/settings.cpython-312.pyc b/NorahUniversity/__pycache__/settings.cpython-312.pyc index b1e307c..a700acd 100644 Binary files a/NorahUniversity/__pycache__/settings.cpython-312.pyc and b/NorahUniversity/__pycache__/settings.cpython-312.pyc differ diff --git a/recruitment/__pycache__/forms.cpython-312.pyc b/recruitment/__pycache__/forms.cpython-312.pyc index 54a2743..40890a1 100644 Binary files a/recruitment/__pycache__/forms.cpython-312.pyc and b/recruitment/__pycache__/forms.cpython-312.pyc differ diff --git a/recruitment/__pycache__/models.cpython-312.pyc b/recruitment/__pycache__/models.cpython-312.pyc index 649b789..a99202c 100644 Binary files a/recruitment/__pycache__/models.cpython-312.pyc and b/recruitment/__pycache__/models.cpython-312.pyc differ diff --git a/recruitment/__pycache__/urls.cpython-312.pyc b/recruitment/__pycache__/urls.cpython-312.pyc index 3222e0f..c68f7e6 100644 Binary files a/recruitment/__pycache__/urls.cpython-312.pyc and b/recruitment/__pycache__/urls.cpython-312.pyc differ diff --git a/recruitment/__pycache__/views.cpython-312.pyc b/recruitment/__pycache__/views.cpython-312.pyc index 3b9e9d6..0f15f54 100644 Binary files a/recruitment/__pycache__/views.cpython-312.pyc and b/recruitment/__pycache__/views.cpython-312.pyc differ diff --git a/recruitment/forms.py b/recruitment/forms.py index 3d33c2a..76bbfc9 100644 --- a/recruitment/forms.py +++ b/recruitment/forms.py @@ -11,7 +11,7 @@ from .models import ( ZoomMeeting, Candidate,TrainingMaterial,JobPosting, FormTemplate,InterviewSchedule,BreakTime,JobPostingImage, Profile,MeetingComment,ScheduledInterview,Source,HiringAgency, - AgencyJobAssignment, AgencyAccessLink,Participants + AgencyJobAssignment, AgencyAccessLink,Participants,OnsiteMeeting ) # from django_summernote.widgets import SummernoteWidget from django_ckeditor_5.widgets import CKEditor5Widget @@ -1594,24 +1594,29 @@ KAAUH HIRING TEAM -class InterviewScheduleLocationForm(forms.ModelForm): - class Meta: - model=InterviewSchedule - fields=['location'] - widgets={ - 'location': forms.TextInput(attrs={'placeholder': 'Enter Interview Location'}), - } +# class OnsiteLocationForm(forms.ModelForm): +# class Meta: +# model= +# fields=['location'] +# widgets={ +# 'location': forms.TextInput(attrs={'placeholder': 'Enter Interview Location'}), +# } - - - - - - - - - - - \ No newline at end of file +class OnsiteMeetingForm(forms.ModelForm): + class Meta: + model = OnsiteMeeting + fields = ['topic', 'start_time', 'duration', 'timezone', 'location', 'status'] + widgets = { + 'topic': forms.TextInput(attrs={'placeholder': 'Enter the Meeting Topic', 'class': 'form-control'}), + 'start_time': forms.DateTimeInput( + attrs={'type': 'datetime-local', 'class': 'form-control'} + ), + 'duration': forms.NumberInput( + attrs={'min': 15, 'placeholder': 'Duration in minutes', 'class': 'form-control'} + ), + 'location': forms.TextInput(attrs={'placeholder': 'Physical location', 'class': 'form-control'}), + 'timezone': forms.TextInput(attrs={'class': 'form-control'}), + 'status': forms.Select(attrs={'class': 'form-control'}), + } \ No newline at end of file diff --git a/recruitment/migrations/0012_interviewschedule_interview_topic.py b/recruitment/migrations/0012_interviewschedule_interview_topic.py new file mode 100644 index 0000000..fbf21b7 --- /dev/null +++ b/recruitment/migrations/0012_interviewschedule_interview_topic.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.7 on 2025-11-10 09:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0011_alter_scheduledinterview_zoom_meeting'), + ] + + operations = [ + migrations.AddField( + model_name='interviewschedule', + name='interview_topic', + field=models.CharField(blank=True, null=True), + ), + ] diff --git a/recruitment/migrations/0013_onsitemeeting_and_more.py b/recruitment/migrations/0013_onsitemeeting_and_more.py new file mode 100644 index 0000000..4cb09d6 --- /dev/null +++ b/recruitment/migrations/0013_onsitemeeting_and_more.py @@ -0,0 +1,45 @@ +# Generated by Django 5.2.7 on 2025-11-10 13:00 + +import django.db.models.deletion +import django_extensions.db.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0012_interviewschedule_interview_topic'), + ] + + operations = [ + migrations.CreateModel( + name='OnsiteMeeting', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), + ('topic', models.CharField(max_length=255, verbose_name='Topic')), + ('start_time', models.DateTimeField(db_index=True, verbose_name='Start Time')), + ('duration', models.PositiveIntegerField(verbose_name='Duration')), + ('timezone', models.CharField(max_length=50, verbose_name='Timezone')), + ('location', models.CharField(blank=True, null=True)), + ], + options={ + 'abstract': False, + }, + ), + migrations.RemoveField( + model_name='interviewschedule', + name='interview_topic', + ), + migrations.RemoveField( + model_name='interviewschedule', + name='location', + ), + migrations.AddField( + model_name='scheduledinterview', + name='onsite_meeting', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interview', to='recruitment.onsitemeeting'), + ), + ] diff --git a/recruitment/migrations/0014_onsitemeeting_status.py b/recruitment/migrations/0014_onsitemeeting_status.py new file mode 100644 index 0000000..78270f1 --- /dev/null +++ b/recruitment/migrations/0014_onsitemeeting_status.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.7 on 2025-11-10 13:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0013_onsitemeeting_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='onsitemeeting', + name='status', + field=models.CharField(blank=True, db_index=True, default='waiting', max_length=20, null=True, verbose_name='Status'), + ), + ] diff --git a/recruitment/migrations/0015_alter_scheduledinterview_onsite_meeting.py b/recruitment/migrations/0015_alter_scheduledinterview_onsite_meeting.py new file mode 100644 index 0000000..3127c6b --- /dev/null +++ b/recruitment/migrations/0015_alter_scheduledinterview_onsite_meeting.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.7 on 2025-11-10 13:55 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0014_onsitemeeting_status'), + ] + + operations = [ + migrations.AlterField( + model_name='scheduledinterview', + name='onsite_meeting', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='onsite_interview', to='recruitment.onsitemeeting'), + ), + ] diff --git a/recruitment/models.py b/recruitment/models.py index a4c54d2..bd25ec7 100644 --- a/recruitment/models.py +++ b/recruitment/models.py @@ -731,6 +731,27 @@ class TrainingMaterial(Base): def __str__(self): return self.title +class OnsiteMeeting(Base): + class MeetingStatus(models.TextChoices): + WAITING = "waiting", _("Waiting") + STARTED = "started", _("Started") + ENDED = "ended", _("Ended") + CANCELLED = "cancelled",_("Cancelled") + # Basic meeting details + topic = models.CharField(max_length=255, verbose_name=_("Topic")) + start_time = models.DateTimeField(db_index=True, verbose_name=_("Start Time")) # Added index + duration = models.PositiveIntegerField( + verbose_name=_("Duration") + ) # Duration in minutes + timezone = models.CharField(max_length=50, verbose_name=_("Timezone")) + location=models.CharField(null=True,blank=True) + status = models.CharField( + db_index=True, max_length=20, # Added index + null=True, + blank=True, + verbose_name=_("Status"), + default=MeetingStatus.WAITING, + ) class ZoomMeeting(Base): class MeetingStatus(models.TextChoices): @@ -1613,7 +1634,6 @@ class InterviewSchedule(Base): verbose_name="Interview Meeting Type" ) - location=models.CharField(null=True,blank=True,default='Remote') job = models.ForeignKey( JobPosting, on_delete=models.CASCADE, related_name="interview_schedules", db_index=True @@ -1673,6 +1693,11 @@ class ScheduledInterview(Base): ZoomMeeting, on_delete=models.CASCADE, related_name="interview", db_index=True, null=True, blank=True ) + + onsite_meeting= models.OneToOneField( + OnsiteMeeting, on_delete=models.CASCADE, related_name="onsite_interview", db_index=True, + null=True, blank=True + ) schedule = models.ForeignKey( InterviewSchedule, on_delete=models.CASCADE, related_name="interviews",null=True,blank=True, db_index=True ) diff --git a/recruitment/templatetags/url_extras.py b/recruitment/templatetags/url_extras.py new file mode 100644 index 0000000..3c46e0a --- /dev/null +++ b/recruitment/templatetags/url_extras.py @@ -0,0 +1,19 @@ +from django import template + +register = template.Library() + +@register.simple_tag +def add_get_params(request_get, *args): + """ + Constructs a GET query string by preserving all current + parameters EXCEPT 'page', which is handled separately. + """ + params = request_get.copy() + + # Remove the page parameter to prevent it from duplicating or interfering + if 'page' in params: + del params['page'] + + # Return the URL-encoded string (e.g., department=IT&employment_type=FULL_TIME) + # The template prepends the '&' and the 'page=X' + return params.urlencode() \ No newline at end of file diff --git a/recruitment/urls.py b/recruitment/urls.py index ad838ea..794e2c5 100644 --- a/recruitment/urls.py +++ b/recruitment/urls.py @@ -14,6 +14,7 @@ urlpatterns = [ path('jobs//update/', views.edit_job, name='job_update'), # path('jobs//delete/', views., name='job_delete'), path('jobs//', views.job_detail, name='job_detail'), + path('jobs//download/cvs/', views.job_cvs_download, name='job_cvs_download'), path('careers/',views.kaauh_career,name='kaauh_career'), @@ -234,8 +235,14 @@ urlpatterns = [ path('jobs//candidates/compose_email/', views.compose_candidate_email, name='compose_candidate_email'), path('interview/partcipants//',views.create_interview_participants,name='create_interview_participants'), path('interview/email//',views.send_interview_email,name='send_interview_email'), - path('interview/schedule/location//',views.schedule_interview_location_form,name='schedule_interview_location_form'), - path('interview/list',views.InterviewListView,name='interview_list') + + + + # # --- SCHEDULED INTERVIEW URLS (New Centralized Management) --- + # path('interview/list/', views.InterviewListView.as_view(), name='interview_list'), + # path('interviews//', views.ScheduledInterviewDetailView.as_view(), name='scheduled_interview_detail'), + # path('interviews//update/', views.ScheduledInterviewUpdateView.as_view(), name='update_scheduled_interview'), + # path('interviews//delete/', views.ScheduledInterviewDeleteView.as_view(), name='delete_scheduled_interview'), ] diff --git a/recruitment/views.py b/recruitment/views.py index 6cb373f..c77b52e 100644 --- a/recruitment/views.py +++ b/recruitment/views.py @@ -1,5 +1,8 @@ import json +import io +import zipfile +from django.core.paginator import Paginator from django.utils.translation import gettext as _ from django.contrib.auth.models import User from django.contrib.auth.decorators import login_required @@ -21,6 +24,7 @@ from django.db.models import F, IntegerField, Count, Avg, Sum, Q, ExpressionWrap 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.urls import reverse_lazy from django.db.models import Count, Avg, F,Q from .forms import ( CandidateExamDateForm, @@ -44,7 +48,7 @@ from .forms import ( CandidateEmailForm, SourceForm, InterviewEmailForm, - InterviewScheduleLocationForm + ) from easyaudit.models import CRUDEvent, LoginEvent, RequestEvent from rest_framework import viewsets @@ -53,7 +57,7 @@ from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger from .linkedin_service import LinkedInService from .serializers import JobPostingSerializer, CandidateSerializer from django.shortcuts import get_object_or_404, render, redirect -from django.views.generic import CreateView, UpdateView, DetailView, ListView +from django.views.generic import CreateView, UpdateView, DetailView, ListView,DeleteView from .utils import ( create_zoom_meeting, delete_zoom_meeting, @@ -194,20 +198,17 @@ class ZoomMeetingListView(LoginRequiredMixin, ListView): context["candidate_name_filter"] = self.request.GET.get("candidate_name", "") return context -@login_required -def InterviewListView(request): - interview_type=request.GET.get('interview_type','Remote') - print(interview_type) - if interview_type=='Onsite': - meetings=ScheduledInterview.objects.filter(schedule__interview_type=interview_type) - else: - meetings=ZoomMeeting.objects.all() - print(meetings) - return render(request, "meetings/list_meetings.html",{ - 'meetings':meetings, - 'current_interview_type':interview_type - }) +# @login_required +# def InterviewListView(request): +# # interview_type=request.GET.get('interview_type','Remote') +# # print(interview_type) +# interview_type='Onsite' +# meetings=ScheduledInterview.objects.filter(schedule__interview_type=interview_type) +# return render(request, "meetings/list_meetings.html",{ +# 'meetings':meetings, +# }) + # search_query = request.GET.get("q", "") # Renamed from 'search' to 'q' for consistency # if search_query: # interviews = interviews.filter( @@ -239,7 +240,10 @@ class ZoomMeetingDetailsView(LoginRequiredMixin, DetailView): def get_context_data(self, **kwargs): context=super().get_context_data(**kwargs) meeting = self.object - interview=meeting.interview + try: + interview=meeting.interview + except Exception as e: + print(e) candidate = interview.candidate job=meeting.get_job @@ -402,10 +406,13 @@ def edit_job(request, slug): SCORE_PATH = 'ai_analysis_data__analysis_data__match_score' HIGH_POTENTIAL_THRESHOLD=75 +from django.contrib.sites.shortcuts import get_current_site @login_required def job_detail(request, slug): """View details of a specific job""" job = get_object_or_404(JobPosting, slug=slug) + current_site=get_current_site(request) + print(current_site) # Get all candidates for this job, ordered by most recent applicants = job.candidates.all().order_by("-created_at") @@ -556,6 +563,60 @@ def job_detail(request, slug): } return render(request, "jobs/job_detail.html", context) + + +ALLOWED_EXTENSIONS = ('.pdf', '.docx') + +def job_cvs_download(request,slug): + + job = get_object_or_404(JobPosting,slug=slug) + entries=Candidate.objects.filter(job=job) + + + # 2. Create an in-memory byte stream (BytesIO) + zip_buffer = io.BytesIO() + + # 3. Create the ZIP archive + with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf: + + for entry in entries: + # Check if the file field has a file + if not entry.resume: + continue + + # Get the file name and check extension (case-insensitive) + file_name = entry.resume.name.split('/')[-1] + file_name_lower = file_name.lower() + + if file_name_lower.endswith(ALLOWED_EXTENSIONS): + try: + # Open the file object (rb is read binary) + file_obj = entry.resume.open('rb') + + # *** ROBUST METHOD: Read the content and write it to the ZIP *** + file_content = file_obj.read() + + # Write the file content directly to the ZIP archive + zf.writestr(file_name, file_content) + + file_obj.close() + + except Exception as e: + # Log the error but continue with the rest of the files + print(f"Error processing file {file_name}: {e}") + continue + + # 4. Prepare the response + zip_buffer.seek(0) + + # 5. Create the HTTP response + response = HttpResponse(zip_buffer.read(), content_type='application/zip') + + # Set the header for the browser to download the file + response['Content-Disposition'] = 'attachment; filename=f"all_cvs_for_{job.title}.zip"' + + return response + @login_required def job_image_upload(request, slug): #only for handling the post request @@ -612,6 +673,20 @@ def edit_linkedin_post_content(request,slug): +JOB_TYPES = [ + ("FULL_TIME", "Full-time"), + ("PART_TIME", "Part-time"), + ("CONTRACT", "Contract"), + ("INTERNSHIP", "Internship"), + ("FACULTY", "Faculty"), + ("TEMPORARY", "Temporary"), +] + +WORKPLACE_TYPES = [ + ("ON_SITE", "On-site"), + ("REMOTE", "Remote"), + ("HYBRID", "Hybrid"), +] def kaauh_career(request): @@ -621,8 +696,48 @@ def kaauh_career(request): status='ACTIVE', form_template__is_active=True ) + selected_department=request.GET.get('department','') + department_type_keys=active_jobs.exclude( + department__isnull=True + ).exclude(department__exact='' + ).values_list( + 'department', + flat=True + ).distinct().order_by('department') + + if selected_department and selected_department in department_type_keys: + active_jobs=active_jobs.filter(department=selected_department) + selected_workplace_type=request.GET.get('workplace_type','') + print(selected_workplace_type) + selected_job_type = request.GET.get('employment_type', '') + + job_type_keys = active_jobs.values_list('job_type', flat=True).distinct() + workplace_type_keys=active_jobs.values_list('workplace_type',flat=True).distinct() + if selected_job_type and selected_job_type in job_type_keys: + active_jobs=active_jobs.filter(job_type=selected_job_type) + if selected_workplace_type and selected_workplace_type in workplace_type_keys: + active_jobs=active_jobs.filter(workplace_type=selected_workplace_type) - return render(request,'applicant/career.html',{'active_jobs':active_jobs}) + JOBS_PER_PAGE=10 + paginator = Paginator(active_jobs, JOBS_PER_PAGE) + page_number = request.GET.get('page', 1) + + try: + page_obj = paginator.get_page(page_number) + except EmptyPage: + page_obj = paginator.page(paginator.num_pages) + + total_open_roles=active_jobs.all().count() + + + return render(request,'applicant/career.html',{'active_jobs': page_obj.object_list, + 'job_type_keys':job_type_keys, + 'selected_job_type':selected_job_type, + 'workplace_type_keys':workplace_type_keys, + 'selected_workplace_type':selected_workplace_type, + 'selected_department':selected_department, + 'department_type_keys':department_type_keys, + 'total_open_roles': total_open_roles,'page_obj': page_obj}) # job detail facing the candidate: def application_detail(request, slug): @@ -4131,18 +4246,19 @@ def send_interview_email(request, slug): -def schedule_interview_location_form(request,slug): - schedule=get_object_or_404(InterviewSchedule,slug=slug) - if request.method=='POST': - form=InterviewScheduleLocationForm(request.POST,instance=schedule) - form.save() - return redirect('list_meetings') - else: - form=InterviewScheduleLocationForm(instance=schedule) - return render(request,'interviews/schedule_interview_location_form.html',{'form':form,'schedule':schedule}) +# def schedule_interview_location_form(request,slug): +# schedule=get_object_or_404(InterviewSchedule,slug=slug) +# if request.method=='POST': +# form=InterviewScheduleLocationForm(request.POST,instance=schedule) +# form.save() +# return redirect('list_meetings') +# else: +# form=InterviewScheduleLocationForm(instance=schedule) +# return render(request,'interviews/schedule_interview_location_form.html',{'form':form,'schedule':schedule}) def onsite_interview_list_view(request): onsite_interviews=ScheduledInterview.objects.filter(schedule__interview_type='Onsite') return render(request,'interviews/onsite_interview_list.html',{'onsite_interviews':onsite_interviews}) + diff --git a/templates/applicant/application_detail.html b/templates/applicant/application_detail.html index 64465e6..ac319ea 100644 --- a/templates/applicant/application_detail.html +++ b/templates/applicant/application_detail.html @@ -1,172 +1,216 @@ -{% extends 'applicant/partials/candidate_facing_base.html'%} +{% extends 'applicant/partials/candidate_facing_base.html' %} {% load static i18n %} {% block content %} - +{% if messages %} +
+
+ {# Use responsive columns matching the main content block for alignment #} +
+ {% for message in messages %} + + {% endfor %} +
+
+
+ {% endif %} + + {# ================================================= #} + {# DJANGO MESSAGE BLOCK - Placed directly below the main navbar #} + {# ================================================= #} + + {# ================================================= #} + + {% block content %} + {% endblock content %} + diff --git a/templates/base.html b/templates/base.html index 0463281..404faaa 100644 --- a/templates/base.html +++ b/templates/base.html @@ -208,7 +208,11 @@ {% trans "Sign Out" %} - + + {% comment %} + + {% trans "Sign Out" %} + {% endcomment %} {% endif %} @@ -255,7 +259,7 @@ - + {% endcomment %}