diff --git a/NorahUniversity/settings.py b/NorahUniversity/settings.py index af007de..6a93abb 100644 --- a/NorahUniversity/settings.py +++ b/NorahUniversity/settings.py @@ -62,6 +62,7 @@ INSTALLED_APPS = [ "django_q", "widget_tweaks", "easyaudit", + "secured_fields", ] @@ -538,3 +539,4 @@ LOGGING={ } +SECURED_FIELDS_KEY="kvaCwxrIMtVRouBH5mzf9g-uelv7XUD840ncAiOXkt4=" \ No newline at end of file diff --git a/recruitment/forms.py b/recruitment/forms.py index ebca72b..5464df7 100644 --- a/recruitment/forms.py +++ b/recruitment/forms.py @@ -714,7 +714,7 @@ class BulkInterviewTemplateForm(forms.ModelForm): class InterviewCancelForm(forms.ModelForm): class Meta: - model = Interview + model = ScheduledInterview fields = ["cancelled_reason","cancelled_at"] widgets = { "cancelled_reason": forms.Textarea( @@ -2032,3 +2032,74 @@ class SettingsForm(forms.ModelForm): if not value: raise forms.ValidationError("Setting value cannot be empty.") return value + + +class InterviewEmailForm(forms.Form): + """Form for composing emails to participants about a candidate""" + to = forms.MultipleChoiceField( + widget=forms.CheckboxSelectMultiple(attrs={ + 'class': 'form-check' + }), + label=_('Select Candidates'), # Use a descriptive label + required=False + ) + + 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 + + if application.hiring_agency: + self.fields['to'].initial=application.hiring_agency.email + else: + self.fields['to'].initial=application.person.email + + + super().__init__(*args, **kwargs) + + + # 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.interview_date} +Time: {interview.interview_time} +Job: {job.title} + +""" + + if schedule.location_type == 'Remote': + initial_message += "This is a remote schedule. You will receive the meeting link separately.\n\n" + else: + email_body += "This is an onsite schedule. Please arrive 10 minutes early.\n\n" + + initial_message += """ +Best regards, +HR Team + """ + + self.fields['message'].initial = initial_message + diff --git a/recruitment/migrations/0002_alter_customuser_first_name_alter_customuser_phone_and_more.py b/recruitment/migrations/0002_alter_customuser_first_name_alter_customuser_phone_and_more.py new file mode 100644 index 0000000..9ca803b --- /dev/null +++ b/recruitment/migrations/0002_alter_customuser_first_name_alter_customuser_phone_and_more.py @@ -0,0 +1,44 @@ +# Generated by Django 5.2.7 on 2025-12-10 12:50 + +import secured_fields.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='first_name', + field=secured_fields.fields.EncryptedCharField(blank=True, max_length=150, verbose_name='first name'), + ), + migrations.AlterField( + model_name='customuser', + name='phone', + field=secured_fields.fields.EncryptedCharField(blank=True, null=True, verbose_name='Phone'), + ), + migrations.AlterField( + model_name='hiringagency', + name='phone', + field=secured_fields.fields.EncryptedCharField(blank=True, max_length=20, null=True), + ), + migrations.AlterField( + model_name='participants', + name='phone', + field=secured_fields.fields.EncryptedCharField(blank=True, max_length=12, null=True, verbose_name='Phone Number'), + ), + migrations.AlterField( + model_name='person', + name='first_name', + field=secured_fields.fields.EncryptedCharField(max_length=255, verbose_name='First Name'), + ), + migrations.AlterField( + model_name='person', + name='phone', + field=secured_fields.fields.EncryptedCharField(blank=True, null=True, verbose_name='Phone'), + ), + ] diff --git a/recruitment/migrations/0003_alter_person_national_id.py b/recruitment/migrations/0003_alter_person_national_id.py new file mode 100644 index 0000000..140cee3 --- /dev/null +++ b/recruitment/migrations/0003_alter_person_national_id.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.7 on 2025-12-10 13:04 + +import secured_fields.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0002_alter_customuser_first_name_alter_customuser_phone_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='person', + name='national_id', + field=secured_fields.fields.EncryptedCharField(help_text='Enter the national id or iqama number'), + ), + ] diff --git a/recruitment/migrations/0004_remove_interview_cancelled_at_and_more.py b/recruitment/migrations/0004_remove_interview_cancelled_at_and_more.py new file mode 100644 index 0000000..599d096 --- /dev/null +++ b/recruitment/migrations/0004_remove_interview_cancelled_at_and_more.py @@ -0,0 +1,31 @@ +# Generated by Django 5.2.7 on 2025-12-10 13:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0003_alter_person_national_id'), + ] + + operations = [ + migrations.RemoveField( + model_name='interview', + name='cancelled_at', + ), + migrations.RemoveField( + model_name='interview', + name='cancelled_reason', + ), + migrations.AddField( + model_name='scheduledinterview', + name='cancelled_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='Cancelled At'), + ), + migrations.AddField( + model_name='scheduledinterview', + name='cancelled_reason', + field=models.TextField(blank=True, null=True, verbose_name='Cancellation Reason'), + ), + ] diff --git a/recruitment/models.py b/recruitment/models.py index 4f643c9..648cf13 100644 --- a/recruitment/models.py +++ b/recruitment/models.py @@ -16,6 +16,7 @@ from django.db.models.fields.json import KeyTransform, KeyTextTransform from django_countries.fields import CountryField from django_ckeditor_5.fields import CKEditor5Field from django_extensions.db.fields import RandomCharField +from secured_fields import EncryptedCharField from typing import List, Dict, Any @@ -42,10 +43,12 @@ class CustomUser(AbstractUser): ("candidate", _("Candidate")), ] + first_name=EncryptedCharField(_("first name"), max_length=150, blank=True) + user_type = models.CharField( max_length=20, choices=USER_TYPES, default="staff", verbose_name=_("User Type") ) - phone = models.CharField( + phone = EncryptedCharField( blank=True, null=True, verbose_name=_("Phone") ) profile_image = models.ImageField( @@ -514,7 +517,7 @@ class Person(Base): ] # Personal Information - first_name = models.CharField(max_length=255, verbose_name=_("First Name")) + first_name = EncryptedCharField(max_length=255, verbose_name=_("First Name")) last_name = models.CharField(max_length=255, verbose_name=_("Last Name")) middle_name = models.CharField( max_length=255, blank=True, null=True, verbose_name=_("Middle Name") @@ -524,7 +527,7 @@ class Person(Base): db_index=True, verbose_name=_("Email"), ) - phone = models.CharField( + phone = EncryptedCharField( blank=True, null=True, verbose_name=_("Phone") ) date_of_birth = models.DateField( @@ -540,7 +543,7 @@ class Person(Base): gpa = models.DecimalField( max_digits=3, decimal_places=2, verbose_name=_("GPA"),help_text=_("GPA must be between 0 and 4.") ) - national_id = models.CharField( + national_id = EncryptedCharField( help_text=_("Enter the national id or iqama number") ) nationality = CountryField(blank=True, null=True, verbose_name=_("Nationality")) @@ -1124,9 +1127,7 @@ class Interview(Base): default=Status.WAITING, db_index=True ) - cancelled_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Cancelled At")) - cancelled_reason = models.TextField(blank=True, null=True, verbose_name=_("Cancellation Reason")) - + # Remote-specific (nullable) meeting_id = models.CharField( max_length=50, unique=True, null=True, blank=True, verbose_name=_("External Meeting ID") @@ -1242,6 +1243,10 @@ class ScheduledInterview(Base): CANCELLED = "cancelled", _("Cancelled") COMPLETED = "completed", _("Completed") + cancelled_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Cancelled At")) + cancelled_reason = models.TextField(blank=True, null=True, verbose_name=_("Cancellation Reason")) + + application = models.ForeignKey( Application, on_delete=models.CASCADE, @@ -1254,7 +1259,7 @@ class ScheduledInterview(Base): related_name="scheduled_interviews", db_index=True, ) - + # Links to the specific, individual location/meeting details for THIS interview interview = models.OneToOneField( Interview, @@ -1880,7 +1885,7 @@ class HiringAgency(Base): max_length=150, blank=True, verbose_name=_("Contact Person") ) email = models.EmailField(unique=True) - phone = models.CharField(max_length=20, blank=True,null=True) + phone = EncryptedCharField(max_length=20, blank=True,null=True) website = models.URLField(blank=True) notes = models.TextField(blank=True, help_text=_("Internal notes about the agency")) country = CountryField(blank=True, null=True, blank_label=_("Select country")) @@ -2278,7 +2283,7 @@ class Participants(Base): max_length=255, verbose_name=_("Participant Name"), null=True, blank=True ) email =models.EmailField(verbose_name=_("Email")) - phone = models.CharField( + phone = EncryptedCharField( max_length=12, verbose_name=_("Phone Number"), null=True, blank=True ) designation = models.CharField( diff --git a/recruitment/urls.py b/recruitment/urls.py index 73062c8..5c2750c 100644 --- a/recruitment/urls.py +++ b/recruitment/urls.py @@ -84,6 +84,7 @@ urlpatterns = [ path("interviews//", views.interview_detail, name="interview_detail"), path("interviews//update_interview_status", views.update_interview_status, name="update_interview_status"), path("interviews//cancel_interview_for_application", views.cancel_interview_for_application, name="cancel_interview_for_application"), + path("interview//interview-email/",views.send_interview_email,name="send_interview_email"), # Interview Creation path("interviews/create//", views.interview_create_type_selection, name="interview_create_type_selection"), diff --git a/recruitment/views.py b/recruitment/views.py index 628c137..5d0a101 100644 --- a/recruitment/views.py +++ b/recruitment/views.py @@ -63,6 +63,7 @@ from rest_framework import viewsets from easyaudit.models import CRUDEvent, LoginEvent, RequestEvent from django_q.tasks import async_task + # Local Apps from .decorators import ( agency_user_required, @@ -88,7 +89,8 @@ from .forms import ( OnsiteInterviewForm, BulkInterviewTemplateForm, SettingsForm, - InterviewCancelForm + InterviewCancelForm, + InterviewEmailForm ) from .utils import generate_random_password from django.views.decorators.csrf import csrf_exempt @@ -182,7 +184,7 @@ class PersonListView(StaffRequiredMixin, ListView,LoginRequiredMixin): search_query=self.request.GET.get('search','') if search_query: queryset=queryset.filter( - Q(first_name__icontains=search_query) | + Q(first_name=search_query) | Q(last_name__icontains=search_query) | Q(email__icontains=search_query) ) @@ -1834,7 +1836,7 @@ def applications_document_review_view(request, slug): search_query = request.GET.get('q', '') if search_query: applications = applications.filter( - Q(person__first_name__icontains=search_query) | + Q(person__first_name=search_query) | Q(person__last_name__icontains=search_query) | Q(person__email__icontains=search_query) ) @@ -2812,10 +2814,10 @@ def agency_portal_persons_list(request): search_query = request.GET.get("q", "") if search_query: persons = persons.filter( - Q(first_name__icontains=search_query) + Q(first_name=search_query) | Q(last_name__icontains=search_query) | Q(email__icontains=search_query) - | Q(phone__icontains=search_query) + | Q(phone=search_query) | Q(job__title__icontains=search_query) ) @@ -3806,18 +3808,13 @@ def cancel_interview_for_application(request, slug): Handles POST request to cancel an interview, setting the status and saving the form data (likely a reason for cancellation). """ - interview = get_object_or_404(Interview, slug=slug) - scheduled_interview = get_object_or_404(ScheduledInterview, interview=interview) - form = InterviewCancelForm(request.POST, instance=interview) - - + scheduled_interview = get_object_or_404(ScheduledInterview) + form = InterviewCancelForm(request.POST, instance=scheduled_interview) if form.is_valid(): - - interview.status = interview.Status.CANCELLED scheduled_interview.status = scheduled_interview.InterviewStatus.CANCELLED scheduled_interview.save(update_fields=['status']) - interview.save(update_fields=['status']) # Saves the new status + scheduled_interview.save(update_fields=['status']) # Saves the new status form.save() # Saves form data @@ -4360,7 +4357,7 @@ def interview_list(request): interviews = interviews.filter(job__slug=job_filter) if search_query: interviews = interviews.filter( - Q(application__person__first_name__icontains=search_query) | + Q(application__person__first_name=search_query) | Q(application__person__last_name__icontains=search_query) | Q(application__person__email=search_query)| Q(job__title__icontains=search_query) @@ -4391,8 +4388,8 @@ def interview_detail(request, slug): interview = schedule.interview reschedule_form = ScheduledInterviewForm() - reschedule_form.initial['topic'] = interview.interview.topic - meeting=interview.interview + reschedule_form.initial['topic'] = interview.topic + meeting=interview context = { 'schedule': schedule, 'interview': interview, @@ -4582,7 +4579,7 @@ def job_applicants_view(request, slug): # Apply filters if search_query: applications = applications.filter( - Q(person__first_name__icontains=search_query) | + Q(person__first_name=search_query) | Q(person__last_name__icontains=search_query) | Q(person__email__icontains=search_query) | Q(email__icontains=search_query) @@ -4909,10 +4906,10 @@ class JobApplicationListView(LoginRequiredMixin, StaffRequiredMixin, ListView): search_query = self.request.GET.get('search', '') if search_query: queryset = queryset.filter( - Q(first_name__icontains=search_query) | + Q(first_name=search_query) | Q(last_name__icontains=search_query) | Q(email__icontains=search_query) | - Q(phone__icontains=search_query) | + Q(phone=search_query) | Q(stage__icontains=search_query) ) @@ -4944,10 +4941,10 @@ class ApplicationListView(LoginRequiredMixin, StaffRequiredMixin, ListView): stage = self.request.GET.get('stage', '') if search_query: queryset = queryset.filter( - Q(person__first_name__icontains=search_query) | + Q(person__first_name=search_query) | Q(person__last_name__icontains=search_query) | Q(person__email__icontains=search_query) | - Q(person__phone__icontains=search_query) + Q(person__phone=search_query) ) if job: queryset = queryset.filter(job__slug=job) @@ -5331,10 +5328,10 @@ def applications_offer_view(request, slug): search_query = request.GET.get('search', '') if search_query: applications = applications.filter( - Q(first_name__icontains=search_query) | + Q(first_name=search_query) | Q(last_name__icontains=search_query) | Q(email__icontains=search_query) | - Q(phone__icontains=search_query) + Q(phone=search_query) ) applications = applications.order_by('-created_at') @@ -5361,10 +5358,10 @@ def applications_hired_view(request, slug): search_query = request.GET.get('search', '') if search_query: applications = applications.filter( - Q(first_name__icontains=search_query) | + Q(first_name=search_query) | Q(last_name__icontains=search_query) | Q(email__icontains=search_query) | - Q(phone__icontains=search_query) + Q(phone=search_query) ) applications = applications.order_by('-created_at') @@ -5469,10 +5466,10 @@ def export_applications_csv(request, job_slug, stage): search_query = request.GET.get('search', '') if search_query: applications = applications.filter( - Q(first_name__icontains=search_query) | + Q(first_name=search_query) | Q(last_name__icontains=search_query) | Q(email__icontains=search_query) | - Q(phone__icontains=search_query) + Q(phone=search_query) ) applications = applications.order_by('-created_at') @@ -5739,4 +5736,22 @@ def sync_history(request, job_slug=None): 'job': job if job_slug else None, } - return render(request, 'recruitment/sync_history.html', context) \ No newline at end of file + return render(request, 'recruitment/sync_history.html', context) + + +def send_interview_email(request,slug): + schedule=get_object_or_404(ScheduledInterview,slug=slug) + application=schedule.application.first() + job=application.job + form=InterviewEmailForm(job,application,schedule) + if request.method=='POST': + recipient=form.cleaned_data.get('to').strip() + body_message=form.cleaned_data.get('message') + sender=request.user + job=job + pass + + + + # async_task('recruitment.tasks._task_send_individual_email', 'value1', 'value2') + # def _task_send_individual_email(subject, body_message, recipient, attachments,sender,job): \ No newline at end of file diff --git a/templates/interviews/interview_detail.html b/templates/interviews/interview_detail.html index 9bcda25..cc5d3a1 100644 --- a/templates/interviews/interview_detail.html +++ b/templates/interviews/interview_detail.html @@ -223,7 +223,7 @@ {% trans "View Job" %} - {% if interview.status != 'cancelled' %} + {% if schedule.status != 'cancelled' %} {% endcomment %} - {% endcomment %} +
@@ -503,7 +498,7 @@
- {% if schedule.status != 'CANCELLED' and schedule.status != 'COMPLETED' %} + {% if schedule.status != 'cancelled' and schedule.status != 'completed' %} {% endif %} - + {% if schedule.status == 'cancelled' %} +

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

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