Merge pull request 'email for interview page:' (#95) from frontend into main
Reviewed-on: #95
This commit is contained in:
commit
aaca342de5
@ -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="
|
||||||
@ -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)}"
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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)),
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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"),
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 %}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user