Merge pull request 'email for interview page:' (#95) from frontend into main

Reviewed-on: #95
This commit is contained in:
ismail 2025-12-11 16:53:10 +03:00
commit aaca342de5
11 changed files with 297 additions and 227 deletions

View File

@ -62,6 +62,7 @@ INSTALLED_APPS = [
"django_q", "django_q",
"widget_tweaks", "widget_tweaks",
"easyaudit", "easyaudit",
"secured_fields",
] ]
@ -538,3 +539,4 @@ LOGGING={
} }
SECURED_FIELDS_KEY="kvaCwxrIMtVRouBH5mzf9g-uelv7XUD840ncAiOXkt4="

View File

@ -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, Send bulk email to multiple recipients with HTML support and attachments,
supporting synchronous or asynchronous dispatch. supporting synchronous or asynchronous dispatch.
""" """
# --- 1. Categorization and Custom Message Preparation (CORRECTED) --- # --- 1. Categorization and Custom Message Preparation (CORRECTED) ---
if not from_interview:
agency_emails = [] agency_emails = []
pure_candidate_emails = [] pure_candidate_emails = []
candidate_through_agency_emails = [] candidate_through_agency_emails = []
if not recipient_list: if not recipient_list:
return {'success': False, 'error': 'No recipients provided'} return {'success': False, 'error': 'No recipients provided'}
# This must contain (final_recipient_email, customized_message) for ALL sends # This must contain (final_recipient_email, customized_message) for ALL sends
customized_sends = [] customized_sends = []
# 1a. Classify Recipients and Prepare Custom Messages # 1a. Classify Recipients and Prepare Custom Messages
for email in recipient_list: for email in recipient_list:
email = email.strip().lower() email = email.strip().lower()
try: try:
candidate = get_object_or_404(Application, person__email=email) candidate = get_object_or_404(Application, person__email=email)
except Exception: except Exception:
logger.warning(f"Candidate not found for email: {email}") logger.warning(f"Candidate not found for email: {email}")
continue continue
candidate_name = candidate.person.full_name candidate_name = candidate.person.full_name
# --- Candidate belongs to an agency (Final Recipient: Agency) --- # --- Candidate belongs to an agency (Final Recipient: Agency) ---
if candidate.hiring_agency and candidate.hiring_agency.email: if candidate.hiring_agency and candidate.hiring_agency.email:
agency_email = candidate.hiring_agency.email agency_email = candidate.hiring_agency.email
agency_message = f"Hi, {candidate_name}" + "\n" + message agency_message = f"Hi, {candidate_name}" + "\n" + message
# Add Agency email as the recipient with the custom message # Add Agency email as the recipient with the custom message
customized_sends.append((agency_email, agency_message)) customized_sends.append((agency_email, agency_message))
agency_emails.append(agency_email) agency_emails.append(agency_email)
candidate_through_agency_emails.append(candidate.email) # For sync block only candidate_through_agency_emails.append(candidate.email) # For sync block only
# --- Pure Candidate (Final Recipient: Candidate) --- # --- Pure Candidate (Final Recipient: Candidate) ---
else: else:
candidate_message = f"Hi, {candidate_name}" + "\n" + message candidate_message = f"Hi, {candidate_name}" + "\n" + message
# Add Candidate email as the recipient with the custom message # Add Candidate email as the recipient with the custom message
customized_sends.append((email, candidate_message)) customized_sends.append((email, candidate_message))
pure_candidate_emails.append(email) # For sync block only pure_candidate_emails.append(email) # For sync block only
# Calculate total recipients based on the size of the final send list # Calculate total recipients based on the size of the final send list
total_recipients = len(customized_sends) 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) --- # --- 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 job_id=job.id
sender_user_id=request.user.id if request and hasattr(request, 'user') and request.user.is_authenticated else None 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 # Loop through ALL final customized sends
task_id = async_task( task_id = async_task(
'recruitment.tasks.send_bulk_email_task', 'recruitment.tasks.send_bulk_email_task',
subject, subject,
customized_sends, customized_sends,
processed_attachments, processed_attachments,
sender_user_id, sender_user_id,
job_id, job_id,
hook='recruitment.tasks.email_success_hook', 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 { return {
'success': True, 'success': True,
'async': True, 'async': True,
'task_ids': task_ids, 'task_ids': task_ids,
'message': f'Emails queued for background sending to {len(task_ids)} recipient(s).' '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)'
}
except ImportError: except ImportError:
@ -398,38 +376,29 @@ def send_bulk_email(subject, message, recipient_list, request=None, attachments=
except Exception as e: except Exception as e:
logger.error(f"Failed to send email to {recipient}: {str(e)}", exc_info=True) 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 - Agencies # Send Emails - Pure Candidates
i = 0 for email in pure_candidate_emails:
for email in agency_emails: candidate_name = Application.objects.filter(email=email).first().first_name
candidate_email = candidate_through_agency_emails[i] candidate_message = f"Hi, {candidate_name}" + "\n" + message
candidate_name = Application.objects.filter(email=candidate_email).first().first_name send_individual_email(email, candidate_message)
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.") # Send Emails - Agencies
return { i = 0
'success': True, for email in agency_emails:
'recipients_count': successful_sends, candidate_email = candidate_through_agency_emails[i]
'message': f'Email processing complete. {successful_sends} email(s) were sent successfully to {total_recipients} unique intended recipients.' candidate_name = Application.objects.filter(email=candidate_email).first().first_name
} agency_message = f"Hi, {candidate_name}" + "\n" + message
else: send_individual_email(email, agency_message)
for email in recipient_list: i += 1
send_individual_email(email, message)
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.'
}
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).'
}
except Exception as e: except Exception as e:
error_msg = f"Failed to process bulk email send request: {str(e)}" error_msg = f"Failed to process bulk email send request: {str(e)}"

View File

@ -714,7 +714,7 @@ class BulkInterviewTemplateForm(forms.ModelForm):
class InterviewCancelForm(forms.ModelForm): class InterviewCancelForm(forms.ModelForm):
class Meta: class Meta:
model = Interview model = ScheduledInterview
fields = ["cancelled_reason","cancelled_at"] fields = ["cancelled_reason","cancelled_at"]
widgets = { widgets = {
"cancelled_reason": forms.Textarea( "cancelled_reason": forms.Textarea(
@ -2032,3 +2032,76 @@ class SettingsForm(forms.ModelForm):
if not value: if not value:
raise forms.ValidationError("Setting value cannot be empty.") raise forms.ValidationError("Setting value cannot be empty.")
return value 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

View File

@ -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.models
import django.contrib.auth.validators import django.contrib.auth.validators
@ -9,6 +9,7 @@ import django_ckeditor_5.fields
import django_countries.fields import django_countries.fields
import django_extensions.db.fields import django_extensions.db.fields
import recruitment.validators import recruitment.validators
import secured_fields.fields
from django.conf import settings from django.conf import settings
from django.db import migrations, models 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')), ('start_time', models.DateTimeField(db_index=True, verbose_name='Start Time')),
('duration', models.PositiveIntegerField(verbose_name='Duration (minutes)')), ('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)), ('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')), ('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)), ('password', models.CharField(blank=True, max_length=20, null=True)),
('zoom_gateway_response', models.JSONField(blank=True, 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')), ('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')), ('name', models.CharField(blank=True, max_length=255, null=True, verbose_name='Participant Name')),
('email', models.EmailField(max_length=254, verbose_name='Email')), ('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')), ('designation', models.CharField(blank=True, max_length=100, null=True, verbose_name='Designation')),
], ],
options={ options={
@ -162,13 +161,13 @@ class Migration(migrations.Migration):
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), ('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')), ('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')), ('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')), ('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_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')), ('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')), ('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')), ('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')), ('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')), ('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)), ('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')), ('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')), ('contact_person', models.CharField(blank=True, max_length=150, verbose_name='Contact Person')),
('email', models.EmailField(max_length=254, unique=True)), ('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)), ('website', models.URLField(blank=True)),
('notes', models.TextField(blank=True, help_text='Internal notes about the agency')), ('notes', models.TextField(blank=True, help_text='Internal notes about the agency')),
('country', django_countries.fields.CountryField(blank=True, max_length=2, null=True)), ('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')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated 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')), ('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')), ('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')), ('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')), ('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')), ('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')), ('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')), ('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')), ('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')), ('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')), ('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')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated 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')), ('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_date', models.DateField(db_index=True, verbose_name='Interview Date')),
('interview_time', models.TimeField(verbose_name='Interview Time')), ('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)), ('interview_type', models.CharField(choices=[('Remote', 'Remote (e.g., Zoom, Google Meet)'), ('Onsite', 'In-Person (Physical Location)')], default='Remote', max_length=20)),

View File

@ -16,6 +16,7 @@ from django.db.models.fields.json import KeyTransform, KeyTextTransform
from django_countries.fields import CountryField from django_countries.fields import CountryField
from django_ckeditor_5.fields import CKEditor5Field from django_ckeditor_5.fields import CKEditor5Field
from django_extensions.db.fields import RandomCharField from django_extensions.db.fields import RandomCharField
from secured_fields import EncryptedCharField
from typing import List, Dict, Any from typing import List, Dict, Any
@ -42,10 +43,12 @@ class CustomUser(AbstractUser):
("candidate", _("Candidate")), ("candidate", _("Candidate")),
] ]
first_name=EncryptedCharField(_("first name"), max_length=150, blank=True)
user_type = models.CharField( user_type = models.CharField(
max_length=20, choices=USER_TYPES, default="staff", verbose_name=_("User Type") max_length=20, choices=USER_TYPES, default="staff", verbose_name=_("User Type")
) )
phone = models.CharField( phone = EncryptedCharField(
blank=True, null=True, verbose_name=_("Phone") blank=True, null=True, verbose_name=_("Phone")
) )
profile_image = models.ImageField( profile_image = models.ImageField(
@ -514,7 +517,7 @@ class Person(Base):
] ]
# Personal Information # 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")) last_name = models.CharField(max_length=255, verbose_name=_("Last Name"))
middle_name = models.CharField( middle_name = models.CharField(
max_length=255, blank=True, null=True, verbose_name=_("Middle Name") max_length=255, blank=True, null=True, verbose_name=_("Middle Name")
@ -524,7 +527,7 @@ class Person(Base):
db_index=True, db_index=True,
verbose_name=_("Email"), verbose_name=_("Email"),
) )
phone = models.CharField( phone = EncryptedCharField(
blank=True, null=True, verbose_name=_("Phone") blank=True, null=True, verbose_name=_("Phone")
) )
date_of_birth = models.DateField( date_of_birth = models.DateField(
@ -540,7 +543,7 @@ class Person(Base):
gpa = models.DecimalField( gpa = models.DecimalField(
max_digits=3, decimal_places=2, verbose_name=_("GPA"),help_text=_("GPA must be between 0 and 4.") 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") help_text=_("Enter the national id or iqama number")
) )
nationality = CountryField(blank=True, null=True, verbose_name=_("Nationality")) nationality = CountryField(blank=True, null=True, verbose_name=_("Nationality"))
@ -1124,8 +1127,6 @@ class Interview(Base):
default=Status.WAITING, default=Status.WAITING,
db_index=True 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) # Remote-specific (nullable)
meeting_id = models.CharField( meeting_id = models.CharField(
@ -1242,6 +1243,10 @@ class ScheduledInterview(Base):
CANCELLED = "cancelled", _("Cancelled") CANCELLED = "cancelled", _("Cancelled")
COMPLETED = "completed", _("Completed") 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 = models.ForeignKey(
Application, Application,
on_delete=models.CASCADE, on_delete=models.CASCADE,
@ -1880,7 +1885,7 @@ class HiringAgency(Base):
max_length=150, blank=True, verbose_name=_("Contact Person") max_length=150, blank=True, verbose_name=_("Contact Person")
) )
email = models.EmailField(unique=True) 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) website = models.URLField(blank=True)
notes = models.TextField(blank=True, help_text=_("Internal notes about the agency")) notes = models.TextField(blank=True, help_text=_("Internal notes about the agency"))
country = CountryField(blank=True, null=True, blank_label=_("Select country")) 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 max_length=255, verbose_name=_("Participant Name"), null=True, blank=True
) )
email =models.EmailField(verbose_name=_("Email")) email =models.EmailField(verbose_name=_("Email"))
phone = models.CharField( phone = EncryptedCharField(
max_length=12, verbose_name=_("Phone Number"), null=True, blank=True max_length=12, verbose_name=_("Phone Number"), null=True, blank=True
) )
designation = models.CharField( designation = models.CharField(

View File

@ -84,6 +84,7 @@ urlpatterns = [
path("interviews/<slug:slug>/", views.interview_detail, name="interview_detail"), path("interviews/<slug:slug>/", views.interview_detail, name="interview_detail"),
path("interviews/<slug:slug>/update_interview_status", views.update_interview_status, name="update_interview_status"), path("interviews/<slug:slug>/update_interview_status", views.update_interview_status, name="update_interview_status"),
path("interviews/<slug:slug>/cancel_interview_for_application", views.cancel_interview_for_application, name="cancel_interview_for_application"), path("interviews/<slug:slug>/cancel_interview_for_application", views.cancel_interview_for_application, name="cancel_interview_for_application"),
path("interview/<slug:slug>/interview-email/",views.send_interview_email,name="send_interview_email"),
# Interview Creation # Interview Creation
path("interviews/create/<slug:application_slug>/", views.interview_create_type_selection, name="interview_create_type_selection"), path("interviews/create/<slug:application_slug>/", views.interview_create_type_selection, name="interview_create_type_selection"),
@ -217,7 +218,8 @@ urlpatterns = [
# SYSTEM & ADMINISTRATIVE # SYSTEM & ADMINISTRATIVE
# ======================================================================== # ========================================================================
# Settings & Configuration # 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/list/", views.settings_list, name="settings_list"),
path("settings/create/", views.settings_create, name="settings_create"), path("settings/create/", views.settings_create, name="settings_create"),
path("settings/<int:pk>/", views.settings_detail, name="settings_detail"), path("settings/<int:pk>/", views.settings_detail, name="settings_detail"),

View File

@ -8,7 +8,7 @@ from django.utils import timezone
from .models import ScheduledInterview from .models import ScheduledInterview
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.core.mail import send_mail from django.core.mail import send_mail
import random
import os import os
import json import json
import logging import logging

View File

@ -63,6 +63,7 @@ from rest_framework import viewsets
from easyaudit.models import CRUDEvent, LoginEvent, RequestEvent from easyaudit.models import CRUDEvent, LoginEvent, RequestEvent
from django_q.tasks import async_task from django_q.tasks import async_task
# Local Apps # Local Apps
from .decorators import ( from .decorators import (
agency_user_required, agency_user_required,
@ -88,7 +89,9 @@ from .forms import (
OnsiteInterviewForm, OnsiteInterviewForm,
BulkInterviewTemplateForm, BulkInterviewTemplateForm,
SettingsForm, SettingsForm,
InterviewCancelForm InterviewCancelForm,
InterviewEmailForm,
ApplicationStageForm
) )
from .utils import generate_random_password from .utils import generate_random_password
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
@ -182,7 +185,7 @@ class PersonListView(StaffRequiredMixin, ListView,LoginRequiredMixin):
search_query=self.request.GET.get('search','') search_query=self.request.GET.get('search','')
if search_query: if search_query:
queryset=queryset.filter( queryset=queryset.filter(
Q(first_name__icontains=search_query) | Q(first_name=search_query) |
Q(last_name__icontains=search_query) | Q(last_name__icontains=search_query) |
Q(email__icontains=search_query) Q(email__icontains=search_query)
) )
@ -1016,13 +1019,15 @@ def delete_form_template(request, template_id):
) )
@login_required # @login_required
@staff_or_candidate_required # @staff_or_candidate_required
def application_submit_form(request, template_slug): def application_submit_form(request, slug):
"""Display the form as a step-by-step wizard""" """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: if not request.user.is_authenticated:
return redirect("application_signup",slug=slug) 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": if request.user.user_type == "candidate":
person=request.user.person_profile person=request.user.person_profile
if job.has_already_applied_to_this_job(person): 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', '') search_query = request.GET.get('q', '')
if search_query: if search_query:
applications = applications.filter( 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__last_name__icontains=search_query) |
Q(person__email__icontains=search_query) Q(person__email__icontains=search_query)
) )
@ -2300,6 +2305,10 @@ def regenerate_agency_password(request, slug):
new_password=generate_random_password() new_password=generate_random_password()
agency.generated_password=new_password agency.generated_password=new_password
agency.save() 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=agency.user
user.set_password(new_password) user.set_password(new_password)
user.save() user.save()
@ -2812,10 +2821,10 @@ def agency_portal_persons_list(request):
search_query = request.GET.get("q", "") search_query = request.GET.get("q", "")
if search_query: if search_query:
persons = persons.filter( persons = persons.filter(
Q(first_name__icontains=search_query) Q(first_name=search_query)
| Q(last_name__icontains=search_query) | Q(last_name__icontains=search_query)
| Q(email__icontains=search_query) | Q(email__icontains=search_query)
| Q(phone__icontains=search_query) | Q(phone=search_query)
| Q(job__title__icontains=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 Handles POST request to cancel an interview, setting the status
and saving the form data (likely a reason for cancellation). 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)
scheduled_interview = get_object_or_404(ScheduledInterview, interview=interview) form = InterviewCancelForm(request.POST, instance=scheduled_interview)
form = InterviewCancelForm(request.POST, instance=interview)
if form.is_valid(): if form.is_valid():
interview.status = interview.Status.CANCELLED
scheduled_interview.status = scheduled_interview.InterviewStatus.CANCELLED scheduled_interview.status = scheduled_interview.InterviewStatus.CANCELLED
scheduled_interview.save(update_fields=['status']) 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 form.save() # Saves form data
@ -3952,11 +3956,11 @@ def api_application_detail(request, candidate_id):
@login_required @login_required
@staff_user_required @staff_user_required
def compose_application_email(request, job_slug): def compose_application_email(request, slug):
"""Compose email to participants about a candidate""" """Compose email to participants about a candidate"""
from .email_service import send_bulk_email 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') candidate_ids=request.GET.getlist('candidate_ids')
candidates=Application.objects.filter(id__in=candidate_ids) candidates=Application.objects.filter(id__in=candidate_ids)
@ -4000,7 +4004,6 @@ def compose_application_email(request, job_slug):
request=request, request=request,
attachments=None, attachments=None,
async_task_=True, # Changed to False to avoid pickle issues async_task_=True, # Changed to False to avoid pickle issues
from_interview=False,
job=job job=job
) )
@ -4360,7 +4363,7 @@ def interview_list(request):
interviews = interviews.filter(job__slug=job_filter) interviews = interviews.filter(job__slug=job_filter)
if search_query: if search_query:
interviews = interviews.filter( 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__last_name__icontains=search_query) |
Q(application__person__email=search_query)| Q(application__person__email=search_query)|
Q(job__title__icontains=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) schedule = get_object_or_404(ScheduledInterview, slug=slug)
interview = schedule.interview interview = schedule.interview
application=schedule.application
job=schedule.job
reschedule_form = ScheduledInterviewForm() reschedule_form = ScheduledInterviewForm()
reschedule_form.initial['topic'] = interview.interview.topic reschedule_form.initial['topic'] = interview.topic
meeting=interview.interview meeting=interview
interview_email_form=InterviewEmailForm(job,application,schedule)
context = { context = {
'schedule': schedule, 'schedule': schedule,
'interview': interview, 'interview': interview,
'reschedule_form':reschedule_form, 'reschedule_form':reschedule_form,
'interview_status_form':ScheduledInterviewUpdateStatusForm(), 'interview_status_form':ScheduledInterviewUpdateStatusForm(),
'cancel_form':InterviewCancelForm(instance=meeting), 'cancel_form':InterviewCancelForm(instance=meeting),
'interview_email_form':interview_email_form
} }
return render(request, 'interviews/interview_detail.html', context) return render(request, 'interviews/interview_detail.html', context)
@ -4582,7 +4588,7 @@ def job_applicants_view(request, slug):
# Apply filters # Apply filters
if search_query: if search_query:
applications = applications.filter( 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__last_name__icontains=search_query) |
Q(person__email__icontains=search_query) | Q(person__email__icontains=search_query) |
Q(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', '') search_query = self.request.GET.get('search', '')
if search_query: if search_query:
queryset = queryset.filter( queryset = queryset.filter(
Q(first_name__icontains=search_query) | Q(first_name=search_query) |
Q(last_name__icontains=search_query) | Q(last_name__icontains=search_query) |
Q(email__icontains=search_query) | Q(email__icontains=search_query) |
Q(phone__icontains=search_query) | Q(phone=search_query) |
Q(stage__icontains=search_query) Q(stage__icontains=search_query)
) )
@ -4944,10 +4950,10 @@ class ApplicationListView(LoginRequiredMixin, StaffRequiredMixin, ListView):
stage = self.request.GET.get('stage', '') stage = self.request.GET.get('stage', '')
if search_query: if search_query:
queryset = queryset.filter( 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__last_name__icontains=search_query) |
Q(person__email__icontains=search_query) | Q(person__email__icontains=search_query) |
Q(person__phone__icontains=search_query) Q(person__phone=search_query)
) )
if job: if job:
queryset = queryset.filter(job__slug=job) queryset = queryset.filter(job__slug=job)
@ -5331,10 +5337,10 @@ def applications_offer_view(request, slug):
search_query = request.GET.get('search', '') search_query = request.GET.get('search', '')
if search_query: if search_query:
applications = applications.filter( applications = applications.filter(
Q(first_name__icontains=search_query) | Q(first_name=search_query) |
Q(last_name__icontains=search_query) | Q(last_name__icontains=search_query) |
Q(email__icontains=search_query) | Q(email__icontains=search_query) |
Q(phone__icontains=search_query) Q(phone=search_query)
) )
applications = applications.order_by('-created_at') applications = applications.order_by('-created_at')
@ -5361,10 +5367,10 @@ def applications_hired_view(request, slug):
search_query = request.GET.get('search', '') search_query = request.GET.get('search', '')
if search_query: if search_query:
applications = applications.filter( applications = applications.filter(
Q(first_name__icontains=search_query) | Q(first_name=search_query) |
Q(last_name__icontains=search_query) | Q(last_name__icontains=search_query) |
Q(email__icontains=search_query) | Q(email__icontains=search_query) |
Q(phone__icontains=search_query) Q(phone=search_query)
) )
applications = applications.order_by('-created_at') applications = applications.order_by('-created_at')
@ -5469,10 +5475,10 @@ def export_applications_csv(request, job_slug, stage):
search_query = request.GET.get('search', '') search_query = request.GET.get('search', '')
if search_query: if search_query:
applications = applications.filter( applications = applications.filter(
Q(first_name__icontains=search_query) | Q(first_name=search_query) |
Q(last_name__icontains=search_query) | Q(last_name__icontains=search_query) |
Q(email__icontains=search_query) | Q(email__icontains=search_query) |
Q(phone__icontains=search_query) Q(phone=search_query)
) )
applications = applications.order_by('-created_at') applications = applications.order_by('-created_at')
@ -5740,3 +5746,44 @@ def sync_history(request, job_slug=None):
} }
return render(request, 'recruitment/sync_history.html', context) 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)

View File

@ -229,7 +229,7 @@
</li> </li>
{% if request.user.is_superuser %} {% if request.user.is_superuser %}
<li> <li>
<a class="dropdown-item py-2 px-4 d-flex align-items-center text-decoration-none text-teal" href="{% url 'admin_settings' %}"> <a class="dropdown-item py-2 px-4 d-flex align-items-center text-decoration-none text-teal" href="{% url 'settings' %}">
<i class="fas fa-cog me-3 fs-5"></i> <span>{% trans "Settings" %}</span> <i class="fas fa-cog me-3 fs-5"></i> <span>{% trans "Settings" %}</span>
</a> </a>
</li> </li>

View File

@ -223,7 +223,7 @@
<a href="{% url 'job_detail' schedule.job.slug %}" class="btn btn-outline-secondary"> <a href="{% url 'job_detail' schedule.job.slug %}" class="btn btn-outline-secondary">
<i class="fas fa-briefcase me-1"></i> {% trans "View Job" %} <i class="fas fa-briefcase me-1"></i> {% trans "View Job" %}
</a> </a>
{% if interview.status != 'cancelled' %} {% if schedule.status != 'cancelled' %}
<button type="button" class="btn btn-outline-secondary" <button type="button" class="btn btn-outline-secondary"
data-bs-toggle="modal" data-bs-toggle="modal"
data-bs-target="#statusModal"> data-bs-target="#statusModal">
@ -292,21 +292,15 @@
<i class="fas fa-calendar-check me-2"></i> {% trans "Interview Details" %} <i class="fas fa-calendar-check me-2"></i> {% trans "Interview Details" %}
</h5> </h5>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<span class="badge interview-type-badge
{% if interview.location_type == 'Remote' %}bg-remote <span class="bg-primary-theme badge status-badge text-white">
{% else %}bg-onsite {{interview.location_type}}
{% endif %}">
{% if interview.location_type == 'Remote' %}
<i class="fas fa-video me-1"></i> {% trans "Remote" %}
{% else %}
<i class="fas fa-building me-1"></i> {% trans "Onsite" %}
{% endif %}
</span> </span>
<span class="badge status-badge <span class="badge status-badge
{% if schedule.status == 'SCHEDULED' %}bg-scheduled {% if schedule.status == 'scheduled' %}bg-scheduled
{% elif schedule.status == 'CONFIRMED' %}bg-confirmed {% elif schedule.status == 'confirmed' %}bg-confirmed
{% elif schedule.status == 'CANCELLED' %}bg-cancelled {% elif schedule.status == 'cancelled' %}bg-cancelled
{% elif schedule.status == 'COMPLETED' %}bg-completed {% elif schedule.status == 'completed' %}bg-completed
{% endif %}"> {% endif %}">
{{ schedule.status }} {{ schedule.status }}
</span> </span>
@ -400,7 +394,8 @@
</div> </div>
</div> </div>
</div> </div>
{% if interview.status == 'CONFIRMED' %}
{% if schedule.status == 'confirmed' %}
<div class="timeline-item"> <div class="timeline-item">
<div class="timeline-content"> <div class="timeline-content">
<div class="d-flex justify-content-between align-items-start"> <div class="d-flex justify-content-between align-items-start">
@ -408,12 +403,12 @@
<h6 class="mb-1">{% trans "Interview Confirmed" %}</h6> <h6 class="mb-1">{% trans "Interview Confirmed" %}</h6>
<p class="mb-0 text-muted">{% trans "Candidate has confirmed attendance" %}</p> <p class="mb-0 text-muted">{% trans "Candidate has confirmed attendance" %}</p>
</div> </div>
<small class="text-muted">{% trans "Recently" %}</small>
</div> </div>
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% if interview.status == 'COMPLETED' %} {% if schedule.status == 'completed' %}
<div class="timeline-item"> <div class="timeline-item">
<div class="timeline-content"> <div class="timeline-content">
<div class="d-flex justify-content-between align-items-start"> <div class="d-flex justify-content-between align-items-start">
@ -421,20 +416,20 @@
<h6 class="mb-1">{% trans "Interview Completed" %}</h6> <h6 class="mb-1">{% trans "Interview Completed" %}</h6>
<p class="mb-0 text-muted">{% trans "Interview has been completed" %}</p> <p class="mb-0 text-muted">{% trans "Interview has been completed" %}</p>
</div> </div>
<small class="text-muted">{% trans "Recently" %}</small>
</div> </div>
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% if interview.status == 'CANCELLED' %} {% if schedule.status == 'cancelled' %}
<div class="timeline-item"> <div class="timeline-item">
<div class="timeline-content"> <div class="timeline-content">
<div class="d-flex justify-content-between align-items-start"> <div class="d-flex justify-content-between align-items-start">
<div> <div>
<h6 class="mb-1">{% trans "Interview Cancelled" %}</h6> <h6 class="mb-1">{% trans "Interview Cancelled" %}</h6>
<p class="mb-0 text-muted">{% trans "Interview was cancelled on: " %}{{interview.interview.cancelled_at|date:"F j, Y"}}</p> <p class="mb-0 text-muted">{% trans "Interview was cancelled on: " %}{{ schedule.cancelled_at|date:"d-m-Y" }} {{ schedule.cancelled_at|date:"h:i A" }}</p>
</div> </div>
<small class="text-muted">{% trans "Recently" %}</small>
</div> </div>
</div> </div>
</div> </div>
@ -495,7 +490,7 @@
<i class="fas fa-user-plus me-1"></i> {% trans "Add Participants" %} <i class="fas fa-user-plus me-1"></i> {% trans "Add Participants" %}
</button> </button>
</div> {% endcomment %} </div> {% endcomment %}
</div> {% endcomment %}
<div class="kaauh-card shadow-sm p-4"> <div class="kaauh-card shadow-sm p-4">
<h5 class="mb-3" style="color: var(--kaauh-teal-dark); font-weight: 600;"> <h5 class="mb-3" style="color: var(--kaauh-teal-dark); font-weight: 600;">
@ -503,7 +498,7 @@
</h5> </h5>
<div class="action-buttons"> <div class="action-buttons">
{% if schedule.status != 'CANCELLED' and schedule.status != 'COMPLETED' %} {% if schedule.status != 'cancelled' and schedule.status != 'completed' %}
<button type="button" class="btn btn-main-action btn-sm" <button type="button" class="btn btn-main-action btn-sm"
data-bs-toggle="modal" data-bs-toggle="modal"
data-bs-target="#rescheduleModal"> data-bs-target="#rescheduleModal">
@ -517,7 +512,9 @@
</button> </button>
{% endif %} {% endif %}
{% if schedule.status == 'cancelled' %}
<p class="text-danger">{% trans "This interview has been cancelled" %}</p>
{% endif %}
<hr class="w-100 mt-2 mb-2"> <hr class="w-100 mt-2 mb-2">
<button type="button" class="btn btn-outline-primary btn-sm w-100" <button type="button" class="btn btn-outline-primary btn-sm w-100"
data-bs-toggle="modal" data-bs-toggle="modal"
@ -525,7 +522,7 @@
<i class="fas fa-envelope me-1"></i> {% trans "Send Email" %} <i class="fas fa-envelope me-1"></i> {% trans "Send Email" %}
</button> </button>
{% if schedule.status == 'COMPLETED' %} {% if schedule.status == 'completed' %}
<button type="button" class="btn btn-outline-success btn-sm" <button type="button" class="btn btn-outline-success btn-sm"
data-bs-toggle="modal" data-bs-toggle="modal"
data-bs-target="#resultModal"> data-bs-target="#resultModal">
@ -598,38 +595,9 @@
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form method="post" action="#"> <form method="post" action="{% url 'send_interview_email' schedule.slug %}">
{% csrf_token %} {% csrf_token %}
<div class="mb-3"> {{interview_email_form|crispy}}
<label for="email_to" class="form-label">{% trans "To" %}</label>
<input type="email" class="form-control" id="email_to" value="{{ schedule.application.email }}" readonly>
</div>
<div class="mb-3">
<label for="email_subject" class="form-label">{% trans "Subject" %}</label>
<input type="text" class="form-control" id="email_subject" name="subject"
value="{% trans 'Interview Details' %} - {{ schedule.job.title }}">
</div>
<div class="mb-3">
<label for="email_message" class="form-label">{% trans "Message" %}</label>
<textarea class="form-control" id="email_message" name="message" rows="6">
{% trans "Dear" %} {{ schedule.application.name }},
{% trans "Your interview details are as follows:" %}
{% trans "Date:" %} {{ schedule.interview_date|date:"d-m-Y" }}
{% trans "Time:" %} {{ schedule.interview_time|date:"h:i A" }}
{% trans "Job:" %} {{ schedule.job.title }}
{% if interview.location_type == 'Remote' %}
{% trans "This is a remote schedule. You will receive the meeting link separately." %}
{% else %}
{% trans "This is an onsite schedule. Please arrive 10 minutes early." %}
{% endif %}
{% trans "Best regards," %}
{% trans "HR Team" %}
</textarea>
</div>
<button type="submit" class="btn btn-main-action btn-sm"> <button type="submit" class="btn btn-main-action btn-sm">
<i class="fas fa-paper-plane me-1"></i> {% trans "Send Email" %} <i class="fas fa-paper-plane me-1"></i> {% trans "Send Email" %}
</button> </button>

View File

@ -93,7 +93,7 @@
<div class="row my-4 mx-4"> <div class="row my-4 mx-4">
<div class="col-md-4 mb-4"> <div class="col-md-4 mb-4">
<a href="#integration-settings-page" class="text-decoration-none"> <a href="{% url 'settings_list' %}" class="text-decoration-none">
<div class="kaauh-card shadow-sm p-4 h-100" style="border-left: 5px solid var(--kaauh-teal);"> <div class="kaauh-card shadow-sm p-4 h-100" style="border-left: 5px solid var(--kaauh-teal);">
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<i class="fas fa-plug fa-3x text-primary-theme me-4"></i> <i class="fas fa-plug fa-3x text-primary-theme me-4"></i>
@ -200,9 +200,12 @@
<div class="text-end mt-3"> <div class="text-end mt-3">
{% if not request.session.linkedin_authenticated %} {% if not request.session.linkedin_authenticated %}
<a class="dropdown-item py-2 px-4 d-flex align-items-center text-decoration-none text-teal" href="{% url 'linkedin_login' %}"> <a class="text-decoration-none text-teal" href="{% url 'linkedin_login' %}">
<i class="fab fa-linkedin me-3 text-primary fs-5"></i>
<span>{% trans "Connect to LinkedIn" %}</span> <button class="btn btn-sm btn-outline-secondary">
{% trans "Sign to linkedin" %}<i class="fas fa-arrow-right ms-1"></i>
</button>
</a> </a>
{% else %} {% else %}