diff --git a/NorahUniversity/settings.py b/NorahUniversity/settings.py index af007de..6a93abb 100644 --- a/NorahUniversity/settings.py +++ b/NorahUniversity/settings.py @@ -62,6 +62,7 @@ INSTALLED_APPS = [ "django_q", "widget_tweaks", "easyaudit", + "secured_fields", ] @@ -538,3 +539,4 @@ LOGGING={ } +SECURED_FIELDS_KEY="kvaCwxrIMtVRouBH5mzf9g-uelv7XUD840ncAiOXkt4=" \ No newline at end of file diff --git a/recruitment/email_service.py b/recruitment/email_service.py index 19d68a5..294c818 100644 --- a/recruitment/email_service.py +++ b/recruitment/email_service.py @@ -237,64 +237,61 @@ def send_interview_invitation_email(candidate, job, meeting_details=None, recipi -def send_bulk_email(subject, message, recipient_list, request=None, attachments=None, async_task_=False, from_interview=False,job=None): +def send_bulk_email(subject, message, recipient_list, request=None, attachments=None, async_task_=False,job=None): """ Send bulk email to multiple recipients with HTML support and attachments, supporting synchronous or asynchronous dispatch. """ # --- 1. Categorization and Custom Message Preparation (CORRECTED) --- - if not from_interview: - agency_emails = [] - pure_candidate_emails = [] - candidate_through_agency_emails = [] + agency_emails = [] + pure_candidate_emails = [] + candidate_through_agency_emails = [] - if not recipient_list: - return {'success': False, 'error': 'No recipients provided'} + if not recipient_list: + return {'success': False, 'error': 'No recipients provided'} - # This must contain (final_recipient_email, customized_message) for ALL sends - customized_sends = [] + # This must contain (final_recipient_email, customized_message) for ALL sends + customized_sends = [] - # 1a. Classify Recipients and Prepare Custom Messages - for email in recipient_list: - email = email.strip().lower() + # 1a. Classify Recipients and Prepare Custom Messages + for email in recipient_list: + email = email.strip().lower() - try: - candidate = get_object_or_404(Application, person__email=email) - except Exception: - logger.warning(f"Candidate not found for email: {email}") - continue + try: + candidate = get_object_or_404(Application, person__email=email) + except Exception: + logger.warning(f"Candidate not found for email: {email}") + continue - candidate_name = candidate.person.full_name + candidate_name = candidate.person.full_name - # --- Candidate belongs to an agency (Final Recipient: Agency) --- - if candidate.hiring_agency and candidate.hiring_agency.email: - agency_email = candidate.hiring_agency.email - agency_message = f"Hi, {candidate_name}" + "\n" + message + # --- Candidate belongs to an agency (Final Recipient: Agency) --- + if candidate.hiring_agency and candidate.hiring_agency.email: + agency_email = candidate.hiring_agency.email + agency_message = f"Hi, {candidate_name}" + "\n" + message - # Add Agency email as the recipient with the custom message - customized_sends.append((agency_email, agency_message)) - agency_emails.append(agency_email) - candidate_through_agency_emails.append(candidate.email) # For sync block only + # Add Agency email as the recipient with the custom message + customized_sends.append((agency_email, agency_message)) + agency_emails.append(agency_email) + candidate_through_agency_emails.append(candidate.email) # For sync block only - # --- Pure Candidate (Final Recipient: Candidate) --- - else: - candidate_message = f"Hi, {candidate_name}" + "\n" + message + # --- Pure Candidate (Final Recipient: Candidate) --- + else: + candidate_message = f"Hi, {candidate_name}" + "\n" + message - # Add Candidate email as the recipient with the custom message - customized_sends.append((email, candidate_message)) - pure_candidate_emails.append(email) # For sync block only + # Add Candidate email as the recipient with the custom message + customized_sends.append((email, candidate_message)) + pure_candidate_emails.append(email) # For sync block only - # Calculate total recipients based on the size of the final send list - total_recipients = len(customized_sends) + # Calculate total recipients based on the size of the final send list + total_recipients = len(customized_sends) + + if total_recipients == 0: + return {'success': False, 'error': 'No valid recipients found for sending.'} - if total_recipients == 0: - return {'success': False, 'error': 'No valid recipients found for sending.'} - else: - # For interview flow - total_recipients = len(recipient_list) # --- 2. Handle ASYNC Dispatch (FIXED: Single loop used) --- @@ -306,49 +303,30 @@ def send_bulk_email(subject, message, recipient_list, request=None, attachments= job_id=job.id sender_user_id=request.user.id if request and hasattr(request, 'user') and request.user.is_authenticated else None - if not from_interview: + # Loop through ALL final customized sends - - task_id = async_task( - 'recruitment.tasks.send_bulk_email_task', - subject, - customized_sends, - processed_attachments, - sender_user_id, - job_id, - hook='recruitment.tasks.email_success_hook', + + task_id = async_task( + 'recruitment.tasks.send_bulk_email_task', + subject, + customized_sends, + processed_attachments, + sender_user_id, + job_id, + hook='recruitment.tasks.email_success_hook', - ) - task_ids.append(task_id) + ) + task_ids.append(task_id) - logger.info(f"{len(task_ids)} tasks ({total_recipients} emails) queued.") + logger.info(f"{len(task_ids)} tasks ({total_recipients} emails) queued.") - return { - 'success': True, - 'async': True, - 'task_ids': task_ids, - 'message': f'Emails queued for background sending to {len(task_ids)} recipient(s).' - } - - else: # from_interview is True (generic send to all participants) - task_id = async_task( - 'recruitment.tasks.send_bulk_email_task', - subject, - message, - recipient_list, # Send the original message to the entire list - processed_attachments, - hook='recruitment.tasks.email_success_hook' - ) - task_ids.append(task_id) - logger.info(f"Interview emails queued. ID: {task_id}") - - return { - 'success': True, - 'async': True, - 'task_ids': task_ids, - 'message': f'Interview emails queued for background sending to {total_recipients} recipient(s)' - } + return { + 'success': True, + 'async': True, + 'task_ids': task_ids, + 'message': f'Emails queued for background sending to {len(task_ids)} recipient(s).' + } except ImportError: @@ -398,38 +376,29 @@ def send_bulk_email(subject, message, recipient_list, request=None, attachments= except Exception as e: logger.error(f"Failed to send email to {recipient}: {str(e)}", exc_info=True) - if not from_interview: - # Send Emails - Pure Candidates - for email in pure_candidate_emails: - candidate_name = Application.objects.filter(email=email).first().first_name - candidate_message = f"Hi, {candidate_name}" + "\n" + message - send_individual_email(email, candidate_message) + + # Send Emails - Pure Candidates + for email in pure_candidate_emails: + candidate_name = Application.objects.filter(email=email).first().first_name + candidate_message = f"Hi, {candidate_name}" + "\n" + message + send_individual_email(email, candidate_message) - # Send Emails - Agencies - i = 0 - for email in agency_emails: - candidate_email = candidate_through_agency_emails[i] - candidate_name = Application.objects.filter(email=candidate_email).first().first_name - agency_message = f"Hi, {candidate_name}" + "\n" + message - send_individual_email(email, agency_message) - i += 1 + # Send Emails - Agencies + i = 0 + for email in agency_emails: + candidate_email = candidate_through_agency_emails[i] + candidate_name = Application.objects.filter(email=candidate_email).first().first_name + agency_message = f"Hi, {candidate_name}" + "\n" + message + send_individual_email(email, agency_message) + i += 1 - logger.info(f"Bulk email processing complete. Sent successfully to {successful_sends} out of {total_recipients} unique recipients.") - return { - 'success': True, - 'recipients_count': successful_sends, - 'message': f'Email processing complete. {successful_sends} email(s) were sent successfully to {total_recipients} unique intended recipients.' - } - else: - for email in recipient_list: - send_individual_email(email, message) - - logger.info(f"Interview email processing complete. Sent successfully to {successful_sends} out of {total_recipients} recipients.") - return { - 'success': True, - 'recipients_count': successful_sends, - 'message': f'Interview emails sent successfully to {successful_sends} recipient(s).' - } + logger.info(f"Bulk email processing complete. Sent successfully to {successful_sends} out of {total_recipients} unique recipients.") + return { + 'success': True, + 'recipients_count': successful_sends, + 'message': f'Email processing complete. {successful_sends} email(s) were sent successfully to {total_recipients} unique intended recipients.' + } + except Exception as e: error_msg = f"Failed to process bulk email send request: {str(e)}" diff --git a/recruitment/forms.py b/recruitment/forms.py index ebca72b..e8696de 100644 --- a/recruitment/forms.py +++ b/recruitment/forms.py @@ -714,7 +714,7 @@ class BulkInterviewTemplateForm(forms.ModelForm): class InterviewCancelForm(forms.ModelForm): class Meta: - model = Interview + model = ScheduledInterview fields = ["cancelled_reason","cancelled_at"] widgets = { "cancelled_reason": forms.Textarea( @@ -2032,3 +2032,76 @@ class SettingsForm(forms.ModelForm): if not value: raise forms.ValidationError("Setting value cannot be empty.") return value + + +class InterviewEmailForm(forms.Form): + """Form for composing emails to participants about a candidate""" + to = forms.CharField( + + label=_('To'), # Use a descriptive label + required=True, + + ) + + subject = forms.CharField( + max_length=200, + widget=forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Enter email subject', + 'required': True + }), + label=_('Subject'), + required=True + ) + + message = forms.CharField( + widget=forms.Textarea(attrs={ + 'class': 'form-control', + 'rows': 8, + 'placeholder': 'Enter your message here...', + 'required': True + }), + label=_('Message'), + required=True + ) + + def __init__(self, job, application,schedule, *args, **kwargs): + applicant=application.person.user + interview=schedule.interview + super().__init__(*args, **kwargs) + if application.hiring_agency: + self.fields['to'].initial=application.hiring_agency.email + self.fields['to'].disabled= True + + + else: + self.fields['to'].initial=application.person.email + self.fields['to'].disabled= True + + + + # Set initial message with candidate and meeting info + initial_message = f""" +Dear {applicant.first_name} {applicant.last_name}, + +Your interview details are as follows: + +Date: {interview.start_time.strftime("%d-%m-%Y")} +Time: {interview.start_time.strftime("%I:%M %p")} +Interview Duration: {interview.duration} minutes +Job: {job.title} + +""" + + if interview.location_type == 'Remote': + initial_message += f"Pease join using meeting link {interview.details_url} .\n\n" + else: + initial_message += "This is an onsite schedule. Please arrive 10 minutes early.\n\n" + + initial_message += """ +Best regards, +KAAUH Hiring Team + """ + + self.fields['message'].initial = initial_message + diff --git a/recruitment/migrations/0001_initial.py b/recruitment/migrations/0001_initial.py index 0798d3d..b6ba948 100644 --- a/recruitment/migrations/0001_initial.py +++ b/recruitment/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 6.0 on 2025-12-10 11:08 +# Generated by Django 5.2.7 on 2025-12-11 11:55 import django.contrib.auth.models import django.contrib.auth.validators @@ -9,6 +9,7 @@ import django_ckeditor_5.fields import django_countries.fields import django_extensions.db.fields import recruitment.validators +import secured_fields.fields from django.conf import settings from django.db import migrations, models @@ -73,8 +74,6 @@ class Migration(migrations.Migration): ('start_time', models.DateTimeField(db_index=True, verbose_name='Start Time')), ('duration', models.PositiveIntegerField(verbose_name='Duration (minutes)')), ('status', models.CharField(choices=[('waiting', 'Waiting'), ('started', 'Started'), ('ended', 'Ended'), ('cancelled', 'Cancelled')], db_index=True, default='waiting', max_length=20)), - ('cancelled_at', models.DateTimeField(blank=True, null=True, verbose_name='Cancelled At')), - ('cancelled_reason', models.TextField(blank=True, null=True, verbose_name='Cancellation Reason')), ('meeting_id', models.CharField(blank=True, max_length=50, null=True, unique=True, verbose_name='External Meeting ID')), ('password', models.CharField(blank=True, max_length=20, null=True)), ('zoom_gateway_response', models.JSONField(blank=True, null=True)), @@ -101,7 +100,7 @@ class Migration(migrations.Migration): ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), ('name', models.CharField(blank=True, max_length=255, null=True, verbose_name='Participant Name')), ('email', models.EmailField(max_length=254, verbose_name='Email')), - ('phone', models.CharField(blank=True, max_length=12, null=True, verbose_name='Phone Number')), + ('phone', secured_fields.fields.EncryptedCharField(blank=True, max_length=12, null=True, verbose_name='Phone Number')), ('designation', models.CharField(blank=True, max_length=100, null=True, verbose_name='Designation')), ], options={ @@ -162,13 +161,13 @@ class Migration(migrations.Migration): ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), - ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('first_name', secured_fields.fields.EncryptedCharField(blank=True, max_length=150, verbose_name='first name')), ('user_type', models.CharField(choices=[('staff', 'Staff'), ('agency', 'Agency'), ('candidate', 'Candidate')], default='staff', max_length=20, verbose_name='User Type')), - ('phone', models.CharField(blank=True, null=True, verbose_name='Phone')), + ('phone', secured_fields.fields.EncryptedCharField(blank=True, null=True, verbose_name='Phone')), ('profile_image', models.ImageField(blank=True, null=True, upload_to='profile_pic/', validators=[recruitment.validators.validate_image_size], verbose_name='Profile Image')), ('designation', models.CharField(blank=True, max_length=100, null=True, verbose_name='Designation')), ('email', models.EmailField(error_messages={'unique': 'A user with this email already exists.'}, max_length=254, unique=True)), @@ -261,7 +260,7 @@ class Migration(migrations.Migration): ('name', models.CharField(max_length=200, unique=True, verbose_name='Agency Name')), ('contact_person', models.CharField(blank=True, max_length=150, verbose_name='Contact Person')), ('email', models.EmailField(max_length=254, unique=True)), - ('phone', models.CharField(blank=True, max_length=20, null=True)), + ('phone', secured_fields.fields.EncryptedCharField(blank=True, max_length=20, null=True)), ('website', models.URLField(blank=True)), ('notes', models.TextField(blank=True, help_text='Internal notes about the agency')), ('country', django_countries.fields.CountryField(blank=True, max_length=2, null=True)), @@ -499,15 +498,15 @@ class Migration(migrations.Migration): ('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')), - ('first_name', models.CharField(max_length=255, verbose_name='First Name')), + ('first_name', secured_fields.fields.EncryptedCharField(max_length=255, verbose_name='First Name')), ('last_name', models.CharField(max_length=255, verbose_name='Last Name')), ('middle_name', models.CharField(blank=True, max_length=255, null=True, verbose_name='Middle Name')), ('email', models.EmailField(db_index=True, max_length=254, unique=True, verbose_name='Email')), - ('phone', models.CharField(blank=True, null=True, verbose_name='Phone')), + ('phone', secured_fields.fields.EncryptedCharField(blank=True, null=True, verbose_name='Phone')), ('date_of_birth', models.DateField(blank=True, null=True, verbose_name='Date of Birth')), ('gender', models.CharField(blank=True, choices=[('M', 'Male'), ('F', 'Female')], max_length=1, null=True, verbose_name='Gender')), ('gpa', models.DecimalField(decimal_places=2, help_text='GPA must be between 0 and 4.', max_digits=3, verbose_name='GPA')), - ('national_id', models.CharField(help_text='Enter the national id or iqama number')), + ('national_id', secured_fields.fields.EncryptedCharField(help_text='Enter the national id or iqama number')), ('nationality', django_countries.fields.CountryField(blank=True, max_length=2, null=True, verbose_name='Nationality')), ('address', models.TextField(blank=True, null=True, verbose_name='Address')), ('profile_image', models.ImageField(blank=True, null=True, upload_to='profile_pic/', validators=[recruitment.validators.validate_image_size], verbose_name='Profile Image')), @@ -532,6 +531,8 @@ class Migration(migrations.Migration): ('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')), + ('cancelled_at', models.DateTimeField(blank=True, null=True, verbose_name='Cancelled At')), + ('cancelled_reason', models.TextField(blank=True, null=True, verbose_name='Cancellation Reason')), ('interview_date', models.DateField(db_index=True, verbose_name='Interview Date')), ('interview_time', models.TimeField(verbose_name='Interview Time')), ('interview_type', models.CharField(choices=[('Remote', 'Remote (e.g., Zoom, Google Meet)'), ('Onsite', 'In-Person (Physical Location)')], default='Remote', max_length=20)), diff --git a/recruitment/models.py b/recruitment/models.py index 4f643c9..648cf13 100644 --- a/recruitment/models.py +++ b/recruitment/models.py @@ -16,6 +16,7 @@ from django.db.models.fields.json import KeyTransform, KeyTextTransform from django_countries.fields import CountryField from django_ckeditor_5.fields import CKEditor5Field from django_extensions.db.fields import RandomCharField +from secured_fields import EncryptedCharField from typing import List, Dict, Any @@ -42,10 +43,12 @@ class CustomUser(AbstractUser): ("candidate", _("Candidate")), ] + first_name=EncryptedCharField(_("first name"), max_length=150, blank=True) + user_type = models.CharField( max_length=20, choices=USER_TYPES, default="staff", verbose_name=_("User Type") ) - phone = models.CharField( + phone = EncryptedCharField( blank=True, null=True, verbose_name=_("Phone") ) profile_image = models.ImageField( @@ -514,7 +517,7 @@ class Person(Base): ] # Personal Information - first_name = models.CharField(max_length=255, verbose_name=_("First Name")) + first_name = EncryptedCharField(max_length=255, verbose_name=_("First Name")) last_name = models.CharField(max_length=255, verbose_name=_("Last Name")) middle_name = models.CharField( max_length=255, blank=True, null=True, verbose_name=_("Middle Name") @@ -524,7 +527,7 @@ class Person(Base): db_index=True, verbose_name=_("Email"), ) - phone = models.CharField( + phone = EncryptedCharField( blank=True, null=True, verbose_name=_("Phone") ) date_of_birth = models.DateField( @@ -540,7 +543,7 @@ class Person(Base): gpa = models.DecimalField( max_digits=3, decimal_places=2, verbose_name=_("GPA"),help_text=_("GPA must be between 0 and 4.") ) - national_id = models.CharField( + national_id = EncryptedCharField( help_text=_("Enter the national id or iqama number") ) nationality = CountryField(blank=True, null=True, verbose_name=_("Nationality")) @@ -1124,9 +1127,7 @@ class Interview(Base): default=Status.WAITING, db_index=True ) - cancelled_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Cancelled At")) - cancelled_reason = models.TextField(blank=True, null=True, verbose_name=_("Cancellation Reason")) - + # Remote-specific (nullable) meeting_id = models.CharField( max_length=50, unique=True, null=True, blank=True, verbose_name=_("External Meeting ID") @@ -1242,6 +1243,10 @@ class ScheduledInterview(Base): CANCELLED = "cancelled", _("Cancelled") COMPLETED = "completed", _("Completed") + cancelled_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Cancelled At")) + cancelled_reason = models.TextField(blank=True, null=True, verbose_name=_("Cancellation Reason")) + + application = models.ForeignKey( Application, on_delete=models.CASCADE, @@ -1254,7 +1259,7 @@ class ScheduledInterview(Base): related_name="scheduled_interviews", db_index=True, ) - + # Links to the specific, individual location/meeting details for THIS interview interview = models.OneToOneField( Interview, @@ -1880,7 +1885,7 @@ class HiringAgency(Base): max_length=150, blank=True, verbose_name=_("Contact Person") ) email = models.EmailField(unique=True) - phone = models.CharField(max_length=20, blank=True,null=True) + phone = EncryptedCharField(max_length=20, blank=True,null=True) website = models.URLField(blank=True) notes = models.TextField(blank=True, help_text=_("Internal notes about the agency")) country = CountryField(blank=True, null=True, blank_label=_("Select country")) @@ -2278,7 +2283,7 @@ class Participants(Base): max_length=255, verbose_name=_("Participant Name"), null=True, blank=True ) email =models.EmailField(verbose_name=_("Email")) - phone = models.CharField( + phone = EncryptedCharField( max_length=12, verbose_name=_("Phone Number"), null=True, blank=True ) designation = models.CharField( diff --git a/recruitment/urls.py b/recruitment/urls.py index 73062c8..1798112 100644 --- a/recruitment/urls.py +++ b/recruitment/urls.py @@ -84,6 +84,7 @@ urlpatterns = [ path("interviews//", views.interview_detail, name="interview_detail"), path("interviews//update_interview_status", views.update_interview_status, name="update_interview_status"), path("interviews//cancel_interview_for_application", views.cancel_interview_for_application, name="cancel_interview_for_application"), + path("interview//interview-email/",views.send_interview_email,name="send_interview_email"), # Interview Creation path("interviews/create//", views.interview_create_type_selection, name="interview_create_type_selection"), @@ -217,7 +218,8 @@ urlpatterns = [ # SYSTEM & ADMINISTRATIVE # ======================================================================== # Settings & Configuration - path("settings/", views.admin_settings, name="admin_settings"), + path("settings/",views.settings,name="settings"), + path("settings/staff", views.admin_settings, name="admin_settings"), path("settings/list/", views.settings_list, name="settings_list"), path("settings/create/", views.settings_create, name="settings_create"), path("settings//", views.settings_detail, name="settings_detail"), diff --git a/recruitment/utils.py b/recruitment/utils.py index 95e9d68..e560a3d 100644 --- a/recruitment/utils.py +++ b/recruitment/utils.py @@ -8,7 +8,7 @@ from django.utils import timezone from .models import ScheduledInterview from django.template.loader import render_to_string from django.core.mail import send_mail - +import random import os import json import logging diff --git a/recruitment/views.py b/recruitment/views.py index 628c137..52f6f26 100644 --- a/recruitment/views.py +++ b/recruitment/views.py @@ -63,6 +63,7 @@ from rest_framework import viewsets from easyaudit.models import CRUDEvent, LoginEvent, RequestEvent from django_q.tasks import async_task + # Local Apps from .decorators import ( agency_user_required, @@ -88,7 +89,9 @@ from .forms import ( OnsiteInterviewForm, BulkInterviewTemplateForm, SettingsForm, - InterviewCancelForm + InterviewCancelForm, + InterviewEmailForm, + ApplicationStageForm ) from .utils import generate_random_password from django.views.decorators.csrf import csrf_exempt @@ -182,7 +185,7 @@ class PersonListView(StaffRequiredMixin, ListView,LoginRequiredMixin): search_query=self.request.GET.get('search','') if search_query: queryset=queryset.filter( - Q(first_name__icontains=search_query) | + Q(first_name=search_query) | Q(last_name__icontains=search_query) | Q(email__icontains=search_query) ) @@ -1016,13 +1019,15 @@ def delete_form_template(request, template_id): ) -@login_required -@staff_or_candidate_required -def application_submit_form(request, template_slug): +# @login_required +# @staff_or_candidate_required +def application_submit_form(request, slug): """Display the form as a step-by-step wizard""" + form_template=get_object_or_404(FormTemplate,slug=slug,is_active=True) if not request.user.is_authenticated: return redirect("application_signup",slug=slug) - job = get_object_or_404(JobPosting, form_template__slug=slug) + print(form_template.job.slug) + job = get_object_or_404(JobPosting, slug=form_template.job.slug) if request.user.user_type == "candidate": person=request.user.person_profile if job.has_already_applied_to_this_job(person): @@ -1834,7 +1839,7 @@ def applications_document_review_view(request, slug): search_query = request.GET.get('q', '') if search_query: applications = applications.filter( - Q(person__first_name__icontains=search_query) | + Q(person__first_name=search_query) | Q(person__last_name__icontains=search_query) | Q(person__email__icontains=search_query) ) @@ -2300,6 +2305,10 @@ def regenerate_agency_password(request, slug): new_password=generate_random_password() agency.generated_password=new_password agency.save() + if agency.user is None: + messages.error(request, _("Error: The user account associated with this agency could not be found.")) + # Redirect the staff user back to the agency detail page or list + return redirect('agency_detail', slug=agency.slug) # Or wherever appropriate user=agency.user user.set_password(new_password) user.save() @@ -2812,10 +2821,10 @@ def agency_portal_persons_list(request): search_query = request.GET.get("q", "") if search_query: persons = persons.filter( - Q(first_name__icontains=search_query) + Q(first_name=search_query) | Q(last_name__icontains=search_query) | Q(email__icontains=search_query) - | Q(phone__icontains=search_query) + | Q(phone=search_query) | Q(job__title__icontains=search_query) ) @@ -3806,18 +3815,13 @@ def cancel_interview_for_application(request, slug): Handles POST request to cancel an interview, setting the status and saving the form data (likely a reason for cancellation). """ - interview = get_object_or_404(Interview, slug=slug) - scheduled_interview = get_object_or_404(ScheduledInterview, interview=interview) - form = InterviewCancelForm(request.POST, instance=interview) - - + scheduled_interview = get_object_or_404(ScheduledInterview) + form = InterviewCancelForm(request.POST, instance=scheduled_interview) if form.is_valid(): - - interview.status = interview.Status.CANCELLED scheduled_interview.status = scheduled_interview.InterviewStatus.CANCELLED scheduled_interview.save(update_fields=['status']) - interview.save(update_fields=['status']) # Saves the new status + scheduled_interview.save(update_fields=['status']) # Saves the new status form.save() # Saves form data @@ -3952,11 +3956,11 @@ def api_application_detail(request, candidate_id): @login_required @staff_user_required -def compose_application_email(request, job_slug): +def compose_application_email(request, slug): """Compose email to participants about a candidate""" from .email_service import send_bulk_email - job = get_object_or_404(JobPosting, slug=job_slug) + job = get_object_or_404(JobPosting, slug=slug) candidate_ids=request.GET.getlist('candidate_ids') candidates=Application.objects.filter(id__in=candidate_ids) @@ -4000,7 +4004,6 @@ def compose_application_email(request, job_slug): request=request, attachments=None, async_task_=True, # Changed to False to avoid pickle issues - from_interview=False, job=job ) @@ -4360,7 +4363,7 @@ def interview_list(request): interviews = interviews.filter(job__slug=job_filter) if search_query: interviews = interviews.filter( - Q(application__person__first_name__icontains=search_query) | + Q(application__person__first_name=search_query) | Q(application__person__last_name__icontains=search_query) | Q(application__person__email=search_query)| Q(job__title__icontains=search_query) @@ -4389,16 +4392,19 @@ def interview_detail(request, slug): schedule = get_object_or_404(ScheduledInterview, slug=slug) interview = schedule.interview - + application=schedule.application + job=schedule.job reschedule_form = ScheduledInterviewForm() - reschedule_form.initial['topic'] = interview.interview.topic - meeting=interview.interview + reschedule_form.initial['topic'] = interview.topic + meeting=interview + interview_email_form=InterviewEmailForm(job,application,schedule) context = { 'schedule': schedule, 'interview': interview, 'reschedule_form':reschedule_form, 'interview_status_form':ScheduledInterviewUpdateStatusForm(), 'cancel_form':InterviewCancelForm(instance=meeting), + 'interview_email_form':interview_email_form } return render(request, 'interviews/interview_detail.html', context) @@ -4582,7 +4588,7 @@ def job_applicants_view(request, slug): # Apply filters if search_query: applications = applications.filter( - Q(person__first_name__icontains=search_query) | + Q(person__first_name=search_query) | Q(person__last_name__icontains=search_query) | Q(person__email__icontains=search_query) | Q(email__icontains=search_query) @@ -4909,10 +4915,10 @@ class JobApplicationListView(LoginRequiredMixin, StaffRequiredMixin, ListView): search_query = self.request.GET.get('search', '') if search_query: queryset = queryset.filter( - Q(first_name__icontains=search_query) | + Q(first_name=search_query) | Q(last_name__icontains=search_query) | Q(email__icontains=search_query) | - Q(phone__icontains=search_query) | + Q(phone=search_query) | Q(stage__icontains=search_query) ) @@ -4944,10 +4950,10 @@ class ApplicationListView(LoginRequiredMixin, StaffRequiredMixin, ListView): stage = self.request.GET.get('stage', '') if search_query: queryset = queryset.filter( - Q(person__first_name__icontains=search_query) | + Q(person__first_name=search_query) | Q(person__last_name__icontains=search_query) | Q(person__email__icontains=search_query) | - Q(person__phone__icontains=search_query) + Q(person__phone=search_query) ) if job: queryset = queryset.filter(job__slug=job) @@ -5331,10 +5337,10 @@ def applications_offer_view(request, slug): search_query = request.GET.get('search', '') if search_query: applications = applications.filter( - Q(first_name__icontains=search_query) | + Q(first_name=search_query) | Q(last_name__icontains=search_query) | Q(email__icontains=search_query) | - Q(phone__icontains=search_query) + Q(phone=search_query) ) applications = applications.order_by('-created_at') @@ -5361,10 +5367,10 @@ def applications_hired_view(request, slug): search_query = request.GET.get('search', '') if search_query: applications = applications.filter( - Q(first_name__icontains=search_query) | + Q(first_name=search_query) | Q(last_name__icontains=search_query) | Q(email__icontains=search_query) | - Q(phone__icontains=search_query) + Q(phone=search_query) ) applications = applications.order_by('-created_at') @@ -5469,10 +5475,10 @@ def export_applications_csv(request, job_slug, stage): search_query = request.GET.get('search', '') if search_query: applications = applications.filter( - Q(first_name__icontains=search_query) | + Q(first_name=search_query) | Q(last_name__icontains=search_query) | Q(email__icontains=search_query) | - Q(phone__icontains=search_query) + Q(phone=search_query) ) applications = applications.order_by('-created_at') @@ -5739,4 +5745,45 @@ def sync_history(request, job_slug=None): 'job': job if job_slug else None, } - return render(request, 'recruitment/sync_history.html', context) \ No newline at end of file + return render(request, 'recruitment/sync_history.html', context) + + +def send_interview_email(request,slug): + schedule=get_object_or_404(ScheduledInterview,slug=slug) + application=schedule.application + job=application.job + form=InterviewEmailForm(job,application,schedule) + if request.method=='POST': + form=InterviewEmailForm(job, application, schedule, request.POST) + if form.is_valid(): + recipient=form.cleaned_data.get('to').strip() + body_message=form.cleaned_data.get('message') + subject=form.cleaned_data.get('subject') + sender=request.user + job=job + try: + email_result = async_task('recruitment.tasks._task_send_individual_email', + subject=subject, + body_message=body_message, + recipient=recipient, + attachments=None, + sender=sender, + job=job + ) + if email_result: + messages.success(request, "Message sent successfully via email!") + else: + + messages.warning(request, f"email failed: {email_result.get('message', 'Unknown error')}") + + except Exception as e: + + messages.warning(request, f"Message saved but email sending failed: {str(e)}") + else: + form=InterviewEmailForm(job,application,schedule) + else: # GET request + form = InterviewEmailForm(job, application, schedule) + + # This is the final return, which handles GET requests and invalid POST requests. + return redirect('interview_detail',slug=schedule.slug) + \ No newline at end of file diff --git a/templates/base.html b/templates/base.html index 93461e1..287daa6 100644 --- a/templates/base.html +++ b/templates/base.html @@ -229,7 +229,7 @@ {% if request.user.is_superuser %}
  • - + {% trans "Settings" %}
  • diff --git a/templates/interviews/interview_detail.html b/templates/interviews/interview_detail.html index 9bcda25..9580c80 100644 --- a/templates/interviews/interview_detail.html +++ b/templates/interviews/interview_detail.html @@ -223,7 +223,7 @@ {% trans "View Job" %} - {% if interview.status != 'cancelled' %} + {% if schedule.status != 'cancelled' %} {% endcomment %} - {% endcomment %} +
    @@ -503,7 +498,7 @@
    - {% if schedule.status != 'CANCELLED' and schedule.status != 'COMPLETED' %} + {% if schedule.status != 'cancelled' and schedule.status != 'completed' %} {% endif %} - + {% if schedule.status == 'cancelled' %} +

    {% trans "This interview has been cancelled" %}

    + {% endif %}
    - {% if schedule.status == 'COMPLETED' %} + {% if schedule.status == 'completed' %}