diff --git a/.env b/.env index b9e2bf0..8d7fbd5 100644 --- a/.env +++ b/.env @@ -1,3 +1,3 @@ -DB_NAME=norahuniversity -DB_USER=norahuniversity -DB_PASSWORD=norahuniversity \ No newline at end of file +DB_NAME=haikal_db +DB_USER=faheed +DB_PASSWORD=Faheed@215 \ No newline at end of file diff --git a/NorahUniversity/settings.py b/NorahUniversity/settings.py index 702acec..2b4cac5 100644 --- a/NorahUniversity/settings.py +++ b/NorahUniversity/settings.py @@ -206,7 +206,9 @@ ACCOUNT_LOGIN_ON_EMAIL_CONFIRMATION = True ACCOUNT_FORMS = {"signup": "recruitment.forms.StaffSignupForm"} -EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" +EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" +EMAIL_HOST = "10.10.1.110" +EMAIL_PORT = 2225 # Crispy Forms Configuration CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5" diff --git a/recruitment/forms.py b/recruitment/forms.py index 49fa7d8..75d4f41 100644 --- a/recruitment/forms.py +++ b/recruitment/forms.py @@ -27,7 +27,8 @@ from .models import ( Participants, Message, Person, - Document + Document, + CustomUser ) # from django_summernote.widgets import SummernoteWidget @@ -1028,17 +1029,20 @@ class HiringAgencyForm(forms.ModelForm): raise ValidationError("Please enter a valid email address.") # Check uniqueness (optional - remove if multiple agencies can have same email) - instance = self.instance - if not instance.pk: # Creating new instance - if HiringAgency.objects.filter(email=email).exists(): - raise ValidationError("An agency with this email already exists.") - else: # Editing existing instance - if ( - HiringAgency.objects.filter(email=email) - .exclude(pk=instance.pk) - .exists() - ): - raise ValidationError("An agency with this email already exists.") + # instance = self.instance + email = email.lower().strip() + if CustomUser.objects.filter(email=email).exists(): + raise ValidationError("This email is already associated with a user account.") + # if not instance.pk: # Creating new instance + # if HiringAgency.objects.filter(email=email).exists(): + # raise ValidationError("An agency with this email already exists.") + # else: # Editing existing instance + # if ( + # HiringAgency.objects.filter(email=email) + # .exclude(pk=instance.pk) + # .exists() + # ): + # raise ValidationError("An agency with this email already exists.") return email.lower().strip() if email else email def clean_phone(self): @@ -2108,6 +2112,7 @@ class MessageForm(forms.ModelForm): "rows": 6, "placeholder": "Enter your message here...", "required": True, + 'spellcheck': 'true', } ), "message_type": forms.Select(attrs={"class": "form-select"}), @@ -2152,11 +2157,22 @@ class MessageForm(forms.ModelForm): """Filter job options based on user type""" if self.user.user_type == "agency": - # Agency users can only see jobs assigned to their agency + # # Agency users can only see jobs assigned to their agency + # self.fields["job"].queryset = JobPosting.objects.filter( + # hiring_agency__user=self.user, + # status="ACTIVE" + # ).order_by("-created_at") + # + job_assignments =AgencyJobAssignment.objects.filter( + agency__user=self.user, + job__status="ACTIVE" + ) + job_ids = job_assignments.values_list('job__id', flat=True) self.fields["job"].queryset = JobPosting.objects.filter( - hiring_agency__user=self.user, - status="ACTIVE" + id__in=job_ids ).order_by("-created_at") + + print("Agency user job queryset:", self.fields["job"].queryset) elif self.user.user_type == "candidate": # Candidates can only see jobs they applied for self.fields["job"].queryset = JobPosting.objects.filter( @@ -2179,6 +2195,7 @@ class MessageForm(forms.ModelForm): self.fields["recipient"].queryset = User.objects.filter( user_type="staff" ).distinct().order_by("username") + elif self.user.user_type == "candidate": # Candidates can only message staff self.fields["recipient"].queryset = User.objects.filter( diff --git a/recruitment/migrations/0001_initial.py b/recruitment/migrations/0001_initial.py new file mode 100644 index 0000000..918503b --- /dev/null +++ b/recruitment/migrations/0001_initial.py @@ -0,0 +1,740 @@ +# Generated by Django 5.2.7 on 2025-11-27 15:36 + +import django.contrib.auth.models +import django.contrib.auth.validators +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +import django_ckeditor_5.fields +import django_countries.fields +import django_extensions.db.fields +import recruitment.validators +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ('contenttypes', '0002_remove_content_type_name'), + ] + + operations = [ + migrations.CreateModel( + name='BreakTime', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('start_time', models.TimeField(verbose_name='Start Time')), + ('end_time', models.TimeField(verbose_name='End Time')), + ], + ), + migrations.CreateModel( + name='FormStage', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), + ('name', models.CharField(help_text='Name of the stage', max_length=200)), + ('order', models.PositiveIntegerField(default=0, help_text='Order of the stage in the form')), + ('is_predefined', models.BooleanField(default=False, help_text='Whether this is a default resume stage')), + ], + options={ + 'verbose_name': 'Form Stage', + 'verbose_name_plural': 'Form Stages', + 'ordering': ['order'], + }, + ), + migrations.CreateModel( + name='Interview', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), + ('location_type', models.CharField(choices=[('Remote', 'Remote (e.g., Zoom, Google Meet)'), ('Onsite', 'In-Person (Physical Location)')], db_index=True, max_length=10, verbose_name='Location Type')), + ('topic', models.CharField(blank=True, help_text="e.g., 'Zoom Topic: Software Interview' or 'Main Conference Room'", max_length=255, verbose_name='Meeting/Location Topic')), + ('details_url', models.URLField(blank=True, max_length=2048, null=True, verbose_name='Meeting/Location URL')), + ('timezone', models.CharField(default='UTC', max_length=50, verbose_name='Timezone')), + ('start_time', models.DateTimeField(db_index=True, verbose_name='Start Time')), + ('duration', models.PositiveIntegerField(verbose_name='Duration (minutes)')), + ('status', models.CharField(choices=[('waiting', 'Waiting'), ('started', 'Started'), ('ended', 'Ended'), ('cancelled', 'Cancelled')], db_index=True, default='waiting', max_length=20)), + ('meeting_id', models.CharField(blank=True, max_length=50, null=True, unique=True, verbose_name='External Meeting ID')), + ('password', models.CharField(blank=True, max_length=20, null=True)), + ('zoom_gateway_response', models.JSONField(blank=True, null=True)), + ('participant_video', models.BooleanField(default=True)), + ('join_before_host', models.BooleanField(default=False)), + ('host_email', models.CharField(blank=True, max_length=255, null=True)), + ('mute_upon_entry', models.BooleanField(default=False)), + ('waiting_room', models.BooleanField(default=False)), + ('physical_address', models.CharField(blank=True, max_length=255, null=True)), + ('room_number', models.CharField(blank=True, max_length=50, null=True)), + ], + options={ + 'verbose_name': 'Interview Location', + 'verbose_name_plural': 'Interview Locations', + }, + ), + migrations.CreateModel( + name='Participants', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), + ('name', models.CharField(blank=True, max_length=255, null=True, verbose_name='Participant Name')), + ('email', models.EmailField(max_length=254, verbose_name='Email')), + ('phone', models.CharField(blank=True, max_length=12, null=True, verbose_name='Phone Number')), + ('designation', models.CharField(blank=True, max_length=100, null=True, verbose_name='Designation')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Source', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('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')), + ('name', models.CharField(help_text='e.g., ATS, ERP ', max_length=100, unique=True, verbose_name='Source Name')), + ('source_type', models.CharField(help_text='e.g., ATS, ERP ', max_length=100, verbose_name='Source Type')), + ('description', models.TextField(blank=True, help_text='A description of the source', verbose_name='Description')), + ('ip_address', models.GenericIPAddressField(blank=True, help_text='The IP address of the source', null=True, verbose_name='IP Address')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('api_key', models.CharField(blank=True, help_text='API key for authentication (will be encrypted)', max_length=255, null=True, verbose_name='API Key')), + ('api_secret', models.CharField(blank=True, help_text='API secret for authentication (will be encrypted)', max_length=255, null=True, verbose_name='API Secret')), + ('trusted_ips', models.TextField(blank=True, help_text='Comma-separated list of trusted IP addresses', null=True, verbose_name='Trusted IP Addresses')), + ('is_active', models.BooleanField(default=True, help_text='Whether this source is active for integration', verbose_name='Active')), + ('integration_version', models.CharField(blank=True, help_text='Version of the integration protocol', max_length=50, verbose_name='Integration Version')), + ('last_sync_at', models.DateTimeField(blank=True, help_text='Timestamp of the last successful synchronization', null=True, verbose_name='Last Sync At')), + ('sync_status', models.CharField(blank=True, choices=[('IDLE', 'Idle'), ('SYNCING', 'Syncing'), ('ERROR', 'Error'), ('DISABLED', 'Disabled')], default='IDLE', max_length=20, verbose_name='Sync Status')), + ('sync_endpoint', models.URLField(blank=True, help_text='Endpoint URL for sending candidate data (for outbound sync)', null=True, verbose_name='Sync Endpoint')), + ('sync_method', models.CharField(blank=True, choices=[('POST', 'POST'), ('PUT', 'PUT')], default='POST', help_text='HTTP method for outbound sync requests', max_length=10, verbose_name='Sync Method')), + ('test_method', models.CharField(blank=True, choices=[('GET', 'GET'), ('POST', 'POST')], default='GET', help_text='HTTP method for connection testing', max_length=10, verbose_name='Test Method')), + ('custom_headers', models.TextField(blank=True, help_text='JSON object with custom HTTP headers for sync requests', null=True, verbose_name='Custom Headers')), + ('supports_outbound_sync', models.BooleanField(default=False, help_text='Whether this source supports receiving candidate data from ATS', verbose_name='Supports Outbound Sync')), + ], + options={ + 'verbose_name': 'Source', + 'verbose_name_plural': 'Sources', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('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, max_length=20, null=True, verbose_name='Phone')), + ('profile_image', models.ImageField(blank=True, null=True, upload_to='profile_pic/', validators=[recruitment.validators.validate_image_size], verbose_name='Profile Image')), + ('designation', models.CharField(blank=True, max_length=100, null=True, verbose_name='Designation')), + ('email', models.EmailField(error_messages={'unique': 'A user with this email already exists.'}, max_length=254, unique=True)), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'User', + 'verbose_name_plural': 'Users', + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + migrations.CreateModel( + name='FormField', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), + ('label', models.CharField(help_text='Label for the field', max_length=200)), + ('field_type', models.CharField(choices=[('text', 'Text Input'), ('email', 'Email'), ('phone', 'Phone'), ('textarea', 'Text Area'), ('file', 'File Upload'), ('date', 'Date Picker'), ('select', 'Dropdown'), ('radio', 'Radio Buttons'), ('checkbox', 'Checkboxes')], help_text='Type of the field', max_length=20)), + ('placeholder', models.CharField(blank=True, help_text='Placeholder text', max_length=200)), + ('required', models.BooleanField(default=False, help_text='Whether the field is required')), + ('order', models.PositiveIntegerField(default=0, help_text='Order of the field in the stage')), + ('is_predefined', models.BooleanField(default=False, help_text='Whether this is a default field')), + ('options', models.JSONField(blank=True, default=list, help_text='Options for selection fields (stored as JSON array)')), + ('file_types', models.CharField(blank=True, help_text="Allowed file types (comma-separated, e.g., '.pdf,.doc,.docx')", max_length=200)), + ('max_file_size', models.PositiveIntegerField(default=5, help_text='Maximum file size in MB (default: 5MB)')), + ('multiple_files', models.BooleanField(default=False, help_text='Allow multiple files to be uploaded')), + ('max_files', models.PositiveIntegerField(default=1, help_text='Maximum number of files allowed (when multiple_files is True)')), + ('stage', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fields', to='recruitment.formstage')), + ], + options={ + 'verbose_name': 'Form Field', + 'verbose_name_plural': 'Form Fields', + 'ordering': ['order'], + }, + ), + migrations.CreateModel( + name='FormTemplate', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), + ('name', models.CharField(help_text='Name of the form template', max_length=200)), + ('description', models.TextField(blank=True, help_text='Description of the form template')), + ('is_active', models.BooleanField(default=False, help_text='Whether this template is active')), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='form_templates', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Form Template', + 'verbose_name_plural': 'Form Templates', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='FormSubmission', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), + ('submitted_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('applicant_name', models.CharField(blank=True, help_text='Name of the applicant', max_length=200)), + ('applicant_email', models.EmailField(blank=True, db_index=True, help_text='Email of the applicant', max_length=254)), + ('submitted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='form_submissions', to=settings.AUTH_USER_MODEL)), + ('template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='submissions', to='recruitment.formtemplate')), + ], + options={ + 'verbose_name': 'Form Submission', + 'verbose_name_plural': 'Form Submissions', + 'ordering': ['-submitted_at'], + }, + ), + migrations.AddField( + model_name='formstage', + name='template', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stages', to='recruitment.formtemplate'), + ), + migrations.CreateModel( + name='HiringAgency', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), + ('name', models.CharField(max_length=200, unique=True, verbose_name='Agency Name')), + ('contact_person', models.CharField(blank=True, max_length=150, verbose_name='Contact Person')), + ('email', models.EmailField(blank=True, max_length=254)), + ('phone', models.CharField(blank=True, max_length=20)), + ('website', models.URLField(blank=True)), + ('notes', models.TextField(blank=True, help_text='Internal notes about the agency')), + ('country', django_countries.fields.CountryField(blank=True, max_length=2, null=True)), + ('address', models.TextField(blank=True, null=True)), + ('generated_password', models.CharField(blank=True, help_text='Generated password for agency user account', max_length=255, null=True)), + ('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='agency_profile', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'verbose_name': 'Hiring Agency', + 'verbose_name_plural': 'Hiring Agencies', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='Application', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), + ('resume', models.FileField(upload_to='resumes/', verbose_name='Resume')), + ('cover_letter', models.FileField(blank=True, null=True, upload_to='cover_letters/', verbose_name='Cover Letter')), + ('is_resume_parsed', models.BooleanField(default=False, verbose_name='Resume Parsed')), + ('parsed_summary', models.TextField(blank=True, verbose_name='Parsed Summary')), + ('applied', models.BooleanField(default=False, verbose_name='Applied')), + ('stage', models.CharField(choices=[('Applied', 'Applied'), ('Exam', 'Exam'), ('Interview', 'Interview'), ('Document Review', 'Document Review'), ('Offer', 'Offer'), ('Hired', 'Hired'), ('Rejected', 'Rejected')], db_index=True, default='Applied', max_length=20, verbose_name='Stage')), + ('applicant_status', models.CharField(blank=True, choices=[('Applicant', 'Applicant'), ('Candidate', 'Candidate')], default='Applicant', max_length=20, null=True, verbose_name='Applicant Status')), + ('exam_date', models.DateTimeField(blank=True, null=True, verbose_name='Exam Date')), + ('exam_status', models.CharField(blank=True, choices=[('Passed', 'Passed'), ('Failed', 'Failed')], max_length=20, null=True, verbose_name='Exam Status')), + ('exam_score', models.FloatField(blank=True, null=True, verbose_name='Exam Score')), + ('interview_date', models.DateTimeField(blank=True, null=True, verbose_name='Interview Date')), + ('interview_status', models.CharField(blank=True, choices=[('Passed', 'Passed'), ('Failed', 'Failed')], max_length=20, null=True, verbose_name='Interview Status')), + ('offer_date', models.DateField(blank=True, null=True, verbose_name='Offer Date')), + ('offer_status', models.CharField(blank=True, choices=[('Accepted', 'Accepted'), ('Rejected', 'Rejected'), ('Pending', 'Pending')], max_length=20, null=True, verbose_name='Offer Status')), + ('hired_date', models.DateField(blank=True, null=True, verbose_name='Hired Date')), + ('join_date', models.DateField(blank=True, null=True, verbose_name='Join Date')), + ('ai_analysis_data', models.JSONField(blank=True, default=dict, help_text='Full JSON output from the resume scoring model.', null=True, verbose_name='AI Analysis Data')), + ('retry', models.SmallIntegerField(default=3, verbose_name='Resume Parsing Retry')), + ('hiring_source', models.CharField(blank=True, choices=[('Public', 'Public'), ('Internal', 'Internal'), ('Agency', 'Agency')], default='Public', max_length=255, null=True, verbose_name='Hiring Source')), + ('hiring_agency', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='applications', to='recruitment.hiringagency', verbose_name='Hiring Agency')), + ], + options={ + 'verbose_name': 'Application', + 'verbose_name_plural': 'Applications', + }, + ), + migrations.CreateModel( + name='InterviewNote', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), + ('note_type', models.CharField(choices=[('Feedback', 'Candidate Feedback'), ('Logistics', 'Logistical Note'), ('General', 'General Comment')], default='Feedback', max_length=50, verbose_name='Note Type')), + ('content', django_ckeditor_5.fields.CKEditor5Field(verbose_name='Content/Feedback')), + ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interview_notes', to=settings.AUTH_USER_MODEL, verbose_name='Author')), + ('interview', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notes', to='recruitment.interview', verbose_name='Scheduled Interview')), + ], + options={ + 'verbose_name': 'Interview Note', + 'verbose_name_plural': 'Interview Notes', + 'ordering': ['created_at'], + }, + ), + migrations.CreateModel( + name='JobPosting', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), + ('title', models.CharField(max_length=200)), + ('department', models.CharField(blank=True, max_length=100)), + ('job_type', models.CharField(choices=[('Full-time', 'Full-time'), ('Part-time', 'Part-time'), ('Contract', 'Contract'), ('Internship', 'Internship'), ('Faculty', 'Faculty'), ('Temporary', 'Temporary')], default='Full-time', max_length=20)), + ('workplace_type', models.CharField(choices=[('On-site', 'On-site'), ('Remote', 'Remote'), ('Hybrid', 'Hybrid')], default='On-site', max_length=20)), + ('location_city', models.CharField(blank=True, max_length=100)), + ('location_state', models.CharField(blank=True, max_length=100)), + ('location_country', models.CharField(default='Saudia Arabia', max_length=100)), + ('description', django_ckeditor_5.fields.CKEditor5Field(verbose_name='Description')), + ('qualifications', django_ckeditor_5.fields.CKEditor5Field(blank=True, null=True)), + ('salary_range', models.CharField(blank=True, help_text='e.g., $60,000 - $80,000', max_length=200)), + ('benefits', django_ckeditor_5.fields.CKEditor5Field(blank=True, null=True)), + ('application_url', models.URLField(blank=True, help_text='URL where applicants apply', null=True, validators=[django.core.validators.URLValidator()])), + ('application_deadline', models.DateField(db_index=True)), + ('application_instructions', django_ckeditor_5.fields.CKEditor5Field(blank=True, null=True)), + ('internal_job_id', models.CharField(editable=False, max_length=50)), + ('created_by', models.CharField(blank=True, help_text='Name of person who created this job', max_length=100)), + ('status', models.CharField(choices=[('DRAFT', 'Draft'), ('ACTIVE', 'Active'), ('CLOSED', 'Closed'), ('CANCELLED', 'Cancelled'), ('ARCHIVED', 'Archived')], db_index=True, default='DRAFT', max_length=20)), + ('hash_tags', models.CharField(blank=True, help_text='Comma-separated hashtags for linkedin post like #hiring,#jobopening', max_length=200, validators=[recruitment.validators.validate_hash_tags])), + ('linkedin_post_id', models.CharField(blank=True, help_text='LinkedIn post ID after posting', max_length=200)), + ('linkedin_post_url', models.URLField(blank=True, help_text='Direct URL to LinkedIn post')), + ('posted_to_linkedin', models.BooleanField(default=False)), + ('linkedin_post_status', models.CharField(blank=True, help_text='Status of LinkedIn posting', max_length=50)), + ('linkedin_posted_at', models.DateTimeField(blank=True, null=True)), + ('linkedin_post_formated_data', models.TextField(blank=True, null=True)), + ('published_at', models.DateTimeField(blank=True, db_index=True, null=True)), + ('position_number', models.CharField(blank=True, help_text='University position number', max_length=50)), + ('reporting_to', models.CharField(blank=True, help_text='Who this position reports to', max_length=100)), + ('open_positions', models.PositiveIntegerField(default=1, help_text='Number of open positions for this job')), + ('max_applications', models.PositiveIntegerField(blank=True, default=1000, help_text='Maximum number of applications allowed', null=True)), + ('cancel_reason', models.TextField(blank=True, help_text='Reason for canceling the job posting', verbose_name='Cancel Reason')), + ('cancelled_by', models.CharField(blank=True, help_text='Name of person who cancelled this job', max_length=100, verbose_name='Cancelled By')), + ('cancelled_at', models.DateTimeField(blank=True, null=True)), + ('ai_parsed', models.BooleanField(default=False, help_text='Whether the job posting has been parsed by AI', verbose_name='AI Parsed')), + ('cv_zip_file', models.FileField(blank=True, null=True, upload_to='job_zips/')), + ('zip_created', models.BooleanField(default=False)), + ('assigned_to', models.ForeignKey(blank=True, help_text='The user who has been assigned to this job', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_jobs', to=settings.AUTH_USER_MODEL, verbose_name='Assigned To')), + ('hiring_agency', models.ManyToManyField(blank=True, help_text='External agency responsible for sourcing applicants for this role', related_name='jobs', to='recruitment.hiringagency', verbose_name='Hiring Agency')), + ('source', models.ForeignKey(blank=True, help_text='The system or channel from which this job posting originated or was first published.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='job_postings', to='recruitment.source')), + ], + options={ + 'verbose_name': 'Job Posting', + 'verbose_name_plural': 'Job Postings', + 'ordering': ['-created_at'], + }, + ), + migrations.AddField( + model_name='formtemplate', + name='job', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='form_template', to='recruitment.jobposting'), + ), + migrations.CreateModel( + name='BulkInterviewTemplate', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), + ('start_date', models.DateField(db_index=True, verbose_name='Start Date')), + ('end_date', models.DateField(db_index=True, verbose_name='End Date')), + ('working_days', models.JSONField(verbose_name='Working Days')), + ('start_time', models.TimeField(verbose_name='Start Time')), + ('end_time', models.TimeField(verbose_name='End Time')), + ('break_start_time', models.TimeField(blank=True, null=True, verbose_name='Break Start Time')), + ('break_end_time', models.TimeField(blank=True, null=True, verbose_name='Break End Time')), + ('interview_duration', models.PositiveIntegerField(verbose_name='Interview Duration (minutes)')), + ('buffer_time', models.PositiveIntegerField(default=0, verbose_name='Buffer Time (minutes)')), + ('applications', models.ManyToManyField(blank=True, related_name='interview_schedules', to='recruitment.application')), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('interview', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='schedule_templates', to='recruitment.interview', verbose_name='Location Template (Zoom/Onsite)')), + ('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interview_schedules', to='recruitment.jobposting')), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='application', + name='job', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='applications', to='recruitment.jobposting', verbose_name='Job'), + ), + migrations.CreateModel( + name='AgencyJobAssignment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), + ('max_candidates', models.PositiveIntegerField(help_text='Maximum candidates agency can submit for this job', verbose_name='Maximum Candidates')), + ('candidates_submitted', models.PositiveIntegerField(default=0, help_text='Number of candidates submitted so far', verbose_name='Candidates Submitted')), + ('assigned_date', models.DateTimeField(auto_now_add=True, verbose_name='Assigned Date')), + ('deadline_date', models.DateTimeField(help_text='Deadline for agency to submit candidates', verbose_name='Deadline Date')), + ('is_active', models.BooleanField(default=True, verbose_name='Is Active')), + ('status', models.CharField(choices=[('ACTIVE', 'Active'), ('COMPLETED', 'Completed'), ('EXPIRED', 'Expired'), ('CANCELLED', 'Cancelled')], default='ACTIVE', max_length=20, verbose_name='Status')), + ('deadline_extended', models.BooleanField(default=False, verbose_name='Deadline Extended')), + ('original_deadline', models.DateTimeField(blank=True, help_text='Original deadline before extensions', null=True, verbose_name='Original Deadline')), + ('admin_notes', models.TextField(blank=True, help_text='Internal notes about this assignment', verbose_name='Admin Notes')), + ('agency', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='job_assignments', to='recruitment.hiringagency', verbose_name='Agency')), + ('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='agency_assignments', to='recruitment.jobposting', verbose_name='Job')), + ], + options={ + 'verbose_name': 'Agency Job Assignment', + 'verbose_name_plural': 'Agency Job Assignments', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='JobPostingImage', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('post_image', models.ImageField(upload_to='post/', validators=[recruitment.validators.validate_image_size])), + ('job', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='post_images', to='recruitment.jobposting')), + ], + ), + migrations.CreateModel( + name='Message', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), + ('subject', models.CharField(max_length=200, verbose_name='Subject')), + ('content', models.TextField(verbose_name='Message Content')), + ('message_type', models.CharField(choices=[('direct', 'Direct Message'), ('job_related', 'Job Related'), ('system', 'System Notification')], default='direct', max_length=20, verbose_name='Message Type')), + ('is_read', models.BooleanField(default=False, verbose_name='Is Read')), + ('read_at', models.DateTimeField(blank=True, null=True, verbose_name='Read At')), + ('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='recruitment.jobposting', verbose_name='Related Job')), + ('recipient', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='received_messages', to=settings.AUTH_USER_MODEL, verbose_name='Recipient')), + ('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_messages', to=settings.AUTH_USER_MODEL, verbose_name='Sender')), + ], + options={ + 'verbose_name': 'Message', + 'verbose_name_plural': 'Messages', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='Notification', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('message', models.TextField(verbose_name='Notification Message')), + ('notification_type', models.CharField(choices=[('email', 'Email'), ('in_app', 'In-App')], default='email', max_length=20, verbose_name='Notification Type')), + ('status', models.CharField(choices=[('pending', 'Pending'), ('sent', 'Sent'), ('read', 'Read'), ('failed', 'Failed'), ('retrying', 'Retrying')], default='pending', max_length=20, verbose_name='Status')), + ('scheduled_for', models.DateTimeField(help_text='The date and time this notification is scheduled to be sent.', verbose_name='Scheduled Send Time')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('attempts', models.PositiveIntegerField(default=0, verbose_name='Send Attempts')), + ('last_error', models.TextField(blank=True, verbose_name='Last Error Message')), + ('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL, verbose_name='Recipient')), + ], + options={ + 'verbose_name': 'Notification', + 'verbose_name_plural': 'Notifications', + 'ordering': ['-scheduled_for', '-created_at'], + }, + ), + migrations.CreateModel( + name='Person', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), + ('first_name', models.CharField(max_length=255, verbose_name='First Name')), + ('last_name', models.CharField(max_length=255, verbose_name='Last Name')), + ('middle_name', models.CharField(blank=True, max_length=255, null=True, verbose_name='Middle Name')), + ('email', models.EmailField(db_index=True, max_length=254, unique=True, verbose_name='Email')), + ('phone', models.CharField(blank=True, max_length=20, null=True, verbose_name='Phone')), + ('date_of_birth', models.DateField(blank=True, null=True, verbose_name='Date of Birth')), + ('gender', models.CharField(blank=True, choices=[('M', 'Male'), ('F', 'Female')], max_length=1, null=True, verbose_name='Gender')), + ('gpa', models.DecimalField(blank=True, decimal_places=2, max_digits=3, null=True, verbose_name='GPA')), + ('nationality', django_countries.fields.CountryField(blank=True, max_length=2, null=True, verbose_name='Nationality')), + ('address', models.TextField(blank=True, null=True, verbose_name='Address')), + ('profile_image', models.ImageField(blank=True, null=True, upload_to='profile_pic/', validators=[recruitment.validators.validate_image_size], verbose_name='Profile Image')), + ('linkedin_profile', models.URLField(blank=True, null=True, verbose_name='LinkedIn Profile URL')), + ('agency', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='recruitment.hiringagency', verbose_name='Hiring Agency')), + ('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='person_profile', to=settings.AUTH_USER_MODEL, verbose_name='User Account')), + ], + options={ + 'verbose_name': 'Person', + 'verbose_name_plural': 'People', + }, + ), + migrations.AddField( + model_name='application', + name='person', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='applications', to='recruitment.person', verbose_name='Person'), + ), + migrations.CreateModel( + name='ScheduledInterview', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), + ('interview_date', models.DateField(db_index=True, verbose_name='Interview Date')), + ('interview_time', models.TimeField(verbose_name='Interview Time')), + ('interview_type', models.CharField(choices=[('Remote', 'Remote (e.g., Zoom, Google Meet)'), ('Onsite', 'In-Person (Physical Location)')], default='Remote', max_length=20)), + ('status', models.CharField(choices=[('scheduled', 'Scheduled'), ('confirmed', 'Confirmed'), ('cancelled', 'Cancelled'), ('completed', 'Completed')], db_index=True, default='scheduled', max_length=20)), + ('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.application')), + ('interview', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interview', to='recruitment.interview', verbose_name='Interview/Meeting')), + ('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.jobposting')), + ('participants', models.ManyToManyField(blank=True, to='recruitment.participants')), + ('schedule', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='interviews', to='recruitment.bulkinterviewtemplate')), + ('system_users', models.ManyToManyField(blank=True, related_name='attended_interviews', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='SharedFormTemplate', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), + ('is_public', models.BooleanField(default=False, help_text='Whether this template is publicly available')), + ('shared_with', models.ManyToManyField(blank=True, related_name='shared_templates', to=settings.AUTH_USER_MODEL)), + ('template', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='recruitment.formtemplate')), + ], + options={ + 'verbose_name': 'Shared Form Template', + 'verbose_name_plural': 'Shared Form Templates', + }, + ), + migrations.CreateModel( + name='IntegrationLog', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), + ('action', models.CharField(choices=[('REQUEST', 'Request'), ('RESPONSE', 'Response'), ('ERROR', 'Error'), ('SYNC', 'Sync'), ('CREATE_JOB', 'Create Job'), ('UPDATE_JOB', 'Update Job')], max_length=20, verbose_name='Action')), + ('endpoint', models.CharField(blank=True, max_length=255, verbose_name='Endpoint')), + ('method', models.CharField(blank=True, max_length=50, verbose_name='HTTP Method')), + ('request_data', models.JSONField(blank=True, null=True, verbose_name='Request Data')), + ('response_data', models.JSONField(blank=True, null=True, verbose_name='Response Data')), + ('status_code', models.CharField(blank=True, max_length=10, verbose_name='Status Code')), + ('error_message', models.TextField(blank=True, verbose_name='Error Message')), + ('ip_address', models.GenericIPAddressField(verbose_name='IP Address')), + ('user_agent', models.CharField(blank=True, max_length=255, verbose_name='User Agent')), + ('processing_time', models.FloatField(blank=True, null=True, verbose_name='Processing Time (seconds)')), + ('source', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='integration_logs', to='recruitment.source', verbose_name='Source')), + ], + options={ + 'verbose_name': 'Integration Log', + 'verbose_name_plural': 'Integration Logs', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='TrainingMaterial', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), + ('title', models.CharField(max_length=255, verbose_name='Title')), + ('content', django_ckeditor_5.fields.CKEditor5Field(blank=True, verbose_name='Content')), + ('video_link', models.URLField(blank=True, verbose_name='Video Link')), + ('file', models.FileField(blank=True, upload_to='training_materials/', verbose_name='File')), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Created by')), + ], + options={ + 'verbose_name': 'Training Material', + 'verbose_name_plural': 'Training Materials', + }, + ), + migrations.CreateModel( + name='AgencyAccessLink', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('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')), + ('unique_token', models.CharField(editable=False, max_length=64, unique=True, verbose_name='Unique Token')), + ('access_password', models.CharField(help_text='Password for agency access', max_length=32, verbose_name='Access Password')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('expires_at', models.DateTimeField(help_text='When this access link expires', verbose_name='Expires At')), + ('last_accessed', models.DateTimeField(blank=True, null=True, verbose_name='Last Accessed')), + ('access_count', models.PositiveIntegerField(default=0, verbose_name='Access Count')), + ('is_active', models.BooleanField(default=True, verbose_name='Is Active')), + ('assignment', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='access_link', to='recruitment.agencyjobassignment', verbose_name='Assignment')), + ], + options={ + 'verbose_name': 'Agency Access Link', + 'verbose_name_plural': 'Agency Access Links', + 'ordering': ['-created_at'], + 'indexes': [models.Index(fields=['unique_token'], name='recruitment_unique__f91e76_idx'), models.Index(fields=['expires_at'], name='recruitment_expires_954ed9_idx'), models.Index(fields=['is_active'], name='recruitment_is_acti_4b0804_idx')], + }, + ), + migrations.CreateModel( + name='Document', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), + ('object_id', models.PositiveIntegerField(verbose_name='Object ID')), + ('file', models.FileField(upload_to='documents/%Y/%m/', validators=[recruitment.validators.validate_image_size], verbose_name='Document File')), + ('document_type', models.CharField(choices=[('resume', 'Resume'), ('cover_letter', 'Cover Letter'), ('certificate', 'Certificate'), ('id_document', 'ID Document'), ('passport', 'Passport'), ('education', 'Education Document'), ('experience', 'Experience Letter'), ('other', 'Other')], default='other', max_length=20, verbose_name='Document Type')), + ('description', models.CharField(blank=True, max_length=200, verbose_name='Description')), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype', verbose_name='Content Type')), + ('uploaded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Uploaded By')), + ], + options={ + 'verbose_name': 'Document', + 'verbose_name_plural': 'Documents', + 'ordering': ['-created_at'], + 'indexes': [models.Index(fields=['content_type', 'object_id', 'document_type', 'created_at'], name='recruitment_content_547650_idx')], + }, + ), + migrations.CreateModel( + name='FieldResponse', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), + ('value', models.JSONField(blank=True, help_text='Response value (stored as JSON)', null=True)), + ('uploaded_file', models.FileField(blank=True, null=True, upload_to='form_uploads/')), + ('field', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='responses', to='recruitment.formfield')), + ('submission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='responses', to='recruitment.formsubmission')), + ], + options={ + 'verbose_name': 'Field Response', + 'verbose_name_plural': 'Field Responses', + 'indexes': [models.Index(fields=['submission'], name='recruitment_submiss_474130_idx'), models.Index(fields=['field'], name='recruitment_field_i_097e5b_idx')], + }, + ), + migrations.AddIndex( + model_name='formsubmission', + index=models.Index(fields=['submitted_at'], name='recruitment_submitt_7946c8_idx'), + ), + migrations.AddIndex( + model_name='formtemplate', + index=models.Index(fields=['created_at'], name='recruitment_created_c21775_idx'), + ), + migrations.AddIndex( + model_name='formtemplate', + index=models.Index(fields=['is_active'], name='recruitment_is_acti_ae5efb_idx'), + ), + migrations.AddIndex( + model_name='agencyjobassignment', + index=models.Index(fields=['agency', 'status'], name='recruitment_agency__491a54_idx'), + ), + migrations.AddIndex( + model_name='agencyjobassignment', + index=models.Index(fields=['job', 'status'], name='recruitment_job_id_d798a8_idx'), + ), + migrations.AddIndex( + model_name='agencyjobassignment', + index=models.Index(fields=['deadline_date'], name='recruitment_deadlin_57d3b4_idx'), + ), + migrations.AddIndex( + model_name='agencyjobassignment', + index=models.Index(fields=['is_active'], name='recruitment_is_acti_93b919_idx'), + ), + migrations.AlterUniqueTogether( + name='agencyjobassignment', + unique_together={('agency', 'job')}, + ), + migrations.AddIndex( + model_name='message', + index=models.Index(fields=['sender', 'created_at'], name='recruitment_sender__49d984_idx'), + ), + migrations.AddIndex( + model_name='message', + index=models.Index(fields=['recipient', 'is_read', 'created_at'], name='recruitment_recipie_af0e6d_idx'), + ), + migrations.AddIndex( + model_name='message', + index=models.Index(fields=['job', 'created_at'], name='recruitment_job_id_18f813_idx'), + ), + migrations.AddIndex( + model_name='message', + index=models.Index(fields=['message_type', 'created_at'], name='recruitment_message_f25659_idx'), + ), + migrations.AddIndex( + model_name='notification', + index=models.Index(fields=['status', 'scheduled_for'], name='recruitment_status_0ebbe4_idx'), + ), + migrations.AddIndex( + model_name='notification', + index=models.Index(fields=['recipient'], name='recruitment_recipie_eadf4c_idx'), + ), + migrations.AddIndex( + model_name='person', + index=models.Index(fields=['email'], name='recruitment_email_0b1ab1_idx'), + ), + migrations.AddIndex( + model_name='person', + index=models.Index(fields=['first_name', 'last_name'], name='recruitment_first_n_739de5_idx'), + ), + migrations.AddIndex( + model_name='person', + index=models.Index(fields=['created_at'], name='recruitment_created_33495a_idx'), + ), + migrations.AddIndex( + model_name='application', + index=models.Index(fields=['person', 'job'], name='recruitment_person__34355c_idx'), + ), + migrations.AddIndex( + model_name='application', + index=models.Index(fields=['stage'], name='recruitment_stage_52c2d1_idx'), + ), + migrations.AddIndex( + model_name='application', + index=models.Index(fields=['created_at'], name='recruitment_created_80633f_idx'), + ), + migrations.AddIndex( + model_name='application', + index=models.Index(fields=['person', 'stage', 'created_at'], name='recruitment_person__8715ec_idx'), + ), + migrations.AlterUniqueTogether( + name='application', + unique_together={('person', 'job')}, + ), + migrations.AddIndex( + model_name='scheduledinterview', + index=models.Index(fields=['job', 'status'], name='recruitment_job_id_f09e22_idx'), + ), + migrations.AddIndex( + model_name='scheduledinterview', + index=models.Index(fields=['interview_date', 'interview_time'], name='recruitment_intervi_7f5877_idx'), + ), + migrations.AddIndex( + model_name='scheduledinterview', + index=models.Index(fields=['application', 'job'], name='recruitment_applica_927561_idx'), + ), + migrations.AddIndex( + model_name='jobposting', + index=models.Index(fields=['status', 'created_at', 'title'], name='recruitment_status_8b77aa_idx'), + ), + migrations.AddIndex( + model_name='jobposting', + index=models.Index(fields=['slug'], name='recruitment_slug_004045_idx'), + ), + ] diff --git a/recruitment/migrations/0002_alter_person_user.py b/recruitment/migrations/0002_alter_person_user.py new file mode 100644 index 0000000..0010b0c --- /dev/null +++ b/recruitment/migrations/0002_alter_person_user.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2.7 on 2025-11-28 10:24 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='person', + name='user', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='person_profile', to=settings.AUTH_USER_MODEL, verbose_name='User Account'), + ), + ] diff --git a/recruitment/migrations/__init__.py b/recruitment/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/recruitment/models.py b/recruitment/models.py index a8975a2..df164c6 100644 --- a/recruitment/models.py +++ b/recruitment/models.py @@ -22,6 +22,18 @@ from django.db.models import F, Value, IntegerField, CharField from django.db.models.functions import Coalesce, Cast from django.db.models.fields.json import KeyTransform, KeyTextTransform +class EmailContent(models.Model): + subject = models.CharField(max_length=255, verbose_name=_("Subject")) + message = CKEditor5Field(verbose_name=_("Message Body")) + + class Meta: + verbose_name = _("Email Content") + verbose_name_plural = _("Email Contents") + + def __str__(self): + return self.subject + + class CustomUser(AbstractUser): """Custom user model extending AbstractUser""" @@ -468,6 +480,10 @@ class JobPosting(Base): vacancy_fill_rate = 0.0 return vacancy_fill_rate + + def has_already_applied_to_this_job(self, person): + """Check if a given person has already applied to this job.""" + return self.applications.filter(person=person).exists() class JobPostingImage(models.Model): @@ -518,7 +534,7 @@ class Person(Base): # Optional linking to user account user = models.OneToOneField( User, - on_delete=models.SET_NULL, + on_delete=models.CASCADE, related_name="person_profile", verbose_name=_("User Account"), null=True, @@ -544,6 +560,17 @@ class Person(Base): verbose_name=_("Hiring Agency"), ) + def delete(self, *args, **kwargs): + """ + Custom delete method to ensure the associated User account is also deleted. + """ + # 1. Delete the associated User account first, if it exists + if self.user: + self.user.delete() + + # 2. Call the original delete method for the Person instance + super().delete(*args, **kwargs) + class Meta: verbose_name = _("Person") verbose_name_plural = _("People") @@ -582,6 +609,8 @@ class Person(Base): content_type = ContentType.objects.get_for_model(self.__class__) return Document.objects.filter(content_type=content_type, object_id=self.id) + + class Application(Base): @@ -2048,6 +2077,17 @@ class HiringAgency(Base): verbose_name_plural = _("Hiring Agencies") ordering = ["name"] + def delete(self, *args, **kwargs): + """ + Custom delete method to ensure the associated User account is also deleted. + """ + # 1. Delete the associated User account first, if it exists + if self.user: + self.user.delete() + + # 2. Call the original delete method for the Agency instance + super().delete(*args, **kwargs) + class AgencyJobAssignment(Base): """Assigns specific jobs to agencies with limits and deadlines""" diff --git a/recruitment/tasks.py b/recruitment/tasks.py index 4716133..6df188a 100644 --- a/recruitment/tasks.py +++ b/recruitment/tasks.py @@ -1069,9 +1069,11 @@ def send_bulk_email_task(subject, message, recipient_list,attachments=None,sende # Since the async caller sends one task per recipient, total_recipients should be 1. for recipient in recipient_list: # The 'message' is the custom message specific to this recipient. - if _task_send_individual_email(subject, message, recipient, attachments,sender,job): + r=_task_send_individual_email(subject, message, recipient, attachments,sender,job) + print(f"Email send result for {recipient}: {r}") + if r: successful_sends += 1 - + print(f"successful_sends: {successful_sends} out of {total_recipients}") if successful_sends > 0: logger.info(f"Bulk email task completed successfully. Sent to {successful_sends}/{total_recipients} recipients.") return { diff --git a/recruitment/urls.py b/recruitment/urls.py index 36fdf30..07d06db 100644 --- a/recruitment/urls.py +++ b/recruitment/urls.py @@ -440,7 +440,7 @@ urlpatterns = [ name="applicant_portal_dashboard", ), path( - "applications/applications//", + "applications/application//", views.applicant_application_detail, name="applicant_application_detail", ), diff --git a/recruitment/views.py b/recruitment/views.py index 5db6769..8241d1c 100644 --- a/recruitment/views.py +++ b/recruitment/views.py @@ -630,7 +630,7 @@ def job_detail(request, slug): # New statistics "avg_match_score": avg_match_score, "high_potential_count": high_potential_count, - "high_potential_ratio": high_potential_ratio, + # "high_potential_ratio": high_potential_ratio, "avg_t2i_days": avg_t2i_days, "avg_t_in_exam_days": avg_t_in_exam_days, "linkedin_content_form": linkedin_content_form, @@ -1196,12 +1196,26 @@ def application_submit_form(request, template_slug): """Display the form as a step-by-step wizard""" if not request.user.is_authenticated: return redirect("application_signup",slug=template_slug) + job = get_object_or_404(JobPosting, form_template__slug=template_slug) + if request.user.user_type == "candidate": + person=request.user.person_profile + if job.has_already_applied_to_this_job(person): + messages.error( + request, + _( + "You have already applied to this job: Multiple applications are not allowed." + ), + ) + return redirect("job_application_detail", slug=job.slug) + + template = get_object_or_404(FormTemplate, slug=template_slug, is_active=True) stage = template.stages.filter(name="Contact Information") - job_id = template.job.internal_job_id - job = template.job + # job_id = template.job.internal_job_id + # job = template. + job_id=job.internal_job_id is_limit_exceeded = job.is_application_limit_reached if is_limit_exceeded: messages.error( @@ -1235,7 +1249,7 @@ def application_submit_form(request, template_slug): @require_POST def application_submit(request, template_slug): """Handle form submission""" - if not request.user.is_authenticated :# or request.user.user_type != "candidate": + if not request.user.is_authenticated :# or request.user.user_type != "application": return JsonResponse({"success": False, "message": "Unauthorized access."}) template = get_object_or_404(FormTemplate, slug=template_slug) job = template.job @@ -1437,10 +1451,10 @@ def form_submission_details(request, template_id, slug): # def _handle_get_request(request, slug, job): # """ -# Handles GET requests, setting up forms and restoring candidate selections +# Handles GET requests, setting up forms and restoring application selections # from the session for persistence. # """ -# SESSION_KEY = f"schedule_candidate_ids_{slug}" +# SESSION_KEY = f"schedule_application_ids_{slug}" # form = BulkInterviewTemplateForm(slug=slug) # # break_formset = BreakTimeFormSet(prefix='breaktime') @@ -1449,11 +1463,11 @@ def form_submission_details(request, template_id, slug): # # 1. Capture IDs from HTMX request and store in session (when first clicked) # if "HX-Request" in request.headers: -# candidate_ids = request.GET.getlist("candidate_ids") +# application_ids = request.GET.getlist("application_ids") -# if candidate_ids: -# request.session[SESSION_KEY] = candidate_ids -# selected_ids = candidate_ids +# if application_ids: +# request.session[SESSION_KEY] = application_ids +# selected_ids = application_ids # # 2. Restore IDs from session (on refresh or navigation) # if not selected_ids: @@ -1461,9 +1475,9 @@ def form_submission_details(request, template_id, slug): # # 3. Use the list of IDs to initialize the form # if selected_ids: -# candidates_to_load = Application.objects.filter(pk__in=selected_ids) -# print(candidates_to_load) -# form.initial["applications"] = candidates_to_load +# applications_to_load = Application.objects.filter(pk__in=selected_ids) +# print(applications_to_load) +# form.initial["applications"] = applications_to_load # return render( # request, @@ -1554,7 +1568,7 @@ def form_submission_details(request, template_id, slug): # "buffer_time": buffer_time, # "break_start_time": break_start_time.isoformat() if break_start_time else None, # "break_end_time": break_end_time.isoformat() if break_end_time else None, -# "candidate_ids": [c.id for c in applications], +# "application_ids": [c.id for c in applications], # "schedule_interview_type":schedule_interview_type # } @@ -1596,7 +1610,7 @@ def form_submission_details(request, template_id, slug): # """ # SESSION_DATA_KEY = "interview_schedule_data" -# SESSION_ID_KEY = f"schedule_candidate_ids_{slug}" +# SESSION_ID_KEY = f"schedule_application_ids_{slug}" # # 1. Get schedule data from session # schedule_data = request.session.get(SESSION_DATA_KEY) @@ -1633,22 +1647,22 @@ def form_submission_details(request, template_id, slug): # if SESSION_DATA_KEY in request.session: del request.session[SESSION_DATA_KEY] # return redirect("schedule_interviews", slug=slug) -# # 3. Setup candidates and get slots -# candidates = Application.objects.filter(id__in=schedule_data["candidate_ids"]) -# schedule.applications.set(candidates) +# # 3. Setup applications and get slots +# applications = Application.objects.filter(id__in=schedule_data["application_ids"]) +# schedule.applications.set(applications) # available_slots = get_available_time_slots(schedule) # # 4. Handle Remote/Onsite logic # if schedule_data.get("schedule_interview_type") == 'Remote': # # ... (Remote logic remains unchanged) # queued_count = 0 -# for i, candidate in enumerate(candidates): +# for i, application in enumerate(applications): # if i < len(available_slots): # slot = available_slots[i] # async_task( # "recruitment.tasks.create_interview_and_meeting", -# candidate.pk, job.pk, schedule.pk, slot["date"], slot["time"], schedule.interview_duration, +# application.pk, job.pk, schedule.pk, slot["date"], slot["time"], schedule.interview_duration, # ) # queued_count += 1 @@ -1681,8 +1695,8 @@ def form_submission_details(request, template_id, slug): # try: -# # 1. Iterate over candidates and create a NEW Location object for EACH -# for i, candidate in enumerate(candidates): +# # 1. Iterate over applications and create a NEW Location object for EACH +# for i, application in enumerate(applications): # if i < len(available_slots): # slot = available_slots[i] @@ -1702,7 +1716,7 @@ def form_submission_details(request, template_id, slug): # # 2. Create the ScheduledInterview, linking the unique location # ScheduledInterview.objects.create( -# application=candidate, +# application=application, # job=job, # schedule=schedule, # interview_date=slot['date'], @@ -1712,7 +1726,7 @@ def form_submission_details(request, template_id, slug): # messages.success( # request, -# f"Onsite schedule interviews created successfully for {len(candidates)} candidates." +# f"Onsite schedule interviews created successfully for {len(applications)} applications." # ) # # Clear session data keys upon successful completion @@ -1757,7 +1771,7 @@ def form_submission_details(request, template_id, slug): @staff_user_required def applications_screening_view(request, slug): """ - Manage candidate tiers and stage transitions + Manage application tiers and stage transitions """ job = get_object_or_404(JobPosting, slug=slug) applications = job.screening_applications @@ -1839,7 +1853,7 @@ def applications_screening_view(request, slug): @staff_user_required def applications_exam_view(request, slug): """ - Manage candidate tiers and stage transitions + Manage application tiers and stage transitions """ job = get_object_or_404(JobPosting, slug=slug) context = {"job": job, "applications": job.exam_applications, "current_stage": "Exam"} @@ -1905,7 +1919,7 @@ def application_update_status(request, slug): job = get_object_or_404(JobPosting, slug=slug) mark_as = request.POST.get("mark_as") if mark_as != "----------": - application_ids = request.POST.getlist("candidate_ids") + application_ids = request.POST.getlist("application_ids") if c := Application.objects.filter(pk__in=application_ids): if mark_as == "Exam": @@ -1916,7 +1930,7 @@ def application_update_status(request, slug): offer_date=None, hired_date=None, stage=mark_as, - applicant_status="Candidate" + applicant_status="application" if mark_as in ["Exam", "Interview","Document Review", "Offer"] else "Applicant", ) @@ -1927,7 +1941,7 @@ def application_update_status(request, slug): stage=mark_as, offer_date=None, hired_date=None, - applicant_status="Candidate" + applicant_status="application" if mark_as in ["Exam", "Interview", "Document Review","Offer"] else "Applicant", ) @@ -1937,7 +1951,7 @@ def application_update_status(request, slug): stage=mark_as, offer_date=None, hired_date=None, - applicant_status="Candidate" + applicant_status="application" if mark_as in ["Exam", "Interview", "Document Review","Offer"] else "Applicant", ) @@ -1947,7 +1961,7 @@ def application_update_status(request, slug): stage=mark_as, offer_date=timezone.now(), hired_date=None, - applicant_status="Candidate" + applicant_status="application" if mark_as in ["Exam", "Interview", "Document Review","Offer"] else "Applicant", ) @@ -1956,7 +1970,7 @@ def application_update_status(request, slug): c.update( stage=mark_as, hired_date=timezone.now(), - applicant_status="Candidate" + applicant_status="application" if mark_as in ["Exam", "Interview", "Offer"] else "Applicant", ) @@ -1968,7 +1982,7 @@ def application_update_status(request, slug): interview_date=None, offer_date=None, hired_date=None, - applicant_status="Candidate" + applicant_status="application" if mark_as in ["Exam", "Interview", "Offer"] else "Applicant", ) @@ -1994,11 +2008,11 @@ def applications_interview_view(request, slug): @staff_user_required def applications_document_review_view(request, slug): """ - Document review view for candidates after interview stage and before offer stage + Document review view for applications after interview stage and before offer stage """ job = get_object_or_404(JobPosting, slug=slug) - # Get candidates from Interview stage who need document review + # Get applications from Interview stage who need document review applications = job.document_review_applications.select_related('person') # Get search query for filtering search_query = request.GET.get('q', '') @@ -2019,9 +2033,9 @@ def applications_document_review_view(request, slug): # @staff_user_required -# def reschedule_meeting_for_application(request, slug, candidate_id, meeting_id): +# def reschedule_meeting_for_application(request, slug, application_id, meeting_id): # job = get_object_or_404(JobPosting, slug=slug) -# candidate = get_object_or_404(Application, pk=candidate_id) +# application = get_object_or_404(Application, pk=application_id) # meeting = get_object_or_404(ZoomMeetingDetails, pk=meeting_id) # form = ZoomMeetingForm(instance=meeting) @@ -2039,7 +2053,7 @@ def applications_document_review_view(request, slug): # return redirect( # "reschedule_meeting_for_application", # slug=job.slug, -# candidate_id=candidate_id, +# application_id=candidate_id, # meeting_id=meeting_id, # ) @@ -2140,62 +2154,62 @@ def applications_document_review_view(request, slug): # @staff_user_required # def interview_calendar_view(request, slug): - job = get_object_or_404(JobPosting, slug=slug) + # job = get_object_or_404(JobPosting, slug=slug) - # Get all scheduled interviews for this job - scheduled_interviews = ScheduledInterview.objects.filter(job=job).select_related( - "applicaton", "zoom_meeting" - ) + # # Get all scheduled interviews for this job + # scheduled_interviews = ScheduledInterview.objects.filter(job=job).select_related( + # "applicaton", "zoom_meeting" + # ) - # Convert interviews to calendar events - events = [] - for interview in scheduled_interviews: - # Create start datetime - start_datetime = datetime.combine( - interview.interview_date, interview.interview_time - ) + # # Convert interviews to calendar events + # events = [] + # for interview in scheduled_interviews: + # # Create start datetime + # start_datetime = datetime.combine( + # interview.interview_date, interview.interview_time + # ) - # Calculate end datetime based on interview duration - duration = interview.zoom_meeting.duration if interview.zoom_meeting else 60 - end_datetime = start_datetime + timedelta(minutes=duration) + # # Calculate end datetime based on interview duration + # duration = interview.zoom_meeting.duration if interview.zoom_meeting else 60 + # end_datetime = start_datetime + timedelta(minutes=duration) - # Determine event color based on status - color = "#00636e" # Default color - if interview.status == "confirmed": - color = "#00a86b" # Green for confirmed - elif interview.status == "cancelled": - color = "#e74c3c" # Red for cancelled - elif interview.status == "completed": - color = "#95a5a6" # Gray for completed + # # Determine event color based on status + # color = "#00636e" # Default color + # if interview.status == "confirmed": + # color = "#00a86b" # Green for confirmed + # elif interview.status == "cancelled": + # color = "#e74c3c" # Red for cancelled + # elif interview.status == "completed": + # color = "#95a5a6" # Gray for completed - events.append( - { - "title": f"Interview: {interview.candidate.name}", - "start": start_datetime.isoformat(), - "end": end_datetime.isoformat(), - "url": f"{request.path}interview/{interview.id}/", - "color": color, - "extendedProps": { - "candidate": interview.candidate.name, - "email": interview.candidate.email, - "status": interview.status, - "meeting_id": interview.zoom_meeting.meeting_id - if interview.zoom_meeting - else None, - "join_url": interview.zoom_meeting.join_url - if interview.zoom_meeting - else None, - }, - } - ) + # events.append( + # { + # "title": f"Interview: {interview.candidate.name}", + # "start": start_datetime.isoformat(), + # "end": end_datetime.isoformat(), + # "url": f"{request.path}interview/{interview.id}/", + # "color": color, + # "extendedProps": { + # "candidate": interview.candidate.name, + # "email": interview.candidate.email, + # "status": interview.status, + # "meeting_id": interview.zoom_meeting.meeting_id + # if interview.zoom_meeting + # else None, + # "join_url": interview.zoom_meeting.join_url + # if interview.zoom_meeting + # else None, + # }, + # } + # ) - context = { - "job": job, - "events": events, - "calendar_color": "#00636e", - } + # context = { + # "job": job, + # "events": events, + # "calendar_color": "#00636e", + # } - return render(request, "recruitment/interview_calendar.html", context) + # return render(request, "recruitment/interview_calendar.html", context) # @staff_user_required @@ -3299,6 +3313,7 @@ def agency_create(request): if request.method == "POST": form = HiringAgencyForm(request.POST) if form.is_valid(): + agency = form.save() messages.success(request, f'Agency "{agency.name}" created successfully!') return redirect("agency_detail", slug=agency.slug) @@ -3320,27 +3335,27 @@ def agency_detail(request, slug): """View details of a specific hiring agency""" agency = get_object_or_404(HiringAgency, slug=slug) - # Get candidates associated with this agency - candidates = Application.objects.filter(hiring_agency=agency).order_by( + # Get applications associated with this agency + applications = Application.objects.filter(hiring_agency=agency).order_by( "-created_at" ) # Statistics - total_candidates = candidates.count() - active_candidates = candidates.filter( + total_applications = applications.count() + active_applications = applications.filter( stage__in=["Applied", "Screening", "Exam", "Interview", "Offer"] ).count() - hired_candidates = candidates.filter(stage="Hired").count() - rejected_candidates = candidates.filter(stage="Rejected").count() + hired_applications = applications.filter(stage="Hired").count() + rejected_applications = applications.filter(stage="Rejected").count() job_assignments=AgencyJobAssignment.objects.filter(agency=agency) print(job_assignments) context = { "agency": agency, - "candidates": candidates[:10], # Show recent 10 candidates - "total_candidates": total_candidates, - "active_candidates": active_candidates, - "hired_candidates": hired_candidates, - "rejected_candidates": rejected_candidates, + "applications": applications[:10], # Show recent 10 applications + "total_applications": total_applications, + "active_applications": active_applications, + "hired_applications": hired_applications, + "rejected_applications": rejected_applications, "generated_password": agency.generated_password if agency.generated_password else None, @@ -4078,19 +4093,19 @@ def applicant_portal_dashboard(request): if not request.user.is_authenticated: return redirect("account_login") - # Get candidate profile (Person record) + # Get application profile (Person record) try: applicant = request.user.person_profile except: - messages.error(request, "No candidate profile found.") + messages.error(request, "No application profile found.") return redirect("account_login") - # Get candidate's applications with related job data + # Get application's applications with related job data applications = Application.objects.filter( person=applicant ).select_related('job').order_by('-created_at') - # Get candidate's documents using the Person documents property + # Get application's documents using the Person documents property documents = applicant.documents.order_by('-created_at') # Add password change form for modal @@ -4116,19 +4131,19 @@ def applicant_application_detail(request, slug): if not request.user.is_authenticated: return redirect("account_login") - # Get candidate profile (Person record) + # Get application profile (Person record) agency = getattr(request.user,"agency_profile",None) if agency: - candidate = get_object_or_404(Application,slug=slug) + application = get_object_or_404(Application,slug=slug) # if Application.objects.filter(person=candidate,hirin).exists() else: try: - candidate = request.user.person_profile + applicant = request.user.person_profile except: - messages.error(request, "No candidate profile found.") + messages.error(request, "No applicant profile found.") return redirect("account_login") - # Get the specific application and verify it belongs to this candidate + # Get the specific application and verify it belongs to this applicant application = get_object_or_404( Application.objects.select_related( 'job', 'person' @@ -4136,7 +4151,7 @@ def applicant_application_detail(request, slug): 'scheduled_interviews' # Only prefetch interviews, not documents (Generic FK) ), slug=slug, - person=candidate.person if agency else candidate + person=application.person if agency else applicant ) # Get AI analysis data if available @@ -4155,7 +4170,7 @@ def applicant_application_detail(request, slug): context = { "application": application, - "candidate": candidate, + "applicant": applicant, "ai_analysis": ai_analysis, "interviews": interviews, "documents": documents, @@ -4319,11 +4334,11 @@ def agency_portal_submit_application_page(request, slug): if request.method == "POST": form = ApplicationForm(request.POST, request.FILES,current_agency=current_agency,current_job=current_job) if form.is_valid(): - candidate = form.save(commit=False) + application = form.save(commit=False) - candidate.hiring_source = "AGENCY" - candidate.hiring_agency = assignment.agency - candidate.save() + application.hiring_source = "AGENCY" + application.hiring_agency = assignment.agency + application.save() assignment.increment_submission_count() return redirect("agency_portal_dashboard") @@ -4339,12 +4354,12 @@ def agency_portal_submit_application_page(request, slug): "total_submitted": total_submitted, "job": assignment.job, } - return render(request, "recruitment/agency_portal_submit_candidate.html", context) + return render(request, "recruitment/agency_portal_submit_application.html", context) @agency_user_required def agency_portal_submit_application(request): - """Handle candidate submission via AJAX (for embedded form)""" + """Handle application submission via AJAX (for embedded form)""" assignment_id = request.session.get("agency_assignment_id") if not assignment_id: return redirect("agency_portal_login") @@ -4353,24 +4368,24 @@ def agency_portal_submit_application(request): AgencyJobAssignment.objects.select_related("agency", "job"), id=assignment_id ) if assignment.is_full: - messages.error(request, "Maximum candidate limit reached for this assignment.") + messages.error(request, "Maximum application limit reached for this assignment.") return redirect("agency_portal_assignment_detail", slug=assignment.slug) # Check if assignment allows submission if not assignment.can_submit: messages.error( request, - "Cannot submit candidates: Assignment is not active, expired, or full.", + "Cannot submit applications: Assignment is not active, expired, or full.", ) return redirect("agency_portal_dashboard") if request.method == "POST": form = AgencyApplicationSubmissionForm(assignment, request.POST, request.FILES) if form.is_valid(): - candidate = form.save(commit=False) - candidate.hiring_source = "AGENCY" - candidate.hiring_agency = assignment.agency - candidate.save() + application = form.save(commit=False) + application.hiring_source = "AGENCY" + application.hiring_agency = assignment.agency + application.save() # Increment the assignment's submitted count assignment.increment_submission_count() @@ -4379,12 +4394,12 @@ def agency_portal_submit_application(request): return JsonResponse( { "success": True, - "message": f"Candidate {candidate.name} submitted successfully!", + "message": f"application {application.name} submitted successfully!", } ) else: messages.success( - request, f"Candidate {candidate.name} submitted successfully!" + request, f"application {application.name} submitted successfully!" ) return redirect("agency_portal_dashboard") else: @@ -4400,10 +4415,10 @@ def agency_portal_submit_application(request): context = { "form": form, "assignment": assignment, - "title": f"Submit Candidate for {assignment.job.title}", - "button_text": "Submit Candidate", + "title": f"Submit application for {assignment.job.title}", + "button_text": "Submit application", } - return render(request, "recruitment/agency_portal_submit_candidate.html", context) + return render(request, "recruitment/agency_portal_submit_application.html", context) def agency_portal_assignment_detail(request, slug): @@ -4445,8 +4460,8 @@ def agency_assignment_detail_agency(request, slug, assignment_id): ) return redirect("agency_portal_dashboard") - # Get candidates submitted by this agency for this job - candidates = Application.objects.filter( + # Get applications submitted by this agency for this job + applications = Application.objects.filter( hiring_agency=assignment.agency, job=assignment.job ).order_by("-created_at") @@ -4456,8 +4471,8 @@ def agency_assignment_detail_agency(request, slug, assignment_id): # Mark messages as read # No messages to mark as read - # Pagination for candidates - paginator = Paginator(candidates, 20) # Show 20 candidates per page + # Pagination for applications + paginator = Paginator(applications, 20) # Show 20 applications per page page_number = request.GET.get("page") page_obj = paginator.get_page(page_number) @@ -4467,12 +4482,12 @@ def agency_assignment_detail_agency(request, slug, assignment_id): message_page_obj = message_paginator.get_page(message_page_number) # Calculate progress ring offset for circular progress indicator - total_candidates = candidates.count() - max_candidates = assignment.max_candidates + total_applications = applications.count() + max_applications = assignment.max_candidates circumference = 326.73 # 2 * π * r where r=52 - if max_candidates > 0: - progress_percentage = total_candidates / max_candidates + if max_applications > 0: + progress_percentage = total_applications / max_applications stroke_dashoffset = circumference - (circumference * progress_percentage) else: stroke_dashoffset = circumference @@ -4481,7 +4496,7 @@ def agency_assignment_detail_agency(request, slug, assignment_id): "assignment": assignment, "page_obj": page_obj, "message_page_obj": message_page_obj, - "total_candidates": total_candidates, + "total_applications": total_applications, "stroke_dashoffset": stroke_dashoffset, } return render(request, "recruitment/agency_portal_assignment_detail.html", context) @@ -4494,8 +4509,8 @@ def agency_assignment_detail_admin(request, slug): AgencyJobAssignment.objects.select_related("agency", "job"), slug=slug ) - # Get candidates submitted by this agency for this job - candidates = Application.objects.filter( + # Get applications submitted by this agency for this job + applications = Application.objects.filter( hiring_agency=assignment.agency, job=assignment.job ).order_by("-created_at") @@ -4507,16 +4522,18 @@ def agency_assignment_detail_admin(request, slug): context = { "assignment": assignment, - "candidates": candidates, + "applications": applications, "access_link": access_link, - "total_candidates": candidates.count(), + "total_applications": applications.count(), } return render(request, "recruitment/agency_assignment_detail.html", context) + +#will check the changes application to appliaction in this function @agency_user_required -def agency_portal_edit_application(request, candidate_id): - """Edit a candidate for agency portal""" +def agency_portal_edit_application(request, application_id): + """Edit a application for agency portal""" assignment_id = request.session.get("agency_assignment_id") if not assignment_id: return redirect("agency_portal_login") @@ -4528,45 +4545,45 @@ def agency_portal_edit_application(request, candidate_id): agency = current_assignment.agency - # Get candidate and verify it belongs to this agency - candidate = get_object_or_404(Application, id=candidate_id, hiring_agency=agency) + # Get application and verify it belongs to this agency + application = get_object_or_404(Application, id=application_id, hiring_agency=agency) if request.method == "POST": # Handle form submission - candidate.first_name = request.POST.get("first_name", candidate.first_name) - candidate.last_name = request.POST.get("last_name", candidate.last_name) - candidate.email = request.POST.get("email", candidate.email) - candidate.phone = request.POST.get("phone", candidate.phone) - candidate.address = request.POST.get("address", candidate.address) + application.first_name = request.POST.get("first_name", application.first_name) + application.last_name = request.POST.get("last_name", application.last_name) + application.email = request.POST.get("email", application.email) + application.phone = request.POST.get("phone", application.phone) + application.address = request.POST.get("address", application.address) # Handle resume upload if provided if "resume" in request.FILES: - candidate.resume = request.FILES["resume"] + application.resume = request.FILES["resume"] try: - candidate.save() + application.save() messages.success( - request, f"Candidate {candidate.name} updated successfully!" + request, f"Application {application.name} updated successfully!" ) return redirect( "agency_assignment_detail", - slug=candidate.job.agencyjobassignment_set.first().slug, + slug=application.job.agencyjobassignment_set.first().slug, ) except Exception as e: - messages.error(request, f"Error updating candidate: {e}") + messages.error(request, f"Error updating application: {e}") # For GET requests or POST errors, return JSON response for AJAX if request.headers.get("X-Requested-With") == "XMLHttpRequest": return JsonResponse( { "success": True, - "candidate": { - "id": candidate.id, - "first_name": candidate.first_name, - "last_name": candidate.last_name, - "email": candidate.email, - "phone": candidate.phone, - "address": candidate.address, + "application": { + "id": application.id, + "first_name": application.first_name, + "last_name": application.last_name, + "email": application.email, + "phone": application.phone, + "address": application.address, }, } ) @@ -4576,8 +4593,8 @@ def agency_portal_edit_application(request, candidate_id): @agency_user_required -def agency_portal_delete_application(request, candidate_id): - """Delete a candidate for agency portal""" +def agency_portal_delete_application(request, application_id): + """Delete a application for agency portal""" assignment_id = request.session.get("agency_assignment_id") if not assignment_id: return redirect("agency_portal_login") @@ -4589,20 +4606,20 @@ def agency_portal_delete_application(request, candidate_id): agency = current_assignment.agency - # Get candidate and verify it belongs to this agency - candidate = get_object_or_404(Application, id=candidate_id, hiring_agency=agency) + # Get application and verify it belongs to this agency + application = get_object_or_404(Application, id=application_id, hiring_agency=agency) if request.method == "POST": try: - candidate_name = candidate.name - candidate.delete() + application_name = application.name + application.delete() - current_assignment.candidates_submitted -= 1 + current_assignment.candidates_submitted -= 1 #in the modal current_assignment.status = current_assignment.AssignmentStatus.ACTIVE current_assignment.save(update_fields=["candidates_submitted", "status"]) messages.success( - request, f"Candidate {candidate_name} removed successfully!" + request, f"Application {application_name} removed successfully!" ) return JsonResponse({"success": True}) except Exception as e: @@ -4661,7 +4678,7 @@ def message_list(request): "search_query": search_query, } if request.user.user_type != "staff": - return render(request, "messages/candidate_message_list.html", context) + return render(request, "messages/application_message_list.html", context) return render(request, "messages/message_list.html", context) @@ -4687,7 +4704,7 @@ def message_detail(request, message_id): "message": message, } if request.user.user_type != "staff": - return render(request, "messages/candidate_message_detail.html", context) + return render(request, "messages/application_message_detail.html", context) return render(request, "messages/message_detail.html", context) @@ -4743,7 +4760,7 @@ def message_create(request): "form": form, } if request.user.user_type != "staff": - return render(request, "messages/candidate_message_form.html", context) + return render(request, "messages/application_message_form.html", context) return render(request, "messages/message_form.html", context) @@ -4811,7 +4828,7 @@ def message_reply(request, message_id): "parent_message": parent_message, } if request.user.user_type != "staff": - return render(request, "messages/candidate_message_form.html", context) + return render(request, "messages/application_message_form.html", context) return render(request, "messages/message_form.html", context) @@ -4869,35 +4886,47 @@ def message_mark_unread(request, message_id): @login_required def message_delete(request, message_id): - """Delete a message""" + """ + Deletes a message using a POST request, primarily designed for HTMX. + Redirects to the message list on success (either via standard redirect + or HTMX's hx-redirect header). + """ + + # 1. Retrieve the message + # Use select_related to fetch linked objects efficiently for checks/logging message = get_object_or_404( Message.objects.select_related("sender", "recipient"), id=message_id ) - # Check if user has permission to delete this message + # 2. Permission Check + # Only the sender or recipient can delete the message if message.sender != request.user and message.recipient != request.user: messages.error(request, "You don't have permission to delete this message.") + + # HTMX requests should handle redirection via client-side logic (hx-redirect) + if "HX-Request" in request.headers: + # Returning 403 or 400 is ideal, but 200 with an empty body is often accepted + # by HTMX and the message is shown on the next page/refresh. + return HttpResponse(status=403) + + # Standard navigation redirect return redirect("message_list") + # 3. Handle POST Request (Deletion) if request.method == "POST": message.delete() messages.success(request, "Message deleted successfully.") # Handle HTMX requests if "HX-Request" in request.headers: - return HttpResponse(status=200) # HTMX success response - - return redirect("message_list") - - # For GET requests, show confirmation page - context = { - "message": message, - "title": "Delete Message", - "message": f"Are you sure you want to delete this message from {message.sender.get_full_name() or message.sender.username}?", - "cancel_url": reverse("message_detail", kwargs={"message_id": message_id}), - } - return render(request, "messages/message_confirm_delete.html", context) + # 1. Set the HTMX response header for redirection + response = HttpResponse(status=200) + response["HX-Redirect"] = reverse("message_list") # <--- EXPLICIT HEADER + return response + # Standard navigation fallback + return redirect("message_list") + @login_required def api_unread_count(request): @@ -4927,10 +4956,10 @@ def document_upload(request, slug): # Handle Person document upload try: person = get_object_or_404(Person, id=actual_application_id) - # Check if user owns this person (for candidate portal) + # Check if user owns this person (for applicant portal) if request.user.user_type == "candidate": - candidate = request.user.person_profile - if person != candidate: + applicant = request.user.person_profile + if person != applicant: messages.error(request, "You can only upload documents to your own profile.") return JsonResponse({"success": False, "error": "Permission denied"}) except (ValueError, Person.DoesNotExist): @@ -4942,15 +4971,15 @@ def document_upload(request, slug): except (ValueError, Application.DoesNotExist): return JsonResponse({"success": False, "error": "Invalid application ID"}) - # Check if user owns this application (for candidate portal) + # Check if user owns this application (for applicant portal) if request.user.user_type == "candidate": try: - candidate = request.user.person_profile - if application.person != candidate: + applicant = request.user.person_profile + if application.person != applicant: messages.error(request, "You can only upload documents to your own applications.") return JsonResponse({"success": False, "error": "Permission denied"}) except: - messages.error(request, "No candidate profile found.") + messages.error(request, "No applicant profile found.") return JsonResponse({"success": False, "error": "Permission denied"}) if request.method == "POST": @@ -5024,54 +5053,138 @@ def document_upload(request, slug): return redirect("application_detail", slug=application.job.slug) +# @login_required +# def document_delete(request, document_id): +# """Delete a document""" +# document = get_object_or_404(Document, id=document_id) + +# # Check permission - document is now linked to Application or Person via Generic Foreign Key +# if hasattr(document.content_object, "job"): +# # Application document +# if ( +# document.content_object.job.assigned_to != request.user +# and not request.user.is_superuser +# ): +# messages.error( +# request, "You don't have permission to delete this document." +# ) +# return JsonResponse({"success": False, "error": "Permission denied"}) +# job_slug = document.content_object.job.slug +# redirect_url = "applicant_portal_dashboard" if request.user.user_type == "candidate" else "job_detail" +# elif hasattr(document.content_object, "person"): +# # Person document +# if request.user.user_type == "candidate": +# applicant = request.user.person_profile +# if document.content_object != applicant: +# messages.error( +# request, "You can only delete your own documents." +# ) +# return JsonResponse({"success": False, "error": "Permission denied"}) +# redirect_url = "applicant_portal_dashboard" +# else: +# # Handle other content object types +# messages.error(request, "You don't have permission to delete this document.") +# return JsonResponse({"success": False, "error": "Permission denied"}) + +# if request.method == "POST": +# file_name = document.file.name if document.file else "Unknown" +# document.delete() +# messages.success(request, f'Document "{file_name}" deleted successfully!') + +# # Handle AJAX requests +# if request.headers.get("X-Requested-With") == "XMLHttpRequest": +# return JsonResponse( +# {"success": True, "message": "Document deleted successfully!"} +# ) +# else: +# return redirect("application_detail", slug=job_slug) + +# return JsonResponse({"success": False, "error": "Method not allowed"}) + + @login_required def document_delete(request, document_id): - """Delete a document""" + """Delete a document using a POST request (ideal for HTMX).""" document = get_object_or_404(Document, id=document_id) + + # Initialize variables for redirection outside of the complex logic + is_htmx = "HX-Request" in request.headers + + # 1. Permission and Context Initialization + has_permission = False + + content_object = document.content_object + + # Case A: Document linked to an Application (via content_object) + if hasattr(content_object, "job"): + # Staff/Superuser checking against Application's Job assignment + if (content_object.job.assigned_to == request.user) or request.user.is_superuser: + has_permission = True + + # Candidate checking if the Application belongs to them + elif request.user.user_type == "candidate" and content_object.person.user == request.user: + has_permission = True - # Check permission - document is now linked to Application or Person via Generic Foreign Key - if hasattr(document.content_object, "job"): - # Application document - if ( - document.content_object.job.assigned_to != request.user - and not request.user.is_superuser - ): - messages.error( - request, "You don't have permission to delete this document." - ) - return JsonResponse({"success": False, "error": "Permission denied"}) - job_slug = document.content_object.job.slug - redirect_url = "applicant_portal_dashboard" if request.user.user_type == "candidate" else "job_detail" - elif hasattr(document.content_object, "person"): - # Person document + # Determine redirect URL for non-HTMX requests (fallback) if request.user.user_type == "candidate": - candidate = request.user.person_profile - if document.content_object != candidate: - messages.error( - request, "You can only delete your own documents." - ) - return JsonResponse({"success": False, "error": "Permission denied"}) - redirect_url = "applicant_portal_dashboard" + # Assuming you redirect to the candidate's main dashboard after deleting their app document + redirect_view_name = "applicant_portal_dashboard" + else: + # Assuming you redirect to the job detail page for staff + redirect_view_name = "job_detail" + redirect_args = [content_object.job.slug] # Pass the job slug + + # Case B: Document linked directly to a Person (e.g., profile document) + elif hasattr(content_object, "user"): + # Check if the document belongs to the requesting candidate + if request.user.user_type == "candidate" and content_object.user == request.user: + has_permission = True + redirect_view_name = "applicant_portal_dashboard" + # Check if the requesting user is staff/superuser (Staff can delete profile docs) + elif request.user.is_staff or request.user.is_superuser: + has_permission = True + # Staff should probably go to the person's profile detail, but defaulting to a safe spot. + redirect_view_name = "dashboard" + + # Case C: No clear content object linkage or unhandled type else: - # Handle other content object types - messages.error(request, "You don't have permission to delete this document.") - return JsonResponse({"success": False, "error": "Permission denied"}) + has_permission = request.user.is_superuser # Only superuser can delete unlinked docs + + # 2. Enforce Permissions + if not has_permission: + messages.error(request, "Permission denied: You cannot delete this document.") + # Return a 403 response for HTMX/AJAX + return HttpResponse(status=403) + + + # 3. Handle POST Request (Deletion) if request.method == "POST": file_name = document.file.name if document.file else "Unknown" document.delete() messages.success(request, f'Document "{file_name}" deleted successfully!') - # Handle AJAX requests - if request.headers.get("X-Requested-With") == "XMLHttpRequest": - return JsonResponse( - {"success": True, "message": "Document deleted successfully!"} - ) + # --- HTMX / AJAX Response --- + if is_htmx or request.headers.get("X-Requested-With") == "XMLHttpRequest": + # For HTMX, return a 200 OK. The front-end is expected to use hx-swap='outerHTML' + # to remove the element, or hx-redirect to navigate. + return HttpResponse(status=200) + + # --- Standard Navigation Fallback --- else: - return redirect("application_detail", slug=job_slug) - - return JsonResponse({"success": False, "error": "Method not allowed"}) + try: + # Use the calculated redirect view name and arguments + if 'redirect_args' in locals(): + return redirect(redirect_view_name, *redirect_args) + else: + return redirect(redirect_view_name) + except NameError: + # If no specific redirect_view_name was set (e.g., Case C failure) + return redirect("dashboard") + # 4. Handle non-POST (e.g., GET) + # The delete view should not be accessed via GET. + return HttpResponse(status=405) # Method Not Allowed @login_required def document_download(request, document_id): @@ -5093,8 +5206,8 @@ def document_download(request, document_id): elif hasattr(document.content_object, "person"): # Person document if request.user.user_type == "candidate": - candidate = request.user.person_profile - if document.content_object != candidate: + applicant = request.user.person_profile + if document.content_object != applicant: messages.error( request, "You can only download your own documents." ) @@ -5114,15 +5227,7 @@ def document_download(request, document_id): return JsonResponse({"success": False, "error": "File not found"}) - if document.file: - response = HttpResponse( - document.file.read(), content_type="application/octet-stream" - ) - response["Content-Disposition"] = f'attachment; filename="{document.file.name}"' - return response - - return JsonResponse({"success": False, "error": "File not found"}) - + @login_required def portal_logout(request): @@ -5135,31 +5240,31 @@ def portal_logout(request): # Interview Creation Views @staff_user_required -def interview_create_type_selection(request, candidate_slug): - """Show interview type selection page for a candidate""" - candidate = get_object_or_404(Application, slug=candidate_slug) +def interview_create_type_selection(request, application_slug): + """Show interview type selection page for a application""" + application = get_object_or_404(Application, slug=application_slug) - # Validate candidate is in Interview stage - if candidate.stage != 'Interview': - messages.error(request, f"Candidate {candidate.name} is not in Interview stage.") - return redirect('candidate_interview_view', slug=candidate.job.slug) + # Validate application is in Interview stage + if application.stage != 'Interview': + messages.error(request, f"application {application.name} is not in Interview stage.") + return redirect('applications_interview_view', slug=application.job.slug) context = { - 'candidate': candidate, - 'job': candidate.job, + 'application': application, + 'job': application.job, } return render(request, 'interviews/interview_create_type_selection.html', context) @staff_user_required -def interview_create_remote(request, candidate_slug): - """Create remote interview for a candidate""" - application = get_object_or_404(Application, slug=candidate_slug) +def interview_create_remote(request, application_slug): + """Create remote interview for a application""" + application = get_object_or_404(Application, slug=application_slug) - # Validate candidate is in Interview stage - # if candidate.stage != 'Interview': - # messages.error(request, f"Candidate {candidate.name} is not in Interview stage.") - # return redirect('candidate_interview_view', slug=candidate.job.slug) + # Validate application is in Interview stage + # if application.stage != 'Interview': + # messages.error(request, f"application {application.name} is not in Interview stage.") + # return redirect('application_interview_view', slug=application.job.slug) if request.method == 'POST': form = RemoteInterviewForm(request.POST) @@ -5206,7 +5311,7 @@ def interview_create_remote(request, candidate_slug): form = RemoteInterviewForm() form.initial['topic'] = f"Interview for {application.job.title} - {application.name}" context = { - 'candidate': application, + 'application': application, 'job': application.job, 'form': form, } @@ -5214,14 +5319,14 @@ def interview_create_remote(request, candidate_slug): @staff_user_required -def interview_create_onsite(request, candidate_slug): - """Create onsite interview for a candidate""" - candidate = get_object_or_404(Application, slug=candidate_slug) +def interview_create_onsite(request, application_slug): + """Create onsite interview for a application""" + application = get_object_or_404(Application, slug=application_slug) - # Validate candidate is in Interview stage - # if candidate.stage != 'Interview': - # messages.error(request, f"Candidate {candidate.name} is not in Interview stage.") - # return redirect('candidate_interview_view', slug=candidate.job.slug) + # Validate application is in Interview stage + # if application.stage != 'Interview': + # messages.error(request, f"application {application.name} is not in Interview stage.") + # return redirect('application_interview_view', slug=application.job.slug) if request.method == 'POST': from .models import Interview @@ -5235,7 +5340,7 @@ def interview_create_onsite(request, candidate_slug): physical_address=form.cleaned_data["physical_address"], duration=form.cleaned_data["duration"],location_type="Onsite",status="SCHEDULED") - schedule = ScheduledInterview.objects.create(application=candidate,job=candidate.job,interview=interview,interview_date=form.cleaned_data["interview_date"],interview_time=form.cleaned_data["interview_time"]) + schedule = ScheduledInterview.objects.create(application=application,job=application.job,interview=interview,interview_date=form.cleaned_data["interview_date"],interview_time=form.cleaned_data["interview_time"]) # Create ScheduledInterview record # interview = form.save(commit=False) # interview.interview_type = 'ONSITE' @@ -5264,19 +5369,19 @@ def interview_create_onsite(request, candidate_slug): # interview.interview_location = onsite_location # interview.save() - messages.success(request, f"Onsite interview scheduled for {candidate.name}") + messages.success(request, f"Onsite interview scheduled for {application.name}") return redirect('interview_detail', slug=schedule.slug) except Exception as e: messages.error(request, f"Error creating onsite interview: {str(e)}") else: # Pre-populate topic - form.initial['topic'] = f"Interview for {candidate.job.title} - {candidate.name}" + form.initial['topic'] = f"Interview for {application.job.title} - {application.name}" form = OnsiteInterviewForm() context = { - 'candidate': candidate, - 'job': candidate.job, + 'application': application, + 'job': application.job, 'form': form, } return render(request, 'interviews/interview_create_onsite.html', context) @@ -5363,10 +5468,10 @@ def agency_access_link_reactivate(request, slug): @agency_user_required -def api_application_detail(request, candidate_id): - """API endpoint to get candidate details for agency portal""" +def api_application_detail(request, application_id): + """API endpoint to get application details for agency portal""" try: - # Get candidate from session-based agency access + # Get application from session-based agency access assignment_id = request.session.get("agency_assignment_id") if not assignment_id: return JsonResponse({"success": False, "error": "Access denied"}) @@ -5378,20 +5483,20 @@ def api_application_detail(request, candidate_id): agency = current_assignment.agency - # Get candidate and verify it belongs to this agency - candidate = get_object_or_404( - Application, id=candidate_id, hiring_agency=agency + # Get application and verify it belongs to this agency + application = get_object_or_404( + Application, id=application_id, hiring_agency=agency ) - # Return candidate data + # Return application data response_data = { "success": True, - "id": candidate.id, - "first_name": candidate.first_name, - "last_name": candidate.last_name, - "email": candidate.email, - "phone": candidate.phone, - "address": candidate.address, + "id": application.id, + "first_name": application.first_name, + "last_name": application.last_name, + "email": application.email, + "phone": application.phone, + "address": application.address, } return JsonResponse(response_data) @@ -5402,7 +5507,7 @@ def api_application_detail(request, candidate_id): @staff_user_required def compose_application_email(request, job_slug): - """Compose email to participants about a candidate""" + """Compose email to participants about a application""" from .email_service import send_bulk_email job = get_object_or_404(JobPosting, slug=job_slug) @@ -5411,14 +5516,20 @@ def compose_application_email(request, job_slug): # if request.method == "POST": # form = CandidateEmailForm(job, candidate, request.POST) candidate_ids=request.GET.getlist('candidate_ids') - candidates=Application.objects.filter(id__in=candidate_ids) + + print("candidate_ids:",candidate_ids) + + + applications=Application.objects.filter(id__in=candidate_ids) if request.method == 'POST': candidate_ids = request.POST.getlist('candidate_ids') - candidates=Application.objects.filter(id__in=candidate_ids) - form = CandidateEmailForm(job, candidates, request.POST) + print("candidate_ids from post:", candidate_ids) + + applications=Application.objects.filter(id__in=candidate_ids) + form = CandidateEmailForm(job, applications, request.POST) if form.is_valid(): print("form is valid ...") # Get email addresses @@ -5454,23 +5565,28 @@ def compose_application_email(request, job_slug): ) if email_result["success"]: - for candidate in candidates: - if hasattr(candidate, 'person') and candidate.person: + for application in applications: + if hasattr(application, 'person') and application.person: try: + print(request.user) + print(application.person.user) + print(subject) + print(message) + print(job) + Message.objects.create( sender=request.user, - recipient=candidate.person.user, + recipient=application.person.user, subject=subject, content=message, job=job, - message_type='email', - is_email_sent=True, - email_address=candidate.person.email if candidate.person.email else candidate.email + message_type='job_related', + is_read=False, ) except Exception as e: # Log error but don't fail the entire process - print(f"Error creating message") + print(f"CRITICAL ERROR creating message for {application.person.user.email}: {e}") messages.success( request, @@ -5500,7 +5616,7 @@ def compose_application_email(request, job_slug): return render( request, "includes/email_compose_form.html", - {"form": form, "job": job, "candidate": candidates}, + {"form": form, "job": job, "applications": applications}, ) @@ -5521,18 +5637,18 @@ def compose_application_email(request, job_slug): return render( request, "includes/email_compose_form.html", - {"form": form, "job": job, "candidates": candidates}, + {"form": form, "job": job, "applications": applications}, ) else: # GET request - show the form - form = CandidateEmailForm(job, candidates) + form = CandidateEmailForm(job, applications) return render( request, "includes/email_compose_form.html", - # {"form": form, "job": job, "candidates": candidates}, - {"form": form,"job":job}, + # {"form": form, "job": job, "applications": applications}, + {"form": form,"job":job,"applications":applications}, ) diff --git a/recruitment/views_frontend.py b/recruitment/views_frontend.py index fc15046..7148b34 100644 --- a/recruitment/views_frontend.py +++ b/recruitment/views_frontend.py @@ -279,7 +279,7 @@ def application_detail(request, slug): @login_required @staff_user_required def application_resume_template_view(request, slug): - """Display formatted resume template for a candidate""" + """Display formatted resume template for a application""" application = get_object_or_404(models.Application, slug=slug) if not request.user.is_staff: @@ -398,7 +398,7 @@ def dashboard_view(request): # --- 2. TIME SERIES: GLOBAL DAILY APPLICANTS --- - # Group ALL candidates by creation date + # Group ALL applications by creation date global_daily_applications_qs = all_applications_queryset.annotate( date=TruncDate('created_at') ).values('date').annotate( @@ -482,7 +482,7 @@ def dashboard_view(request): # ) - # candidates_with_score_query= candidate_queryset.filter(is_resume_parsed=True).annotate( + # applications_with_score_query= application_queryset.filter(is_resume_parsed=True).annotate( # # The Coalesce handles NULL values (from missing data, non-numeric data, or NullIf) and sets them to 0. # annotated_match_score=Coalesce(safe_match_score_cast, Value(0)) # ) @@ -637,10 +637,10 @@ def dashboard_view(request): @login_required @staff_user_required def applications_offer_view(request, slug): - """View for candidates in the Offer stage""" + """View for applications in the Offer stage""" job = get_object_or_404(models.JobPosting, slug=slug) - # Filter candidates for this specific job and stage + # Filter applications for this specific job and stage applications = job.offer_applications # Handle search diff --git a/static/css/main.css b/static/css/main.css index bd5ca4c..a7120d8 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -732,3 +732,6 @@ html[dir="rtl"] .me-auto { margin-right: 0 !important; margin-left: auto !import content: ">"; color: var(--kaauh-teal); } + + + \ No newline at end of file diff --git a/templates/applicant/application_submit_form.html b/templates/applicant/application_submit_form.html index 29413bc..26137c6 100644 --- a/templates/applicant/application_submit_form.html +++ b/templates/applicant/application_submit_form.html @@ -403,13 +403,13 @@ } .btn-submit { - background: var(--success); /* Green for submit */ + background: var( --kaauh-teal-dark); /* Green for submit */ color: white; box-shadow: 0 4px 12px rgba(25, 135, 84, 0.3); } .btn-submit:hover { - background: #157347; + background: var(--kaauh-teal); transform: translateY(-2px); } diff --git a/templates/applicant/partials/candidate_facing_base.html b/templates/applicant/partials/candidate_facing_base.html index e9ed45e..d0cc67c 100644 --- a/templates/applicant/partials/candidate_facing_base.html +++ b/templates/applicant/partials/candidate_facing_base.html @@ -8,7 +8,7 @@ - {% trans "Careers" %} - {% block title %}{% translate "Application Form" %}{% endblock %} + {% trans "Careers" %} - {% block title %}{% trans "Application Form" %}{% endblock %} {% comment %} Load the correct Bootstrap CSS file for RTL/LTR {% endcomment %} {% if LANGUAGE_CODE == 'ar' %} @@ -309,7 +309,7 @@