diff --git a/.env b/.env new file mode 100644 index 0000000..8d7fbd5 --- /dev/null +++ b/.env @@ -0,0 +1,3 @@ +DB_NAME=haikal_db +DB_USER=faheed +DB_PASSWORD=Faheed@215 \ No newline at end of file diff --git a/recruitment/email_service.py b/recruitment/email_service.py index 1a9af65..750bf12 100644 --- a/recruitment/email_service.py +++ b/recruitment/email_service.py @@ -318,7 +318,7 @@ def send_bulk_email(subject, message, recipient_list, request=None, attachments= sender_user_id, job_id, hook='recruitment.tasks.email_success_hook', - + ) task_ids.append(task_id) diff --git a/recruitment/forms.py b/recruitment/forms.py index a2a9291..51085ad 100644 --- a/recruitment/forms.py +++ b/recruitment/forms.py @@ -302,13 +302,25 @@ class ApplicationForm(forms.ModelForm): "hiring_agency": forms.Select(attrs={"class": "form-select"}), } - def __init__(self, *args, **kwargs): + def __init__(self, *args,current_agency=None,current_job=None,**kwargs): super().__init__(*args, **kwargs) self.helper = FormHelper() self.helper.form_method = "post" self.helper.form_class = "form-horizontal" self.helper.label_class = "col-md-3" self.helper.field_class = "col-md-9" + if current_agency: + # IMPORTANT: Replace 'agency' below with the actual field name + # on your Person model that links it back to the Agency model. + self.fields['person'].queryset = self.fields['person'].queryset.filter( + agency=current_agency + ) + self.fields['job'].queryset = self.fields['job'].queryset.filter( + pk=current_job.id + ) + self.fields['job'].initial = current_job + + self.fields['job'].widget.attrs['readonly'] = True # Make job field read-only if it's being pre-populated job_value = self.initial.get("job") @@ -1990,7 +2002,7 @@ class OnsiteLocationForm(forms.ModelForm): class InterviewEmailForm(forms.Form): subject = forms.CharField(max_length=255, widget=forms.TextInput(attrs={'class': 'form-control'})) message_for_candidate = forms.CharField(widget=forms.Textarea(attrs={'class': 'form-control', 'rows': 6})) - message_for_agency = forms.CharField(widget=forms.Textarea(attrs={'class': 'form-control', 'rows': 6})) + message_for_agency = forms.CharField(widget=forms.Textarea(attrs={'class': 'form-control', 'rows': 6}),required=False) message_for_participants = forms.CharField(widget=forms.Textarea(attrs={'class': 'form-control', 'rows': 6})) def __init__(self, *args, candidate, external_participants, system_participants, meeting, job, **kwargs): @@ -2025,7 +2037,7 @@ class InterviewEmailForm(forms.Form): job_title = job.title agency_name = ( candidate.hiring_agency.name - if candidate.belong_to_agency and candidate.hiring_agency + if candidate.belong_to_an_agency and candidate.hiring_agency else "Hiring Agency" ) diff --git a/recruitment/migrations/0003_jobposting_cv_zip_file_jobposting_zip_created.py b/recruitment/migrations/0003_jobposting_cv_zip_file_jobposting_zip_created.py new file mode 100644 index 0000000..9654118 --- /dev/null +++ b/recruitment/migrations/0003_jobposting_cv_zip_file_jobposting_zip_created.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.7 on 2025-11-19 14:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0002_alter_jobposting_job_type_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='jobposting', + name='cv_zip_file', + field=models.FileField(blank=True, null=True, upload_to='job_zips/'), + ), + migrations.AddField( + model_name='jobposting', + name='zip_created', + field=models.BooleanField(default=False), + ), + ] diff --git a/recruitment/models.py b/recruitment/models.py index b05faad..2cef9e2 100644 --- a/recruitment/models.py +++ b/recruitment/models.py @@ -243,6 +243,11 @@ class JobPosting(Base): help_text=_("Whether the job posting has been parsed by AI"), verbose_name=_("AI Parsed"), ) + # Field to store the generated zip file + cv_zip_file = models.FileField(upload_to='job_zips/', null=True, blank=True) + + # Field to track if the background task has completed + zip_created = models.BooleanField(default=False) class Meta: ordering = ["-created_at"] @@ -984,6 +989,14 @@ class Application(Base): content_type = ContentType.objects.get_for_model(self.__class__) return Document.objects.filter(content_type=content_type, object_id=self.id) + + @property + def belong_to_an_agency(self): + if self.hiring_agency: + return True + else: + return False + class TrainingMaterial(Base): diff --git a/recruitment/tasks.py b/recruitment/tasks.py index 7a185d6..511aa8f 100644 --- a/recruitment/tasks.py +++ b/recruitment/tasks.py @@ -847,3 +847,53 @@ def email_success_hook(task): logger.error(f"Task ID {task.id} failed. Error: {task.result}") + + +import io +import zipfile +import os +from django.core.files.base import ContentFile +from django.conf import settings +from .models import Application, JobPosting # Import your models + +ALLOWED_EXTENSIONS = (".pdf", ".docx") + +def generate_and_save_cv_zip(job_posting_id): + """ + Generates a zip file of all CVs for a job posting and saves it to the job model. + """ + job = JobPosting.objects.get(id=job_posting_id) + entries = Application.objects.filter(job=job) + + zip_buffer = io.BytesIO() + + with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf: + for entry in entries: + if not entry.resume: + continue + + file_name = entry.resume.name.split("/")[-1] + file_name_lower = file_name.lower() + + if file_name_lower.endswith(ALLOWED_EXTENSIONS): + try: + with entry.resume.open("rb") as file_obj: + file_content = file_obj.read() + zf.writestr(file_name, file_content) + + except Exception as e: + # Log the error using Django's logging system if set up + print(f"Error processing file {file_name}: {e}") + continue + + # 4. Save the generated zip buffer to the JobPosting model + zip_buffer.seek(0) + now = str(timezone.now()) + zip_filename = f"all_cvs_for_{job.slug}_{job.title}_{now}.zip" + + # Use ContentFile to save the bytes stream into the FileField + job.cv_zip_file.save(zip_filename, ContentFile(zip_buffer.read())) + job.zip_created = True # Assuming you added a BooleanField for tracking completion + job.save() + + return f"Successfully created zip for Job ID {job.slug} {job_posting_id}" \ No newline at end of file diff --git a/recruitment/urls.py b/recruitment/urls.py index 5b60895..2fbcb4d 100644 --- a/recruitment/urls.py +++ b/recruitment/urls.py @@ -23,7 +23,9 @@ 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('jobs//download/cvs/', views.job_cvs_download, name='job_cvs_download'), + path('job//request-download/', views.request_cvs_download, name='request_cvs_download'), + path('job//download-ready/', views.download_ready_cvs, name='download_ready_cvs'), path('careers/',views.kaauh_career,name='kaauh_career'), diff --git a/recruitment/views.py b/recruitment/views.py index 1a01d9d..65b5ceb 100644 --- a/recruitment/views.py +++ b/recruitment/views.py @@ -157,14 +157,37 @@ class PersonListView(StaffRequiredMixin, ListView): model = Person template_name = "people/person_list.html" context_object_name = "people_list" + def get_queryset(self): + queryset=super().get_queryset() + gender=self.request.GET.get('gender') + if gender: + queryset=queryset.filter(gender=gender) + + nationality=self.request.GET.get('nationality') + if nationality: + queryset=queryset.filter(nationality=nationality) + + return queryset + def get_context_data(self, **kwargs): + context=super().get_context_data(**kwargs) + # We query the base model to ensure we list ALL options, not just those currently displayed. + nationalities = self.model.objects.values_list('nationality', flat=True).filter( + nationality__isnull=False + ).distinct().order_by('nationality') + + nationality=self.request.GET.get('nationality') + context['nationality']=nationality + context['nationalities']=nationalities + return context + class PersonCreateView(CreateView): model = Person template_name = "people/create_person.html" form_class = PersonForm - # success_url = reverse_lazy("person_list") - + success_url = reverse_lazy("person_list") + print("from agency") def form_valid(self, form): if "HX-Request" in self.request.headers: instance = form.save() @@ -596,59 +619,89 @@ def job_detail(request, slug): return render(request, "jobs/job_detail.html", context) -ALLOWED_EXTENSIONS = (".pdf", ".docx") +# ALLOWED_EXTENSIONS = (".pdf", ".docx") -def job_cvs_download(request, slug): +# def job_cvs_download(request, slug): +# job = get_object_or_404(JobPosting, slug=slug) +# entries = Application.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"] = ( +# f'attachment; filename="all_cvs_for_{job.title}.zip"' +# ) + +# return response + +def request_cvs_download(request, slug): + """ + View to initiate the background task. + """ job = get_object_or_404(JobPosting, slug=slug) - entries = Application.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"] = ( - f'attachment; filename="all_cvs_for_{job.title}.zip"' - ) - - return response + job.zip_created = False + job.save(update_fields=["zip_created"]) + # Use async_task to run the function in the background + # Pass only simple arguments (like the job ID) + async_task('recruitment.tasks.generate_and_save_cv_zip', job.id) + + # Provide user feedback and redirect + messages.info(request, "The CV compilation has started in the background. It may take a few moments. Refresh this page to check status.") + return redirect('job_detail', slug=slug) # Redirect back to the job detail page +def download_ready_cvs(request, slug): + """ + View to serve the file once it is ready. + """ + job = get_object_or_404(JobPosting, slug=slug) + if job.cv_zip_file and job.zip_created: + # Django FileField handles the HttpResponse and file serving easily + response = HttpResponse(job.cv_zip_file.read(), content_type="application/zip") + response["Content-Disposition"] = f'attachment; filename="{job.cv_zip_file.name.split("/")[-1]}"' + return response + else: + # File is not ready or doesn't exist + messages.warning(request, "The ZIP file is still being generated or an error occurred.") + return redirect('job_detail', slug=slug) + @login_required @staff_user_required def job_image_upload(request, slug): @@ -2938,16 +2991,17 @@ def staff_assignment_view(request, slug): applications = job.applications.all() if request.method == "POST": - form = StaffAssignmentForm(request.POST) + form = StaffAssignmentForm(request.POST, instance=job) + if form.is_valid(): - assignment = form.save(commit=False) + assignment = form.save() messages.success(request, f"Staff assigned to job '{job.title}' successfully!") return redirect("job_detail", slug=job.slug) else: messages.error(request, "Please correct the errors below.") else: - form = StaffAssignmentForm() - + form = StaffAssignmentForm(instance=job) + print(staff_users) context = { "job": job, "applications": applications, @@ -3192,7 +3246,7 @@ def set_meeting_candidate(request, slug): @staff_user_required def agency_list(request): """List all hiring agencies with search and pagination""" - search_query = request.GET.get("q", "") + search_query = request.GET.get("search", "") agencies = HiringAgency.objects.all() if search_query: @@ -4206,6 +4260,8 @@ def agency_portal_submit_candidate_page(request, slug): assignment = get_object_or_404( AgencyJobAssignment.objects.select_related("agency", "job"), slug=slug ) + current_agency=assignment.agency + current_job=assignment.job if assignment.is_full: messages.error(request, "Maximum candidate limit reached for this assignment.") @@ -4230,9 +4286,9 @@ def agency_portal_submit_candidate_page(request, slug): hiring_agency=assignment.agency, job=assignment.job ).count() - form = ApplicationForm() + form = ApplicationForm(current_agency=current_agency,current_job=current_job) if request.method == "POST": - form = ApplicationForm(request.POST, request.FILES) + form = ApplicationForm(request.POST, request.FILES,current_agency=current_agency,current_job=current_job) if form.is_valid(): candidate = form.save(commit=False) @@ -5602,9 +5658,9 @@ def send_interview_email(request, slug): interview = get_object_or_404(ScheduledInterview, slug=slug) # 2. Retrieve the required data for the form's constructor - candidate = interview.candidate + candidate = interview.application job = interview.job - meeting = interview.zoom_meeting + meeting = interview.interview_location participants = list(interview.participants.all()) + list( interview.system_users.all() ) @@ -5625,7 +5681,7 @@ def send_interview_email(request, slug): meeting=meeting, job=job, ) - + if form.is_valid(): # 4. Extract cleaned data subject = form.cleaned_data["subject"] @@ -5635,6 +5691,8 @@ def send_interview_email(request, slug): # --- SEND EMAILS Candidate or agency--- if candidate.belong_to_an_agency: + email=candidate.hiring_agency.email + print(email) send_mail( subject, msg_agency, @@ -5647,7 +5705,7 @@ def send_interview_email(request, slug): subject, msg_candidate, settings.DEFAULT_FROM_EMAIL, - [candidate.email], + [candidate.person.email], fail_silently=False, ) @@ -5659,6 +5717,8 @@ def send_interview_email(request, slug): attachments=None, async_task_=True, # Changed to False to avoid pickle issues, from_interview=True, + job=job + ) if email_result["success"]: @@ -5694,6 +5754,13 @@ def send_interview_email(request, slug): f"Failed to send email: {email_result.get('message', 'Unknown error')}", ) return redirect("list_meetings") + else: + + error_msg = "Failed to send email. Please check the form for errors." + print(form.errors) + messages.error(request, error_msg) + return redirect("meeting_details", slug=meeting.slug) + return redirect("meeting_details", slug=meeting.slug) # def schedule_interview_location_form(request,slug): @@ -6015,13 +6082,13 @@ def meeting_details(request, slug): participant_form = InterviewParticpantsForm(instance=interview) - # email_form = InterviewEmailForm( - # candidate=candidate, - # external_participants=external_participants, # QuerySet of Participants - # system_participants=system_participants, # QuerySet of Users - # meeting=meeting, # ← This is InterviewLocation (e.g., ZoomMeetingDetails) - # job=job, - # ) + email_form = InterviewEmailForm( + candidate=candidate, + external_participants=external_participants, # QuerySet of Participants + system_participants=system_participants, # QuerySet of Users + meeting=meeting, # ← This is InterviewLocation (e.g., ZoomMeetingDetails) + job=job, + ) context = { 'meeting': meeting, @@ -6032,7 +6099,7 @@ def meeting_details(request, slug): 'system_participants': system_participants, 'total_participants': total_participants, 'form': participant_form, - # 'email_form': email_form, + 'email_form': email_form, } return render(request, 'interviews/detail_interview.html', context) diff --git a/recruitment/views_frontend.py b/recruitment/views_frontend.py index 0c97a4d..e9c4fb7 100644 --- a/recruitment/views_frontend.py +++ b/recruitment/views_frontend.py @@ -64,9 +64,9 @@ class JobListView(LoginRequiredMixin, StaffRequiredMixin, ListView): # if not self.request.user.is_staff: # queryset = queryset.filter(status='Published') - status = self.request.GET.get('status') - if status: - queryset = queryset.filter(status=status) + status_filter = self.request.GET.get('status') + if status_filter: + queryset = queryset.filter(status=status_filter) return queryset @@ -74,6 +74,7 @@ class JobListView(LoginRequiredMixin, StaffRequiredMixin, ListView): context = super().get_context_data(**kwargs) context['search_query'] = self.request.GET.get('search', '') context['lang'] = get_language() + context['status_filter']=self.request.GET.get('status') return context @@ -156,15 +157,13 @@ class ApplicationListView(LoginRequiredMixin, StaffRequiredMixin, ListView): 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) | - # Q(last_name__icontains=search_query) | - # Q(email__icontains=search_query) | - # Q(phone__icontains=search_query) | - # Q(stage__icontains=search_query) | - # Q(job__title__icontains=search_query) - # ) + if search_query: + queryset = queryset.filter( + Q(person__first_name__icontains=search_query) | + Q(person__last_name__icontains=search_query) | + Q(person__email__icontains=search_query) | + Q(person__phone__icontains=search_query) + ) if job: queryset = queryset.filter(job__slug=job) if stage: diff --git a/templates/account/email_confirm.html b/templates/account/email_confirm.html index 7489e61..c1eda3b 100644 --- a/templates/account/email_confirm.html +++ b/templates/account/email_confirm.html @@ -1,23 +1,174 @@ -{% extends "base.html" %} -{% load i18n %} +{% load static i18n %} {% load account %} - -{% block title %}{% trans "Confirm Email Address" %}{% endblock %} - -{% block content %} -
+{% get_current_language_bidi as LANGUAGE_BIDI %} +{% get_current_language as LANGUAGE_CODE %} + + + + + + {% block title %}{% trans "Confirm Email Address" %}{% endblock %} - {# Centering the main header content #} -
-
-

{% trans "Account Verification" %}

-

{% trans "Verify your email to secure your account and unlock full features." %}

+ + + + + + + +
+
+

+ +
+
جامعة الأميرة نورة بنت عبدالرحمن الأكاديمية
+
ومستشفى الملك عبدالله بن عبدالعزيز التخصصي
+
Princess Nourah bint Abdulrahman University
+
King Abdullah bin Abdulaziz University Hospital
+
+
+

+ Powered By TENHAL | تنحل
- -
-
-
+ +
+ +
+ + {# Global Header #} +
+

{% trans "Account Verification" %}

+

{% trans "Verify your email to secure your account and unlock full features." %}

+
+ +
{% with email as email %} @@ -27,11 +178,11 @@ {# ------------------- CONFIRMATION REQUEST (GET) - Success Theme ------------------- #} {% user_display confirmation.email_address.user as user_display %} - {# Changed icon to a checkmark for clarity #} + -

{% translate "Confirm Your Email Address" %}

+

{% trans "Confirm Your Email Address" %}

-

+

{% blocktrans with email as email %}Please confirm that **{{ email }}** is the correct email address for your account.{% endblocktrans %}

@@ -39,28 +190,27 @@
{% csrf_token %} -
{% else %} {# ------------------- CONFIRMATION FAILED (Error) - Danger Theme ------------------- #} - {# Changed icon to be more specific #} + -

{% translate "Verification Failed" %}

+

{% trans "Verification Failed" %}

- {% translate "The email confirmation link is expired or invalid." %} + {% trans "The email confirmation link is expired or invalid." %}

-

- {% translate "If you recently requested a link, please ensure you use the newest one. You can request a new verification email from your account settings." %} +

+ {% trans "If you recently requested a link, please ensure you use the newest one. You can request a new verification email from your account settings." %}

- - {% translate "Go to Settings" %} + + {% trans "Go to Settings" %} {% endif %} @@ -68,7 +218,10 @@ {% endwith %}
+
-
-{% endblock content %} \ No newline at end of file + + + + \ No newline at end of file diff --git a/templates/forms/form_submission_details.html b/templates/forms/form_submission_details.html index 013342b..7bd39c5 100644 --- a/templates/forms/form_submission_details.html +++ b/templates/forms/form_submission_details.html @@ -146,7 +146,7 @@