diff --git a/NorahUniversity/__pycache__/settings.cpython-312.pyc b/NorahUniversity/__pycache__/settings.cpython-312.pyc index de5ab87..2d7be7f 100644 Binary files a/NorahUniversity/__pycache__/settings.cpython-312.pyc and b/NorahUniversity/__pycache__/settings.cpython-312.pyc differ diff --git a/NorahUniversity/__pycache__/urls.cpython-312.pyc b/NorahUniversity/__pycache__/urls.cpython-312.pyc index cac990a..3a455bc 100644 Binary files a/NorahUniversity/__pycache__/urls.cpython-312.pyc and b/NorahUniversity/__pycache__/urls.cpython-312.pyc differ diff --git a/NorahUniversity/settings.py b/NorahUniversity/settings.py index 5c00084..d741e46 100644 --- a/NorahUniversity/settings.py +++ b/NorahUniversity/settings.py @@ -135,9 +135,9 @@ WSGI_APPLICATION = 'NorahUniversity.wsgi.application' DATABASES = { 'default': { 'ENGINE': 'django.db.backends.postgresql_psycopg2', - 'NAME': 'norahuniversity', - 'USER': 'norahuniversity', - 'PASSWORD': 'norahuniversity', + 'NAME': 'haikal_db', + 'USER': 'faheed', + 'PASSWORD': 'Faheed@215', 'HOST': '127.0.0.1', 'PORT': '5432', } diff --git a/NorahUniversity/urls.py b/NorahUniversity/urls.py index 10dfb34..f7a39da 100644 --- a/NorahUniversity/urls.py +++ b/NorahUniversity/urls.py @@ -23,9 +23,11 @@ urlpatterns = [ # path('', include('recruitment.urls')), path("ckeditor5/", include('django_ckeditor_5.urls')), - path('form//', views.form_wizard_view, name='form_wizard'), - path('form//submit/', views.submit_form, name='submit_form'), - + path('application//', views.application_submit_form, name='application_submit_form'), + path('application//submit/', views.application_submit, name='application_submit'), + path('application//apply/', views.application_detail, name='application_detail'), + path('application//success/', views.application_success, name='application_success'), + path('api/templates/', views.list_form_templates, name='list_form_templates'), path('api/templates/save/', views.save_form_template, name='save_form_template'), path('api/templates//', views.load_form_template, name='load_form_template'), diff --git a/recruitment/__pycache__/linkedin_service.cpython-312.pyc b/recruitment/__pycache__/linkedin_service.cpython-312.pyc index d949652..19d53fa 100644 Binary files a/recruitment/__pycache__/linkedin_service.cpython-312.pyc and b/recruitment/__pycache__/linkedin_service.cpython-312.pyc differ diff --git a/recruitment/__pycache__/signals.cpython-312.pyc b/recruitment/__pycache__/signals.cpython-312.pyc index 32db21f..5dbffd2 100644 Binary files a/recruitment/__pycache__/signals.cpython-312.pyc and b/recruitment/__pycache__/signals.cpython-312.pyc differ diff --git a/recruitment/__pycache__/urls.cpython-312.pyc b/recruitment/__pycache__/urls.cpython-312.pyc index 082c450..00b718a 100644 Binary files a/recruitment/__pycache__/urls.cpython-312.pyc and b/recruitment/__pycache__/urls.cpython-312.pyc differ diff --git a/recruitment/__pycache__/views.cpython-312.pyc b/recruitment/__pycache__/views.cpython-312.pyc index fc41342..5eb571d 100644 Binary files a/recruitment/__pycache__/views.cpython-312.pyc and b/recruitment/__pycache__/views.cpython-312.pyc differ diff --git a/recruitment/__pycache__/views_frontend.cpython-312.pyc b/recruitment/__pycache__/views_frontend.cpython-312.pyc index 2a9ae28..85f9dab 100644 Binary files a/recruitment/__pycache__/views_frontend.cpython-312.pyc and b/recruitment/__pycache__/views_frontend.cpython-312.pyc differ diff --git a/recruitment/linkedin_service.py b/recruitment/linkedin_service.py index b6702b9..e275f2d 100644 --- a/recruitment/linkedin_service.py +++ b/recruitment/linkedin_service.py @@ -5,16 +5,15 @@ from html import unescape from urllib.parse import quote, urlencode import requests import logging -from django.conf import settings import time -import random -from django.utils import timezone +from django.conf import settings logger = logging.getLogger(__name__) -# Define a constant for the API version for better maintenance +# Define constants LINKEDIN_API_VERSION = '2.0.0' -LINKEDIN_VERSION = '202409' # Modern API version for header control +LINKEDIN_VERSION = '202409' +MAX_POST_CHARS = 3000 # LinkedIn's maximum character limit for shareCommentary class LinkedInService: def __init__(self): @@ -23,11 +22,11 @@ class LinkedInService: self.redirect_uri = settings.LINKEDIN_REDIRECT_URI self.access_token = None # Configuration for image processing wait time - self.ASSET_STATUS_TIMEOUT = 15 # Max time (seconds) to wait for image processing - self.ASSET_STATUS_INTERVAL = 2 # Check every 2 seconds - - # --- AUTHENTICATION & PROFILE --- + self.ASSET_STATUS_TIMEOUT = 15 + self.ASSET_STATUS_INTERVAL = 2 + # ---------------- AUTHENTICATION & PROFILE ---------------- + def get_auth_url(self): """Generate LinkedIn OAuth URL""" params = { @@ -76,7 +75,7 @@ class LinkedInService: logger.error(f"Error getting user profile: {e}") raise - # --- ASSET UPLOAD & STATUS --- + # ---------------- ASSET UPLOAD & STATUS ---------------- def get_asset_status(self, asset_urn): """Checks the status of a registered asset (image) to ensure it's READY.""" @@ -86,7 +85,7 @@ class LinkedInService: 'X-Restli-Protocol-Version': LINKEDIN_API_VERSION, 'LinkedIn-Version': LINKEDIN_VERSION, } - + try: response = requests.get(url, headers=headers, timeout=10) response.raise_for_status() @@ -129,6 +128,7 @@ class LinkedInService: """Step 2: Upload image file and poll for 'READY' status.""" image_file.open() image_content = image_file.read() + image_file.seek(0) # Reset pointer after reading image_file.close() headers = { @@ -137,8 +137,8 @@ class LinkedInService: response = requests.post(upload_url, headers=headers, data=image_content, timeout=60) response.raise_for_status() - - # --- CRITICAL FIX: POLL FOR ASSET STATUS --- + + # --- POLL FOR ASSET STATUS --- start_time = time.time() while time.time() - start_time < self.ASSET_STATUS_TIMEOUT: try: @@ -149,56 +149,55 @@ class LinkedInService: return True if status == "FAILED": raise Exception(f"LinkedIn image processing failed for asset {asset_urn}") - + logger.info(f"Asset {asset_urn} status: {status}. Waiting...") time.sleep(self.ASSET_STATUS_INTERVAL) - + except Exception as e: logger.warning(f"Error during asset status check for {asset_urn}: {e}. Retrying.") - time.sleep(self.ASSET_STATUS_INTERVAL * 2) + time.sleep(self.ASSET_STATUS_INTERVAL * 2) - # If the loop times out, return True to attempt post, but log warning logger.warning(f"Asset {asset_urn} timed out, but upload succeeded. Forcing post attempt.") return True - # --- POSTING LOGIC --- + # ---------------- POSTING UTILITIES ---------------- def clean_html_for_social_post(self, html_content): - """Converts safe HTML to plain text with basic formatting (bullets, bold, newlines).""" + """Converts safe HTML to plain text with basic formatting.""" if not html_content: return "" text = html_content - + # 1. Convert Bolding tags to *Markdown* text = re.sub(r'(.*?)', r'*\1*', text, flags=re.IGNORECASE) text = re.sub(r'(.*?)', r'*\1*', text, flags=re.IGNORECASE) # 2. Handle Lists: Convert
  • tags into a bullet point - text = re.sub(r'', '\n', text, flags=re.IGNORECASE) - text = re.sub(r']*>', 'β€’ ', text, flags=re.IGNORECASE) - text = re.sub(r'
  • ', '\n', text, flags=re.IGNORECASE) - + text = re.sub(r'', '\n', text, flags=re.IGNORECASE) + text = re.sub(r']*>', 'β€’ ', text, flags=re.IGNORECASE) + text = re.sub(r'', '\n', text, flags=re.IGNORECASE) + # 3. Handle Paragraphs and Line Breaks text = re.sub(r'

    ', '\n\n', text, flags=re.IGNORECASE) text = re.sub(r'
    ', '\n', text, flags=re.IGNORECASE) - + # 4. Strip all remaining, unsupported HTML tags clean_text = re.sub(r'<[^>]+>', '', text) - + # 5. Unescape HTML entities clean_text = unescape(clean_text) - + # 6. Clean up excessive whitespace/newlines clean_text = re.sub(r'(\n\s*){3,}', '\n\n', clean_text).strip() - + return clean_text def hashtags_list(self, hash_tags_str): """Convert comma-separated hashtags string to list""" if not hash_tags_str: return ["#HigherEd", "#Hiring", "#UniversityJobs"] - + tags = [tag.strip() for tag in hash_tags_str.split(',') if tag.strip()] tags = [tag if tag.startswith('#') else f'#{tag}' for tag in tags] @@ -208,15 +207,18 @@ class LinkedInService: return tags def _build_post_message(self, job_posting): - """Centralized logic to construct the professionally formatted text message.""" + """ + Constructs the final text message. + Includes a unique suffix for duplicate content prevention (422 fix). + """ message_parts = [ f"πŸ”₯ *Job Alert!* We’re looking for a talented professional to join our team.", f"πŸ‘‰ **{job_posting.title}** πŸ‘ˆ", ] - + if job_posting.department: - message_parts.append(f"*{job_posting.department}*") - + message_parts.append(f"*{job_posting.department}*") + message_parts.append("\n" + "=" * 25 + "\n") # KEY DETAILS SECTION @@ -229,7 +231,7 @@ class LinkedInService: details_list.append(f"🏠 Workplace: {job_posting.get_workplace_type_display()}") if job_posting.salary_range: details_list.append(f"πŸ’° Salary: {job_posting.salary_range}") - + if details_list: message_parts.append("*Key Information*:") message_parts.extend(details_list) @@ -239,11 +241,13 @@ class LinkedInService: clean_description = self.clean_html_for_social_post(job_posting.description) if clean_description: message_parts.append(f"πŸ”Ž *About the Role:*\n{clean_description}") - + # CALL TO ACTION if job_posting.application_url: message_parts.append(f"\n\n---") - message_parts.append(f"πŸ”— **APPLY NOW:** {job_posting.application_url}") + # CRITICAL: Include the URL explicitly in the text body. + # When media_category is NONE, LinkedIn often makes these URLs clickable. + message_parts.append(f"πŸ”— **APPLY NOW:** {job_posting.application_url}") # HASHTAGS hashtags = self.hashtags_list(job_posting.hash_tags) @@ -252,19 +256,38 @@ class LinkedInService: hashtags.insert(0, dept_hashtag) message_parts.append("\n" + " ".join(hashtags)) - - if len(message_parts)>=3000: - message_parts=message_parts[0:2980]+"........" - - return "\n".join(message_parts) + + final_message = "\n".join(message_parts) + + # --- FIX: ADD UNIQUE SUFFIX AND HANDLE LENGTH (422 fix) --- + unique_suffix = f"\n\n| Ref: {int(time.time())}" + + available_length = MAX_POST_CHARS - len(unique_suffix) + + if len(final_message) > available_length: + logger.warning("Post message truncated due to character limit.") + final_message = final_message[:available_length - 3] + "..." + + return final_message + unique_suffix + + + # ---------------- MAIN POSTING METHODS ---------------- def _send_ugc_post(self, person_urn, job_posting, media_category="NONE", media_list=None): """ - New private method to handle the final UGC post request (text or image). - This eliminates the duplication between create_job_post and create_job_post_with_image. + Private method to handle the final UGC post request. + CRITICAL FIX: Avoids ARTICLE category if not using an image to prevent 402 errors. """ - + message = self._build_post_message(job_posting) + + # --- FIX FOR 402: Force NONE if no image is present. --- + if media_category != "IMAGE": + # We explicitly force pure text share to avoid LinkedIn's link crawler + # which triggers the commercial 402 error on job reposts. + media_category = "NONE" + media_list = None + # -------------------------------------------------------- url = "https://api.linkedin.com/v2/ugcPosts" headers = { @@ -280,8 +303,8 @@ class LinkedInService: "shareMediaCategory": media_category, } } - - if media_list: + + if media_list and media_category == "IMAGE": specific_content["com.linkedin.ugc.ShareContent"]["media"] = media_list payload = { @@ -294,8 +317,13 @@ class LinkedInService: } response = requests.post(url, headers=headers, json=payload, timeout=60) + + # Log 402/422 details + if response.status_code in [402, 422]: + logger.error(f"{response.status_code} UGC Post Error Detail: {response.text}") + response.raise_for_status() - + post_id = response.headers.get('x-restli-id', '') post_url = f"https://www.linkedin.com/feed/update/{quote(post_id)}/" if post_id else "" @@ -308,18 +336,21 @@ class LinkedInService: def create_job_post_with_image(self, job_posting, image_file, person_urn, asset_urn): - """Step 3: Creates the final LinkedIn post payload with the image asset.""" + """Creates the final LinkedIn post payload with the image asset.""" + + if not job_posting.application_url: + raise ValueError("Application URL is required for image link share on LinkedIn.") - # Prepare the media list for the _send_ugc_post helper + # Media list for IMAGE category (retains link details) + # Note: This is an exception where we MUST provide link details for the image card media_list = [{ "status": "READY", "media": asset_urn, "description": {"text": job_posting.title}, - "originalUrl": job_posting.application_url, + "originalUrl": job_posting.application_url, "title": {"text": "Apply Now"} }] - # Use the helper method to send the post return self._send_ugc_post( person_urn=person_urn, job_posting=job_posting, @@ -344,47 +375,44 @@ class LinkedInService: # Check for image and attempt post try: - # Assuming correct model path: job_posting.related_model_name.first().image_field_name image_upload = job_posting.post_images.first().post_image has_image = image_upload is not None except Exception: - pass # No image available + pass if has_image: try: - # Step 1: Register + # Steps 1, 2, 3 for image post upload_info = self.register_image_upload(person_urn) asset_urn = upload_info['asset'] - - # Step 2: Upload and WAIT FOR READY (Crucial for 422 fix) self.upload_image_to_linkedin( upload_info['upload_url'], - image_upload, + image_upload, asset_urn ) - - # Step 3: Create post with image + return self.create_job_post_with_image( job_posting, image_upload, person_urn, asset_urn ) except Exception as e: logger.error(f"Image post failed, falling back to text: {e}") - # Force fallback to text-only if image posting fails - has_image = False + has_image = False # === FALLBACK TO PURE TEXT POST (shareMediaCategory: NONE) === - # Use the single helper method here + # The _send_ugc_post method now ensures this is a PURE text post + # to avoid the 402/ARTICLE-related issues. return self._send_ugc_post( person_urn=person_urn, job_posting=job_posting, - media_category="NONE" + media_category="NONE" ) except Exception as e: logger.error(f"Error creating LinkedIn post: {e}") + status_code = getattr(getattr(e, 'response', None), 'status_code', 500) return { 'success': False, 'error': str(e), - 'status_code': getattr(e.response, 'status_code', 500) if hasattr(e, 'response') else 500 + 'status_code': status_code } \ No newline at end of file diff --git a/recruitment/management/__pycache__/__init__.cpython-312.pyc b/recruitment/management/__pycache__/__init__.cpython-312.pyc index 7308a1c..22977f0 100644 Binary files a/recruitment/management/__pycache__/__init__.cpython-312.pyc and b/recruitment/management/__pycache__/__init__.cpython-312.pyc differ diff --git a/recruitment/migrations/0001_initial.py b/recruitment/migrations/0001_initial.py new file mode 100644 index 0000000..fcbfe6e --- /dev/null +++ b/recruitment/migrations/0001_initial.py @@ -0,0 +1,476 @@ +# Generated by Django 5.2.7 on 2025-10-22 16:33 + +import django.core.validators +import django.db.models.deletion +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 = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + 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='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)), + ], + options={ + 'verbose_name': 'Hiring Agency', + 'verbose_name_plural': 'Hiring Agencies', + 'ordering': ['name'], + }, + ), + 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')), + ], + options={ + 'verbose_name': 'Source', + 'verbose_name_plural': 'Sources', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='ZoomMeeting', + 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')), + ('topic', models.CharField(max_length=255, verbose_name='Topic')), + ('meeting_id', models.CharField(db_index=True, max_length=20, unique=True, verbose_name='Meeting ID')), + ('start_time', models.DateTimeField(db_index=True, verbose_name='Start Time')), + ('duration', models.PositiveIntegerField(verbose_name='Duration')), + ('timezone', models.CharField(max_length=50, verbose_name='Timezone')), + ('join_url', models.URLField(verbose_name='Join URL')), + ('participant_video', models.BooleanField(default=True, verbose_name='Participant Video')), + ('password', models.CharField(blank=True, max_length=20, null=True, verbose_name='Password')), + ('join_before_host', models.BooleanField(default=False, verbose_name='Join Before Host')), + ('mute_upon_entry', models.BooleanField(default=False, verbose_name='Mute Upon Entry')), + ('waiting_room', models.BooleanField(default=False, verbose_name='Waiting Room')), + ('zoom_gateway_response', models.JSONField(blank=True, null=True, verbose_name='Zoom Gateway Response')), + ('status', models.CharField(blank=True, db_index=True, default='waiting', max_length=20, null=True, verbose_name='Status')), + ], + options={ + 'abstract': False, + }, + ), + 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='Candidate', + 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')), + ('email', models.EmailField(db_index=True, max_length=254, verbose_name='Email')), + ('phone', models.CharField(max_length=20, verbose_name='Phone')), + ('address', models.TextField(max_length=200, verbose_name='Address')), + ('resume', models.FileField(upload_to='resumes/', verbose_name='Resume')), + ('is_resume_parsed', models.BooleanField(default=False, verbose_name='Resume Parsed')), + ('is_potential_candidate', models.BooleanField(default=False, verbose_name='Potential Candidate')), + ('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'), ('Offer', 'Offer')], db_index=True, default='Applied', max_length=100, verbose_name='Stage')), + ('applicant_status', models.CharField(blank=True, choices=[('Applicant', 'Applicant'), ('Candidate', 'Candidate')], default='Applicant', max_length=100, 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=100, null=True, verbose_name='Exam Status')), + ('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=100, 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')], max_length=100, null=True, verbose_name='Offer Status')), + ('join_date', models.DateField(blank=True, null=True, verbose_name='Join Date')), + ('ai_analysis_data', models.JSONField(default=dict, help_text='Full JSON output from the resume scoring model.', verbose_name='AI Analysis Data')), + ('submitted_by_agency', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='submitted_candidates', to='recruitment.hiringagency', verbose_name='Submitted by Agency')), + ], + options={ + 'verbose_name': 'Candidate', + 'verbose_name_plural': 'Candidates', + }, + ), + migrations.CreateModel( + name='JobPosting', + fields=[ + ('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 candidates 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, primary_key=True, serialize=False)), + ('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)), + ('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)), + ('hiring_agency', models.ManyToManyField(blank=True, help_text='External agency responsible for sourcing candidates 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.CreateModel( + name='InterviewSchedule', + 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)')), + ('candidates', models.ManyToManyField(blank=True, null=True, related_name='interview_schedules', to='recruitment.candidate')), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interview_schedules', to='recruitment.jobposting')), + ], + ), + 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.AddField( + model_name='candidate', + name='job', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='candidates', to='recruitment.jobposting', verbose_name='Job'), + ), + 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='Profile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('profile_image', models.ImageField(blank=True, null=True, upload_to='profile_pic/', validators=[recruitment.validators.validate_image_size])), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', 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=10, 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='ScheduledInterview', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('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')), + ('status', models.CharField(choices=[('scheduled', 'Scheduled'), ('confirmed', 'Confirmed'), ('cancelled', 'Cancelled'), ('completed', 'Completed')], db_index=True, default='scheduled', max_length=20)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('candidate', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.candidate')), + ('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.jobposting')), + ('schedule', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interviews', to='recruitment.interviewschedule')), + ('zoom_meeting', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='interview', to='recruitment.zoommeeting')), + ], + ), + migrations.CreateModel( + name='MeetingComment', + 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')), + ('content', django_ckeditor_5.fields.CKEditor5Field(verbose_name='Content')), + ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='meeting_comments', to=settings.AUTH_USER_MODEL, verbose_name='Author')), + ('meeting', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='recruitment.zoommeeting', verbose_name='Meeting')), + ], + options={ + 'verbose_name': 'Meeting Comment', + 'verbose_name_plural': 'Meeting Comments', + 'ordering': ['-created_at'], + }, + ), + 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='interviewschedule', + index=models.Index(fields=['start_date'], name='recruitment_start_d_15d55e_idx'), + ), + migrations.AddIndex( + model_name='interviewschedule', + index=models.Index(fields=['end_date'], name='recruitment_end_dat_aeb00e_idx'), + ), + migrations.AddIndex( + model_name='interviewschedule', + index=models.Index(fields=['created_by'], name='recruitment_created_d0bdcc_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='candidate', + index=models.Index(fields=['stage'], name='recruitment_stage_f1c6eb_idx'), + ), + migrations.AddIndex( + model_name='candidate', + index=models.Index(fields=['created_at'], name='recruitment_created_73590f_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'), + ), + 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=['candidate', 'job'], name='recruitment_candida_43d5b0_idx'), + ), + ] diff --git a/recruitment/signals.py b/recruitment/signals.py index ebba334..174cf35 100644 --- a/recruitment/signals.py +++ b/recruitment/signals.py @@ -26,7 +26,7 @@ def format_job(sender, instance, created, **kwargs): schedule_type=Schedule.ONCE ).first() - if instance.is_active and instance.application_deadline: + if instance.STATUS_CHOICES=='ACTIVE' and instance.application_deadline: if not existing_schedule: # Create a new schedule if one does not exist schedule( diff --git a/recruitment/tests.py b/recruitment/tests.py index ecf0a7b..20feb89 100644 --- a/recruitment/tests.py +++ b/recruitment/tests.py @@ -248,7 +248,7 @@ class ViewTests(BaseTestCase): } response = self.client.post( - reverse('submit_form', kwargs={'template_id': template.id}), + reverse('application_submit', kwargs={'template_id': template.id}), data ) # After successful submission, should redirect to success page @@ -434,7 +434,7 @@ class IntegrationTests(BaseTestCase): } response = self.client.post( - reverse('submit_form', kwargs={'template_id': template.id}), + reverse('application_submit', kwargs={'template_id': template.id}), form_data ) @@ -493,7 +493,7 @@ class AuthenticationTests(BaseTestCase): ) response = self.client.post( - reverse('submit_form', kwargs={'template_id': template.id}), + reverse('application_submit', kwargs={'template_id': template.id}), {} ) # Should redirect to login page @@ -525,7 +525,7 @@ class EdgeCaseTests(BaseTestCase): # Submit form twice response1 = self.client.post( - reverse('submit_form', kwargs={'template_id': template.id}), + reverse('application_submit', kwargs={'template_id': template.id}), {'field_1': 'John', 'field_2': 'Doe'} ) diff --git a/recruitment/tests_advanced.py b/recruitment/tests_advanced.py index 70f1dac..9e3e4d1 100644 --- a/recruitment/tests_advanced.py +++ b/recruitment/tests_advanced.py @@ -744,7 +744,7 @@ class AdvancedIntegrationTests(TransactionTestCase): } response = self.client.post( - reverse('submit_form', kwargs={'template_id': template.id}), + reverse('application_submit', kwargs={'template_id': template.id}), submission_data ) self.assertEqual(response.status_code, 302) # Redirect to success page diff --git a/recruitment/urls.py b/recruitment/urls.py index 604ad08..d3e418f 100644 --- a/recruitment/urls.py +++ b/recruitment/urls.py @@ -14,8 +14,7 @@ urlpatterns = [ path('jobs//update/', views.edit_job, name='job_update'), # path('jobs//delete/', views., name='job_delete'), path('jobs//', views.job_detail, name='job_detail'), - path('jobs//candidate/', views.job_detail_candidate, name='job_detail_candidate'), - path('jobs//candidate/application/success', views.application_success, name='application_success'), + path('careers/',views.kaauh_career,name='kaauh_career'), # LinkedIn Integration URLs @@ -83,8 +82,8 @@ urlpatterns = [ path('htmx//candidate_update_status/', views.candidate_update_status, name='candidate_update_status'), - path('forms/form//submit/', views.submit_form, name='submit_form'), - path('forms/form//', views.form_wizard_view, name='form_wizard'), + # path('forms/form//submit/', views.submit_form, name='submit_form'), + # path('forms/form//', views.form_wizard_view, name='form_wizard'), path('forms//submissions//', views.form_submission_details, name='form_submission_details'), path('forms/template//submissions/', views.form_template_submissions_list, name='form_template_submissions_list'), path('forms/template//all-submissions/', views.form_template_all_submissions, name='form_template_all_submissions'), @@ -140,6 +139,7 @@ urlpatterns = [ # Meeting Comments URLs path('meetings//comments/add/', views.add_meeting_comment, name='add_meeting_comment'), path('meetings//comments//edit/', views.edit_meeting_comment, name='edit_meeting_comment'), + path('meetings//comments//delete/', views.delete_meeting_comment, name='delete_meeting_comment'), path('meetings//set_meeting_candidate/', views.set_meeting_candidate, name='set_meeting_candidate'), diff --git a/recruitment/views.py b/recruitment/views.py index 7689124..9d535fa 100644 --- a/recruitment/views.py +++ b/recruitment/views.py @@ -280,7 +280,7 @@ def create_job(request): try: job = form.save(commit=False) job.save() - job_apply_url_relative=reverse('job_detail_candidate',kwargs={'slug':job.slug}) + job_apply_url_relative=reverse('application_detail',kwargs={'slug':job.slug}) job_apply_url_absolute=request.build_absolute_uri(job_apply_url_relative) job.application_url=job_apply_url_absolute # FormTemplate.objects.create(job=job, is_active=False, name=job.title,created_by=request.user) @@ -512,9 +512,9 @@ def kaauh_career(request): # job detail facing the candidate: -def job_detail_candidate(request, slug): +def application_detail(request, slug): job = get_object_or_404(JobPosting, slug=slug) - return render(request, "forms/job_detail_candidate.html", {"job": job}) + return render(request, "forms/application_detail.html", {"job": job}) from django_q.tasks import async_task @@ -800,7 +800,7 @@ def delete_form_template(request, template_id): ) -def form_wizard_view(request, template_slug): +def application_submit_form(request, template_slug): """Display the form as a step-by-step wizard""" template = get_object_or_404(FormTemplate, slug=template_slug, is_active=True) job_id = template.job.internal_job_id @@ -811,24 +811,24 @@ def form_wizard_view(request, template_slug): request, 'Application limit reached: This job is no longer accepting new applications. Please explore other available positions.' ) - return redirect('job_detail_candidate',slug=job.slug) + return redirect('application_detail',slug=job.slug) if job.is_expired: messages.error( request, 'Application deadline passed: This job is no longer accepting new applications. Please explore other available positions.' ) - return redirect('job_detail_candidate',slug=job.slug) + return redirect('application_detail',slug=job.slug) return render( request, - "forms/form_wizard.html", + "forms/application_submit_form.html", {"template_slug": template_slug, "job_id": job_id}, ) @csrf_exempt @require_POST -def submit_form(request, template_slug): +def application_submit(request, template_slug): """Handle form submission""" template = get_object_or_404(FormTemplate, slug=template_slug) job = template.job @@ -2292,7 +2292,13 @@ def edit_meeting_comment(request, slug, comment_id): return redirect('meeting_details', slug=slug) else: form = MeetingCommentForm(instance=comment) - + print("hi") + context = { + 'form': form, + 'meeting': meeting, + 'comment':comment + } + return render(request, 'includes/edit_comment_form.html', context) @login_required def delete_meeting_comment(request, slug, comment_id): diff --git a/recruitment/views_frontend.py b/recruitment/views_frontend.py index 50c05e3..d204d97 100644 --- a/recruitment/views_frontend.py +++ b/recruitment/views_frontend.py @@ -394,6 +394,27 @@ def dashboard_view(request): ).count() high_potential_ratio = round((high_potential_count / total_candidates) * 100, 1) if total_candidates > 0 else 0 + jobs=models.JobPosting.objects.all().order_by('internal_job_id') + selected_job_id=request.GET.get('selected_job_id','') + candidate_stage=['APPLIED','EXAM','INTERVIEW','OFFER'] + apply_count,exam_count,interview_count,offer_count=[0]*4 + + if selected_job_id: + job=jobs.get(internal_job_id=selected_job_id) + apply_count=job.screening_candidates_count + exam_count=job.exam_candidates_count + interview_count=job.interview_candidates_count + offer_count=job.offer_candidates_count + all_candidates_count=job.all_candidates_count + + else: #default job + job=jobs.first() + apply_count=job.screening_candidates_count + exam_count=job.exam_candidates_count + interview_count=job.interview_candidates_count + offer_count=job.offer_candidates_count + all_candidates_count=job.all_candidates_count + candidates_count=[ apply_count,exam_count,interview_count,offer_count ] context = { 'total_jobs': total_jobs, @@ -409,6 +430,14 @@ def dashboard_view(request): 'high_potential_count': high_potential_count, 'high_potential_ratio': high_potential_ratio, 'scored_ratio': scored_ratio, + 'current_job_id':selected_job_id, + 'jobs':jobs, + 'all_candidates_count':all_candidates_count, + 'candidate_stage':json.dumps(candidate_stage), + 'candidates_count':json.dumps(candidates_count) + ,'my_job':job + + } return render(request, 'recruitment/dashboard.html', context) @login_required diff --git a/templates/forms/job_detail_candidate.html b/templates/forms/application_detail.html similarity index 96% rename from templates/forms/job_detail_candidate.html rename to templates/forms/application_detail.html index 0ecccf1..9b98017 100644 --- a/templates/forms/job_detail_candidate.html +++ b/templates/forms/application_detail.html @@ -42,7 +42,7 @@

    {% trans "Review the job details, then apply below." %}

    {% if job.form_template %} - + {% trans "Apply for this Position" %} {% endif %} @@ -102,7 +102,7 @@
    {% if job.form_template %} - + {% trans "Apply for this Position" %} {% endif %} diff --git a/templates/forms/form_wizard.html b/templates/forms/application_submit_form similarity index 99% rename from templates/forms/form_wizard.html rename to templates/forms/application_submit_form index 1447255..fdc9972 100644 --- a/templates/forms/form_wizard.html +++ b/templates/forms/application_submit_form @@ -824,7 +824,7 @@ }); try { - const response = await fetch(`/form/${state.templateId}/submit/`, { + const response = await fetch(`/application/${state.templateId}/submit/`, { method: 'POST', body: formData // IMPORTANT: Do NOT set Content-Type header when using FormData diff --git a/templates/forms/form_templates_list.html b/templates/forms/form_templates_list.html index 7cecea0..8f9440e 100644 --- a/templates/forms/form_templates_list.html +++ b/templates/forms/form_templates_list.html @@ -231,7 +231,7 @@ {% endif %}
    - + {% if 'HX-Request' in request.headers %} {% endif %} diff --git a/templates/jobs/career.html b/templates/jobs/career.html index 62768fc..c122663 100644 --- a/templates/jobs/career.html +++ b/templates/jobs/career.html @@ -248,7 +248,7 @@ {% trans 'Apply' %} diff --git a/templates/jobs/create_job.html b/templates/jobs/create_job.html index 51cf403..20fbe2f 100644 --- a/templates/jobs/create_job.html +++ b/templates/jobs/create_job.html @@ -117,28 +117,21 @@
    -
    +
    {{ form.title }} {% if form.title.errors %}
    {{ form.title.errors }}
    {% endif %}
    -
    +
    {{ form.job_type }} {% if form.job_type.errors %}
    {{ form.job_type.errors }}
    {% endif %}
    - -
    -
    - - {{ form.department }} - {% if form.department.errors %}
    {{ form.department.errors }}
    {% endif %} -
    -
    +
    @@ -146,33 +139,18 @@ {% if form.workplace_type.errors %}
    {{ form.workplace_type.errors }}
    {% endif %}
    -
    -
    -
    - - - {# ================================================= #} - {# SECTION 2: INTERNAL AND PROMOTION #} - {# ================================================= #} - -
    -
    -
    {% trans "Internal & Promotion" %}
    -
    -
    -
    - - {{ form.position_number }} - {% if form.position_number.errors %}
    {{ form.position_number.errors }}
    {% endif %} + + {{ form.application_deadline }} + {% if form.application_deadline.errors %}
    {{ form.application_deadline.errors }}
    {% endif %}
    - - {{ form.reporting_to }} - {% if form.reporting_to.errors %}
    {{ form.reporting_to.errors }}
    {% endif %} + + {{ form.department }} + {% if form.department.errors %}
    {{ form.department.errors }}
    {% endif %}
    @@ -189,72 +167,14 @@ {% if form.max_applications.errors %}
    {{ form.max_applications.errors }}
    {% endif %}
    - - -
    -
    - - {{ form.hash_tags }} - {% if form.hash_tags.errors %}
    {{ form.hash_tags.errors }}
    {% endif %} -
    {% trans "Comma-separated list of hashtags, e.g., #hiring, #professor" %}
    -
    -
    - {# ================================================= #} - {# SECTION 3: LOCATION AND DATES #} - {# ================================================= #} + -
    -
    -
    {% trans "Location, Dates, & Salary" %}
    -
    -
    -
    -
    -
    - - {{ form.location_city }} - {% if form.location_city.errors %}
    {{ form.location_city.errors }}
    {% endif %} -
    -
    -
    -
    - - {{ form.location_state }} - {% if form.location_state.errors %}
    {{ form.location_state.errors }}
    {% endif %} -
    -
    -
    -
    - - {{ form.location_country }} - {% if form.location_country.errors %}
    {{ form.location_country.errors }}
    {% endif %} -
    -
    - -
    -
    - - {{ form.application_deadline }} - {% if form.application_deadline.errors %}
    {{ form.application_deadline.errors }}
    {% endif %} -
    -
    - -
    -
    - - {{ form.salary_range }} - {% if form.salary_range.errors %}
    {{ form.salary_range.errors }}
    {% endif %} -
    -
    - -
    -
    -
    + {# ================================================= #} {# SECTION 4: JOB CONTENT (CKEDITOR 5 Fields) #} {# ================================================= #} @@ -313,8 +233,90 @@ + {# ================================================= #} + {# SECTION 2: INTERNAL AND PROMOTION #} + {# ================================================= #} + +
    +
    +
    {% trans "Internal & Promotion" %}
    +
    +
    +
    +
    +
    + + {{ form.position_number }} + {% if form.position_number.errors %}
    {{ form.position_number.errors }}
    {% endif %} +
    +
    +
    +
    + + {{ form.reporting_to }} + {% if form.reporting_to.errors %}
    {{ form.reporting_to.errors }}
    {% endif %} +
    +
    + +
    +
    + + {{ form.hash_tags }} + {% if form.hash_tags.errors %}
    {{ form.hash_tags.errors }}
    {% endif %} +
    {% trans "Comma-separated list of hashtags, e.g., #hiring, #professor" %}
    +
    +
    +
    +
    +
    + + {# ================================================= #} + {# SECTION 3: LOCATION AND Salary #} + {# ================================================= #} + +
    +
    +
    {% trans "Location & Salary" %}
    +
    +
    +
    +
    +
    + + {{ form.location_city }} + {% if form.location_city.errors %}
    {{ form.location_city.errors }}
    {% endif %} +
    +
    +
    +
    + + {{ form.location_state }} + {% if form.location_state.errors %}
    {{ form.location_state.errors }}
    {% endif %} +
    +
    +
    +
    + + {{ form.location_country }} + {% if form.location_country.errors %}
    {{ form.location_country.errors }}
    {% endif %} +
    +
    + + + +
    +
    + + {{ form.salary_range }} + {% if form.salary_range.errors %}
    {{ form.salary_range.errors }}
    {% endif %} +
    +
    + +
    +
    +
    {# ================================================= #} {# ACTION BUTTONS #} diff --git a/templates/jobs/edit_job.html b/templates/jobs/edit_job.html index 51cf403..20fbe2f 100644 --- a/templates/jobs/edit_job.html +++ b/templates/jobs/edit_job.html @@ -117,28 +117,21 @@
    -
    +
    {{ form.title }} {% if form.title.errors %}
    {{ form.title.errors }}
    {% endif %}
    -
    +
    {{ form.job_type }} {% if form.job_type.errors %}
    {{ form.job_type.errors }}
    {% endif %}
    - -
    -
    - - {{ form.department }} - {% if form.department.errors %}
    {{ form.department.errors }}
    {% endif %} -
    -
    +
    @@ -146,33 +139,18 @@ {% if form.workplace_type.errors %}
    {{ form.workplace_type.errors }}
    {% endif %}
    -
    -
    -
    - - - {# ================================================= #} - {# SECTION 2: INTERNAL AND PROMOTION #} - {# ================================================= #} - -
    -
    -
    {% trans "Internal & Promotion" %}
    -
    -
    -
    - - {{ form.position_number }} - {% if form.position_number.errors %}
    {{ form.position_number.errors }}
    {% endif %} + + {{ form.application_deadline }} + {% if form.application_deadline.errors %}
    {{ form.application_deadline.errors }}
    {% endif %}
    - - {{ form.reporting_to }} - {% if form.reporting_to.errors %}
    {{ form.reporting_to.errors }}
    {% endif %} + + {{ form.department }} + {% if form.department.errors %}
    {{ form.department.errors }}
    {% endif %}
    @@ -189,72 +167,14 @@ {% if form.max_applications.errors %}
    {{ form.max_applications.errors }}
    {% endif %}
    - - -
    -
    - - {{ form.hash_tags }} - {% if form.hash_tags.errors %}
    {{ form.hash_tags.errors }}
    {% endif %} -
    {% trans "Comma-separated list of hashtags, e.g., #hiring, #professor" %}
    -
    -
    - {# ================================================= #} - {# SECTION 3: LOCATION AND DATES #} - {# ================================================= #} + -
    -
    -
    {% trans "Location, Dates, & Salary" %}
    -
    -
    -
    -
    -
    - - {{ form.location_city }} - {% if form.location_city.errors %}
    {{ form.location_city.errors }}
    {% endif %} -
    -
    -
    -
    - - {{ form.location_state }} - {% if form.location_state.errors %}
    {{ form.location_state.errors }}
    {% endif %} -
    -
    -
    -
    - - {{ form.location_country }} - {% if form.location_country.errors %}
    {{ form.location_country.errors }}
    {% endif %} -
    -
    - -
    -
    - - {{ form.application_deadline }} - {% if form.application_deadline.errors %}
    {{ form.application_deadline.errors }}
    {% endif %} -
    -
    - -
    -
    - - {{ form.salary_range }} - {% if form.salary_range.errors %}
    {{ form.salary_range.errors }}
    {% endif %} -
    -
    - -
    -
    -
    + {# ================================================= #} {# SECTION 4: JOB CONTENT (CKEDITOR 5 Fields) #} {# ================================================= #} @@ -313,8 +233,90 @@ + {# ================================================= #} + {# SECTION 2: INTERNAL AND PROMOTION #} + {# ================================================= #} + +
    +
    +
    {% trans "Internal & Promotion" %}
    +
    +
    +
    +
    +
    + + {{ form.position_number }} + {% if form.position_number.errors %}
    {{ form.position_number.errors }}
    {% endif %} +
    +
    +
    +
    + + {{ form.reporting_to }} + {% if form.reporting_to.errors %}
    {{ form.reporting_to.errors }}
    {% endif %} +
    +
    + +
    +
    + + {{ form.hash_tags }} + {% if form.hash_tags.errors %}
    {{ form.hash_tags.errors }}
    {% endif %} +
    {% trans "Comma-separated list of hashtags, e.g., #hiring, #professor" %}
    +
    +
    +
    +
    +
    + + {# ================================================= #} + {# SECTION 3: LOCATION AND Salary #} + {# ================================================= #} + +
    +
    +
    {% trans "Location & Salary" %}
    +
    +
    +
    +
    +
    + + {{ form.location_city }} + {% if form.location_city.errors %}
    {{ form.location_city.errors }}
    {% endif %} +
    +
    +
    +
    + + {{ form.location_state }} + {% if form.location_state.errors %}
    {{ form.location_state.errors }}
    {% endif %} +
    +
    +
    +
    + + {{ form.location_country }} + {% if form.location_country.errors %}
    {{ form.location_country.errors }}
    {% endif %} +
    +
    + + + +
    +
    + + {{ form.salary_range }} + {% if form.salary_range.errors %}
    {{ form.salary_range.errors }}
    {% endif %} +
    +
    + +
    +
    +
    {# ================================================= #} {# ACTION BUTTONS #} diff --git a/templates/jobs/job_detail.html b/templates/jobs/job_detail.html index 4b4ff5d..673fc50 100644 --- a/templates/jobs/job_detail.html +++ b/templates/jobs/job_detail.html @@ -4,7 +4,6 @@ {% block title %}{{ job.title }} - University ATS{% endblock %} {% block customCSS %} - {% endblock %} @@ -238,12 +152,12 @@
    - {# LEFT COLUMN: JOB DETAILS WITH TABS #} + {# LEFT COLUMN: JOB DETAILS (NO TABS) #}
    @@ -252,276 +166,136 @@

    {{ job.title }}

    {% trans "JOB ID: "%}{{ job.internal_job_id }} + + {# Deadline #} + {% if job.application_deadline %} +
    + + {% trans "Deadline:" %} {{ job.application_deadline }} +
    + {% endif %}
    -
    - - {# Corrected status badge logic to close the span correctly #} - {% if job.status == "ACTIVE" %} - - {% elif job.status == "DRAFT" %} - - {% elif job.status == "CLOSED" %} - - {% elif job.status == "CANCELLED" %} - - {% elif job.status == "ARCHIVED" %} - - {% else %} - - {% endif %} - {{ job.get_status_display }} +
    + + {# Status badge #} +
    + + {{ job.get_status_display }} + +
    + + {# Share Public Link Button #} + + +
    - {# LEFT TABS NAVIGATION #} - - + {# CONTENT: CORE DETAILS (No Tabs) #}
    -
    - - {# TAB 1 CONTENT: CORE DETAILS #} -
    -
    {% trans "Administrative & Location" %}
    -
    -
    - {% trans "Department:" %} {{ job.department|default:"N/A" }} -
    -
    - {% trans "Position No:" %} {{ job.position_number|default:"N/A" }} -
    -
    - {% trans "Job Type:" %} {{ job.get_job_type_display }} -
    -
    - {% trans "Workplace:" %} {{ job.get_workplace_type_display }} -
    -
    - {% trans "Location:" %} {{ job.get_location_display }} -
    -
    - {% trans "Created By:" %} {{ job.created_by|default:"N/A" }} -
    -
    - {% trans "Created At:" %} {{ job.created_at|default:"N/A" }} -
    -
    - {% trans "Updated At:" %} {{ job.updated_at|default:"N/A" }} -
    - -
    - - - -
    - -
    -
    {% trans "Financial & Timeline" %}
    -
    - {% if job.salary_range %} -
    -
    - - {% trans "Salary:" %} {{ job.salary_range }} -
    -
    - {% endif %} - {% if job.start_date %} -
    -
    - - {% trans "Start Date:" %} {{ job.start_date }} -
    -
    - {% endif %} - {% if job.application_deadline %} -
    -
    - - {% trans "Deadline:" %} {{ job.application_deadline }} - {% if job.is_expired %} - {% trans "EXPIRED" %} - {% endif %} -
    -
    - {% endif %} -
    + +
    {% trans "Administrative & Location" %} +
  • {% trans "Edit JOb" %}
    +
    +
    +
    + {% trans "Department:" %} {{ job.department|default:"N/A" }}
    - - {# TAB 2 CONTENT: DESCRIPTION & REQUIREMENTS #} -
    - {% if job.description %} -
    -
    {% trans "Job Description" %}
    -
    {{ job.description|safe }}
    -
    - {% endif %} - {% if job.qualifications %} -
    -
    {% trans "Required Qualifications" %}
    -
    {{ job.qualifications|safe }}
    -
    - {% endif %} - {% if job.benefits %} -
    -
    {% trans "Benefits" %}
    -
    {{ job.benefits|safe}}
    -
    - {% endif %} - {% if job.application_instructions %} -
    -
    {% trans "Application Instructions" %}
    -
    {{ job.application_instructions|safe }}
    -
    - {% endif %} - +
    + {% trans "Position No:" %} {{ job.position_number|default:"N/A" }}
    - - {# TAB 3 CONTENT: APPLICATION KPIS #} -
    -
    - - {# 1. Job Avg. Score #} -
    -
    -
    - -
    {{ avg_match_score|floatformat:1 }}
    - {% trans "Avg. AI Score" %} -
    -
    -
    - - {# 2. High Potential Count #} -
    -
    -
    - -
    {{ high_potential_count }}
    - {% trans "High Potential" %} -
    -
    -
    - - {# 3. Avg. Time to Interview #} -
    -
    -
    - -
    {{ avg_t2i_days|floatformat:1 }}d
    - {% trans "Time to Interview" %} -
    -
    -
    - - {# 4. Avg. Exam Review Time #} -
    -
    -
    - -
    {{ avg_t_in_exam_days|floatformat:1 }}d
    - {% trans "Avg. Exam Review" %} -
    -
    -
    -
    - -

    - {% trans "KPIs based on completed applicant data." %} -

    - +
    + {% trans "Job Type:" %} {{ job.get_job_type_display }} +
    +
    + {% trans "Workplace:" %} {{ job.get_workplace_type_display }} +
    +
    + {% trans "Location:" %} {{ job.get_location_display }} +
    +
    + {% trans "Salary:" %} {{ job.salary_range |default:"N/A" }} +
    +
    + {% trans "Created By:" %} {{ job.created_by|default:"N/A" }} +
    +
    + {% trans "Created At:" %} {{ job.created_at|default:"N/A" }} +
    +
    + {% trans "Updated At:" %} {{ job.updated_at|default:"N/A" }}
    -
    - - - + + {# Description Blocks (Main Content) #} + {% if job.description %} +
    +
    {% trans "Job Description" %}
    +
    {{ job.description|safe }}
    +
    + {% endif %} + {% if job.qualifications %} +
    +
    {% trans "Required Qualifications" %}
    +
    {{ job.qualifications|safe }}
    +
    + {% endif %} + {% if job.benefits %} +
    +
    {% trans "Benefits" %}
    +
    {{ job.benefits|safe}}
    +
    + {% endif %} + {% if job.application_instructions %} +
    +
    {% trans "Application Instructions" %}
    +
    {{ job.application_instructions|safe }}
    +
    + {% endif %}
    - {# FOOTER ACTIONS #} -
    - {# RIGHT COLUMN: TABBED CARDS #} + {# RIGHT COLUMN: TABBED CARDS #}
    - {# New Card for Candidate Category Chart #}
    -
    -
    - - {% trans "Candidate Categories & Scores" %} -
    -
    -
    -
    - -
    -
    -
    - - {# REMOVED: Standalone Applicant Tracking Card (It is now in a tab) #} - -
    - {# RIGHT TABS NAVIGATION #} -