From bb552cbd3f8023e29c27d9567cadfae51c567d5a Mon Sep 17 00:00:00 2001 From: ismail Date: Thu, 4 Dec 2025 15:02:43 +0300 Subject: [PATCH] fix remote interview creation issue --- .env | 6 +- NorahUniversity/settings.py | 12 +- recruitment/admin.py | 26 +- recruitment/forms.py | 117 ++++-- recruitment/linkedin_service.py | 113 +++--- .../management/commands/init_settings.py | 19 + recruitment/migrations/0002_settings.py | 30 ++ recruitment/models.py | 29 +- recruitment/signals.py | 89 +++-- recruitment/tasks.py | 263 +++++++++++--- recruitment/urls.py | 19 +- recruitment/utils.py | 335 +++++++++++++++--- recruitment/views.py | 260 ++++++++++---- recruitment/zoom_api.py | 46 ++- .../partials/candidate_facing_base.html | 9 +- templates/interviews/interview_detail.html | 178 +++++----- templates/jobs/job_list.html | 1 - .../meetings/reschedule_onsite_meeting.html | 10 +- .../messages/application_message_detail.html | 71 ++-- .../messages/application_message_list.html | 24 +- templates/messages/message_detail.html | 4 +- templates/messages/message_list.html | 45 ++- templates/recruitment/dashboard.html | 62 ++-- .../recruitment/settings_confirm_delete.html | 123 +++++++ templates/recruitment/settings_detail.html | 82 +++++ templates/recruitment/settings_form.html | 218 ++++++++++++ templates/recruitment/settings_list.html | 186 ++++++++++ test_job_reminders.py | 169 +++++++++ 28 files changed, 2069 insertions(+), 477 deletions(-) create mode 100644 recruitment/management/commands/init_settings.py create mode 100644 recruitment/migrations/0002_settings.py create mode 100644 templates/recruitment/settings_confirm_delete.html create mode 100644 templates/recruitment/settings_detail.html create mode 100644 templates/recruitment/settings_form.html create mode 100644 templates/recruitment/settings_list.html create mode 100644 test_job_reminders.py diff --git a/.env b/.env index 8d7fbd5..b9e2bf0 100644 --- a/.env +++ b/.env @@ -1,3 +1,3 @@ -DB_NAME=haikal_db -DB_USER=faheed -DB_PASSWORD=Faheed@215 \ No newline at end of file +DB_NAME=norahuniversity +DB_USER=norahuniversity +DB_PASSWORD=norahuniversity \ No newline at end of file diff --git a/NorahUniversity/settings.py b/NorahUniversity/settings.py index 66bd7bb..a2a138d 100644 --- a/NorahUniversity/settings.py +++ b/NorahUniversity/settings.py @@ -273,6 +273,8 @@ SOCIALACCOUNT_PROVIDERS = { } } +# Dynamic Zoom Configuration - will be loaded from database +# These are fallback values - actual values will be loaded from database at runtime ZOOM_ACCOUNT_ID = "HoGikHXsQB2GNDC5Rvyw9A" ZOOM_CLIENT_ID = "brC39920R8C8azfudUaQgA" ZOOM_CLIENT_SECRET = "rvfhjlbID4ychXPOvZ2lYsoAC0B0Ny2L" @@ -292,6 +294,8 @@ CELERY_TASK_SERIALIZER = "json" CELERY_RESULT_SERIALIZER = "json" CELERY_TIMEZONE = "UTC" +# Dynamic LinkedIn Configuration - will be loaded from database +# These are fallback values - actual values will be loaded from database at runtime LINKEDIN_CLIENT_ID = "867jwsiyem1504" LINKEDIN_CLIENT_SECRET = "WPL_AP1.QNH5lYnfRSQpp0Qp.GO8Srw==" LINKEDIN_REDIRECT_URI = "http://127.0.0.1:8000/jobs/linkedin/callback/" @@ -512,7 +516,7 @@ LOGGING={ "level": "DEBUG", "formatter": "simple" } - + }, "loggers": { "": { @@ -525,12 +529,12 @@ LOGGING={ "verbose": { "format": "[{asctime}] {levelname} [{name}:{lineno}] {message}", "style": "{", - }, + }, "simple": { "format": "{levelname} {message}", "style": "{", - }, - } + }, + } } diff --git a/recruitment/admin.py b/recruitment/admin.py index 543b704..59f9a3d 100644 --- a/recruitment/admin.py +++ b/recruitment/admin.py @@ -6,7 +6,7 @@ from .models import ( JobPosting, Application, TrainingMaterial, FormTemplate, FormStage, FormField, FormSubmission, FieldResponse, SharedFormTemplate, Source, HiringAgency, IntegrationLog,BulkInterviewTemplate,JobPostingImage,Note, - AgencyAccessLink, AgencyJobAssignment,Interview,ScheduledInterview,Person + AgencyAccessLink, AgencyJobAssignment,Interview,ScheduledInterview, Settings,Person ) from django.contrib.auth import get_user_model @@ -249,6 +249,30 @@ admin.site.register(ScheduledInterview) # AgencyMessage admin removed - model has been deleted +@admin.register(Settings) +class SettingsAdmin(admin.ModelAdmin): + list_display = ['key', 'value_preview', 'created_at', 'updated_at'] + list_filter = ['created_at'] + search_fields = ['key', 'value'] + readonly_fields = ['created_at', 'updated_at'] + fieldsets = ( + ('Setting Information', { + 'fields': ('key', 'value') + }), + ('Timestamps', { + 'fields': ('created_at', 'updated_at') + }), + ) + save_on_top = True + + def value_preview(self, obj): + """Show a preview of the value (truncated for long values)""" + if len(obj.value) > 50: + return obj.value[:50] + '...' + return obj.value + value_preview.short_description = 'Value' + + admin.site.register(JobPostingImage) admin.site.register(Person) # admin.site.register(User) diff --git a/recruitment/forms.py b/recruitment/forms.py index 4340ba3..436cc82 100644 --- a/recruitment/forms.py +++ b/recruitment/forms.py @@ -29,6 +29,7 @@ from .models import ( Person, Document, CustomUser, + Settings, Interview ) @@ -287,40 +288,40 @@ class PersonForm(forms.ModelForm): } def clean_email(self): email = self.cleaned_data.get('email') - + if not email: - + return email - - + + if email: instance = self.instance qs = CustomUser.objects.filter(email=email) | CustomUser.objects.filter(username=email) if not instance.pk: # Creating new instance - - + + if qs.exists(): raise ValidationError(_("A user account with this email address already exists. Please use a different email.")) - + else: # Editing existing instance # if ( # qs # .exclude(pk=instance.user.pk) # .exists() # ): - + # raise ValidationError(_("An user with this email already exists.")) pass - + return email.strip() - - - - + + + + return email - + class ApplicationForm(forms.ModelForm): class Meta: @@ -961,7 +962,7 @@ class JobPostingStatusForm(forms.ModelForm): widgets = { "status": forms.Select(attrs={"class": "form-select"}), } - + def clean_status(self): status = self.cleaned_data.get("status") if status == "ACTIVE": @@ -2286,7 +2287,7 @@ class MessageForm(forms.ModelForm): self.fields["recipient"].queryset = User.objects.filter( user_type="staff" ).order_by("username") - + def clean(self): @@ -2946,15 +2947,91 @@ class ScheduledInterviewUpdateStatusForm(forms.Form): model = ScheduledInterview fields = ['status'] + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - + # Filter the choices here EXCLUDED_STATUS = ScheduledInterview.InterviewStatus.CANCELLED filtered_choices = [ - choice for choice in ScheduledInterview.InterviewStatus.choices + choice for choice in ScheduledInterview.InterviewStatus.choices if choice[0]!= EXCLUDED_STATUS ] - + # Apply the filtered list back to the field - self.fields['status'].choices = filtered_choices \ No newline at end of file + self.fields['status'].choices = filtered_choices + + +class SettingsForm(forms.ModelForm): + """Form for creating and editing settings""" + + class Meta: + model = Settings + fields = ['key', 'value'] + widgets = { + 'key': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Enter setting key', + 'required': True + }), + 'value': forms.Textarea(attrs={ + 'class': 'form-control', + 'rows': 3, + 'placeholder': 'Enter setting value', + 'required': True + }), + } + labels = { + 'key': _('Setting Key'), + 'value': _('Setting Value'), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper = FormHelper() + self.helper.form_method = 'post' + self.helper.form_class = 'g-3' + + self.helper.layout = Layout( + Field('key', css_class='form-control'), + Field('value', css_class='form-control'), + Div( + Submit('submit', _('Save Setting'), css_class='btn btn-main-action'), + css_class='col-12 mt-4', + ), + ) + + def clean_key(self): + """Ensure key is unique and properly formatted""" + key = self.cleaned_data.get('key') + if key: + # Convert to uppercase for consistency + key = key.upper().strip() + + # Check for duplicates excluding current instance if editing + instance = self.instance + if not instance.pk: # Creating new instance + if Settings.objects.filter(key=key).exists(): + raise forms.ValidationError("A setting with this key already exists.") + else: # Editing existing instance + if Settings.objects.filter(key=key).exclude(pk=instance.pk).exists(): + raise forms.ValidationError("A setting with this key already exists.") + + # Validate key format (alphanumeric and underscores only) + import re + if not re.match(r'^[A-Z][A-Z0-9_]*$', key): + raise forms.ValidationError( + "Setting key must start with a letter and contain only uppercase letters, numbers, and underscores." + ) + return key + + def clean_value(self): + """Validate setting value""" + value = self.cleaned_data.get('value') + if value: + value = value.strip() + # You can add specific validation based on key type here + # For now, just ensure it's not empty + if not value: + raise forms.ValidationError("Setting value cannot be empty.") + return value diff --git a/recruitment/linkedin_service.py b/recruitment/linkedin_service.py index 3f645a4..ebbd972 100644 --- a/recruitment/linkedin_service.py +++ b/recruitment/linkedin_service.py @@ -4,28 +4,29 @@ import uuid import requests import logging import time -from django.conf import settings from urllib.parse import quote, urlencode +from .utils import get_linkedin_config,get_setting logger = logging.getLogger(__name__) # Define constants -LINKEDIN_API_VERSION = '2.0.0' -LINKEDIN_VERSION = '202409' +LINKEDIN_API_VERSION = get_setting('LINKEDIN_API_VERSION', '2.0.0') +LINKEDIN_VERSION = get_setting('LINKEDIN_VERSION', '202301') class LinkedInService: def __init__(self): - self.client_id = settings.LINKEDIN_CLIENT_ID - self.client_secret = settings.LINKEDIN_CLIENT_SECRET - self.redirect_uri = settings.LINKEDIN_REDIRECT_URI + config = get_linkedin_config() + self.client_id = config['LINKEDIN_CLIENT_ID'] + self.client_secret = config['LINKEDIN_CLIENT_SECRET'] + self.redirect_uri = config['LINKEDIN_REDIRECT_URI'] self.access_token = None # Configuration for image processing wait time - self.ASSET_STATUS_TIMEOUT = 15 - self.ASSET_STATUS_INTERVAL = 2 + self.ASSET_STATUS_TIMEOUT = 15 + self.ASSET_STATUS_INTERVAL = 2 # ---------------- AUTHENTICATION & PROFILE ---------------- - + def get_auth_url(self): """Generate LinkedIn OAuth URL""" params = { @@ -84,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() @@ -136,7 +137,7 @@ class LinkedInService: response = requests.post(upload_url, headers=headers, data=image_content, timeout=60) response.raise_for_status() - + # --- POLL FOR ASSET STATUS --- start_time = time.time() while time.time() - start_time < self.ASSET_STATUS_TIMEOUT: @@ -148,13 +149,13 @@ 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) logger.warning(f"Asset {asset_urn} timed out, but upload succeeded. Forcing post attempt.") return True @@ -167,36 +168,36 @@ class LinkedInService: # 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] @@ -207,17 +208,17 @@ class LinkedInService: # def _build_post_message(self, job_posting): # """ - # Constructs the final 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 @@ -230,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) @@ -241,13 +242,13 @@ class LinkedInService: # if clean_description: # message_parts.append(f"πŸ”Ž *About the Role:*\n{clean_description}") # clean_ - + # # CALL TO ACTION # if job_posting.application_url: # message_parts.append(f"\n\n---") - # # CRITICAL: Include the URL explicitly in the text body. + # # 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}") + # message_parts.append(f"πŸ”— **APPLY NOW:** {job_posting.application_url}") # # HASHTAGS # hashtags = self.hashtags_list(job_posting.hash_tags) @@ -256,21 +257,21 @@ class LinkedInService: # hashtags.insert(0, dept_hashtag) # message_parts.append("\n" + " ".join(hashtags)) - + # final_message = "\n".join(message_parts) - + # # --- FIX: ADD UNIQUE SUFFIX AND HANDLE LENGTH (422 fix) --- - # unique_suffix = f"\n\n| Ref: {int(time.time())}" - + # 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): @@ -278,14 +279,14 @@ class LinkedInService: Private method to handle the final UGC post request. CRITICAL FIX: Avoids ARTICLE category if not using an image to prevent 402 errors. """ - + message = job_posting.linkedin_post_formated_data if len(message)>=3000: message=message[:2900]+"...." - + # --- 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 + # 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 @@ -305,7 +306,7 @@ class LinkedInService: "shareMediaCategory": media_category, } } - + if media_list and media_category == "IMAGE": specific_content["com.linkedin.ugc.ShareContent"]["media"] = media_list @@ -319,13 +320,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 "" @@ -339,7 +340,7 @@ class LinkedInService: def create_job_post_with_image(self, job_posting, image_file, person_urn, asset_urn): """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.") @@ -349,7 +350,7 @@ class LinkedInService: "status": "READY", "media": asset_urn, "description": {"text": job_posting.title}, - "originalUrl": job_posting.application_url, + "originalUrl": job_posting.application_url, "title": {"text": "Apply Now"} }] @@ -380,7 +381,7 @@ class LinkedInService: image_upload = job_posting.post_images.first().post_image has_image = image_upload is not None except Exception: - pass + pass if has_image: try: @@ -389,25 +390,25 @@ class LinkedInService: asset_urn = upload_info['asset'] self.upload_image_to_linkedin( upload_info['upload_url'], - image_upload, + image_upload, asset_urn ) - + 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}") - has_image = False + has_image = False # === FALLBACK TO PURE TEXT POST (shareMediaCategory: NONE) === - # The _send_ugc_post method now ensures this is a PURE text post + # 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: @@ -417,4 +418,4 @@ class LinkedInService: 'success': False, 'error': str(e), 'status_code': status_code - } \ No newline at end of file + } diff --git a/recruitment/management/commands/init_settings.py b/recruitment/management/commands/init_settings.py new file mode 100644 index 0000000..98a9f03 --- /dev/null +++ b/recruitment/management/commands/init_settings.py @@ -0,0 +1,19 @@ +from django.core.management.base import BaseCommand +from recruitment.utils import initialize_default_settings + + +class Command(BaseCommand): + help = 'Initialize Zoom and LinkedIn settings in the database from current hardcoded values' + + def handle(self, *args, **options): + self.stdout.write('Initializing settings in database...') + + try: + initialize_default_settings() + self.stdout.write( + self.style.SUCCESS('Successfully initialized settings in database') + ) + except Exception as e: + self.stdout.write( + self.style.ERROR(f'Error initializing settings: {e}') + ) diff --git a/recruitment/migrations/0002_settings.py b/recruitment/migrations/0002_settings.py new file mode 100644 index 0000000..3ab848c --- /dev/null +++ b/recruitment/migrations/0002_settings.py @@ -0,0 +1,30 @@ +# Generated by Django 5.2.6 on 2025-12-03 17:52 + +import django_extensions.db.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Settings', + 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')), + ('key', models.CharField(help_text='Unique key for the setting', max_length=100, unique=True, verbose_name='Setting Key')), + ('value', models.TextField(help_text='Value for the setting', verbose_name='Setting Value')), + ], + options={ + 'verbose_name': 'Setting', + 'verbose_name_plural': 'Settings', + 'ordering': ['key'], + }, + ), + ] diff --git a/recruitment/models.py b/recruitment/models.py index 71c220a..8bb2665 100644 --- a/recruitment/models.py +++ b/recruitment/models.py @@ -490,7 +490,7 @@ 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() @@ -580,7 +580,7 @@ class Person(Base): # 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) @@ -622,7 +622,7 @@ class Person(Base): content_type = ContentType.objects.get_for_model(self.__class__) return Document.objects.filter(content_type=content_type, object_id=self.id) - + @@ -2120,7 +2120,7 @@ class HiringAgency(Base): # 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) @@ -2739,3 +2739,24 @@ class Document(Base): return "" +class Settings(Base): + """Model to store key-value pair settings""" + + key = models.CharField( + max_length=100, + unique=True, + verbose_name=_("Setting Key"), + help_text=_("Unique key for the setting"), + ) + value = models.TextField( + verbose_name=_("Setting Value"), + help_text=_("Value for the setting"), + ) + + class Meta: + verbose_name = _("Setting") + verbose_name_plural = _("Settings") + ordering = ["key"] + + def __str__(self): + return f"{self.key}: {self.value[:50]}{'...' if len(self.value) > 50 else ''}" \ No newline at end of file diff --git a/recruitment/signals.py b/recruitment/signals.py index 3e7f408..4d50b7a 100644 --- a/recruitment/signals.py +++ b/recruitment/signals.py @@ -44,31 +44,74 @@ def format_job(sender, instance, created, **kwargs): # hook='myapp.tasks.email_sent_callback' # Optional callback ) - else: - existing_schedule = Schedule.objects.filter( - func="recruitment.tasks.form_close", - args=f"[{instance.pk}]", - schedule_type=Schedule.ONCE, + # Enhanced reminder scheduling logic + if instance.status == "ACTIVE" and instance.application_deadline: + # Schedule 1-day reminder + one_day_schedule = Schedule.objects.filter( + name=f"one_day_reminder_{instance.pk}" ).first() - if instance.STATUS_CHOICES == "ACTIVE" and instance.application_deadline: - if not existing_schedule: - # Create a new schedule if one does not exist - schedule( - "recruitment.tasks.form_close", - instance.pk, - schedule_type=Schedule.ONCE, - next_run=instance.application_deadline, - repeats=-1, # Ensure the schedule is deleted after it runs - name=f"job_closing_{instance.pk}", # Add a name for easier lookup - ) - elif existing_schedule.next_run != instance.application_deadline: - # Update an existing schedule's run time - existing_schedule.next_run = instance.application_deadline - existing_schedule.save() - elif existing_schedule: - # If the instance is no longer active, delete the scheduled task - existing_schedule.delete() + one_day_before = instance.application_deadline - timedelta(days=1) + if not one_day_schedule: + schedule( + "recruitment.tasks.send_one_day_reminder", + instance.pk, + schedule_type=Schedule.ONCE, + next_run=one_day_before, + repeats=-1, + name=f"one_day_reminder_{instance.pk}", + ) + elif one_day_schedule.next_run != one_day_before: + one_day_schedule.next_run = one_day_before + one_day_schedule.save() + + # Schedule 15-minute reminder + fifteen_min_schedule = Schedule.objects.filter( + name=f"fifteen_min_reminder_{instance.pk}" + ).first() + + fifteen_min_before = instance.application_deadline - timedelta(minutes=15) + if not fifteen_min_schedule: + schedule( + "recruitment.tasks.send_fifteen_minute_reminder", + instance.pk, + schedule_type=Schedule.ONCE, + next_run=fifteen_min_before, + repeats=-1, + name=f"fifteen_min_reminder_{instance.pk}", + ) + elif fifteen_min_schedule.next_run != fifteen_min_before: + fifteen_min_schedule.next_run = fifteen_min_before + fifteen_min_schedule.save() + + # Schedule job closing notification (enhanced form_close) + closing_schedule = Schedule.objects.filter( + name=f"job_closing_{instance.pk}" + ).first() + + if not closing_schedule: + schedule( + "recruitment.tasks.send_job_closed_notification", + instance.pk, + schedule_type=Schedule.ONCE, + next_run=instance.application_deadline, + repeats=-1, + name=f"job_closing_{instance.pk}", + ) + elif closing_schedule.next_run != instance.application_deadline: + closing_schedule.next_run = instance.application_deadline + closing_schedule.save() + + else: + # Clean up all reminder schedules if job is no longer active + reminder_schedules = Schedule.objects.filter( + name__in=[f"one_day_reminder_{instance.pk}", + f"fifteen_min_reminder_{instance.pk}", + f"job_closing_{instance.pk}"] + ) + if reminder_schedules.exists(): + reminder_schedules.delete() + logger.info(f"Cleaned up reminder schedules for job {instance.pk}") # @receiver(post_save, sender=JobPosting) diff --git a/recruitment/tasks.py b/recruitment/tasks.py index 23bbf0c..4f7d8c2 100644 --- a/recruitment/tasks.py +++ b/recruitment/tasks.py @@ -14,6 +14,8 @@ from . models import JobPosting from django.utils import timezone from . models import BulkInterviewTemplate,Interview,Message,ScheduledInterview from django.contrib.auth import get_user_model +from .utils import get_setting + User = get_user_model() # Add python-docx import for Word document processing try: @@ -26,8 +28,11 @@ except ImportError: logger = logging.getLogger(__name__) -OPENROUTER_API_KEY ='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' -OPENROUTER_MODEL = 'qwen/qwen-2.5-72b-instruct' +OPENROUTER_API_URL = get_setting('OPENROUTER_API_URL') +OPENROUTER_API_KEY = get_setting('OPENROUTER_API_KEY') +OPENROUTER_MODEL = get_setting('OPENROUTER_MODEL') +# OPENROUTER_API_KEY ='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' +# OPENROUTER_MODEL = 'qwen/qwen-2.5-72b-instruct' # OPENROUTER_MODEL = 'qwen/qwen-2.5-7b-instruct' # OPENROUTER_MODEL = 'openai/gpt-oss-20b' @@ -193,7 +198,7 @@ def format_job_description(pk): def ai_handler(prompt): print("model call") response = requests.post( - url="https://openrouter.ai/api/v1/chat/completions", + url=OPENROUTER_API_URL, headers={ "Authorization": f"Bearer {OPENROUTER_API_KEY}", "Content-Type": "application/json", @@ -668,54 +673,25 @@ def handle_resume_parsing_and_scoring(pk: int): from django.utils import timezone -def create_interview_and_meeting( - application_id, - job_id, - schedule_id, - slot_date, - slot_time, - duration -): +def create_interview_and_meeting(schedule_id): """ Synchronous task for a single interview slot, dispatched by django-q. """ try: - application = Application.objects.get(pk=application_id) - job = JobPosting.objects.get(pk=job_id) - schedule = BulkInterviewTemplate.objects.get(pk=schedule_id) + schedule = ScheduledInterview.objects.get(pk=schedule_id) + interview = schedule.interview - interview_datetime = timezone.make_aware(datetime.combine(slot_date, slot_time)) - meeting_topic = schedule.topic - - result = create_zoom_meeting(meeting_topic, interview_datetime, duration) + result = create_zoom_meeting(interview.topic, interview.start_time, interview.duration) if result["status"] == "success": - interview = Interview.objects.create( - topic=meeting_topic, - start_time=interview_datetime, - duration=duration, - meeting_id=result["meeting_details"]["meeting_id"], - details_url=result["meeting_details"]["join_url"], - zoom_gateway_response=result["zoom_gateway_response"], - host_email=result["meeting_details"]["host_email"], - password=result["meeting_details"]["password"], - location_type="Remote" - ) - schedule = ScheduledInterview.objects.create( - application=application, - job=job, - schedule=schedule, - interview_date=slot_date, - interview_time=slot_time, - interview=interview - ) - schedule.interview = interview - schedule.status = "scheduled" - - schedule.save() - + interview.meeting_id = result["meeting_details"]["meeting_id"] + interview.details_url = result["meeting_details"]["join_url"] + interview.zoom_gateway_response = result["zoom_gateway_response"] + interview.host_email = result["meeting_details"]["host_email"] + interview.password = result["meeting_details"]["password"] + interview.save() logger.info(f"Successfully scheduled interview for {Application.name}") - return True # Task succeeded + return True else: # Handle Zoom API failure (e.g., log it or notify administrator) logger.error(f"Zoom API failed for {Application.name}: {result['message']}") @@ -1121,4 +1097,203 @@ def generate_and_save_cv_zip(job_posting_id): job.zip_created = True # Assuming you added a BooleanField for tracking completion job.save() - return f"Successfully created zip for Job ID {job.slug} {job_posting_id}" \ No newline at end of file + return f"Successfully created zip for Job ID {job.slug} {job_posting_id}" + + +def send_one_day_reminder(job_id): + """ + Send email reminder 1 day before job application deadline. + """ + try: + job = JobPosting.objects.get(pk=job_id) + + # Only send if job is still active + if job.status != 'ACTIVE': + logger.info(f"Job {job_id} is no longer active, skipping 1-day reminder") + return + + # Get application count + application_count = Application.objects.filter(job=job).count() + + # Determine recipients + recipients = [] + if job.assigned_to: + recipients.append(job.assigned_to.email) + + # Add admin users as fallback or additional recipients + admin_users = User.objects.filter(is_staff=True) + if not recipients: # If no assigned user, send to all admins + recipients = [admin.email for admin in admin_users] + + if not recipients: + logger.warning(f"No recipients found for job {job_id} 1-day reminder") + return + + # Create email content + subject = f"Reminder: Job '{job.title}' closes tomorrow" + + html_message = f""" + + +

    Job Closing Reminder

    +

    Job Title: {job.title}

    +

    Application Deadline: {job.application_deadline.strftime('%B %d, %Y')}

    +

    Current Applications: {application_count}

    +

    Status: {job.get_status_display()}

    + +

    This job posting will close tomorrow. Please review any pending applications before the deadline.

    + +

    View Job Details

    + +
    +

    This is an automated reminder from the KAAUH Recruitment System.

    + + + """ + + # Send email to each recipient + for recipient_email in recipients: + _task_send_individual_email(subject, html_message, recipient_email, None, None, None) + + logger.info(f"Sent 1-day reminder for job {job_id} to {len(recipients)} recipients") + + except JobPosting.DoesNotExist: + logger.error(f"Job {job_id} not found for 1-day reminder") + except Exception as e: + logger.error(f"Error sending 1-day reminder for job {job_id}: {str(e)}") + + +def send_fifteen_minute_reminder(job_id): + """ + Send final email reminder 15 minutes before job application deadline. + """ + try: + job = JobPosting.objects.get(pk=job_id) + + # Only send if job is still active + if job.status != 'ACTIVE': + logger.info(f"Job {job_id} is no longer active, skipping 15-minute reminder") + return + + # Get application count + application_count = Application.objects.filter(job=job).count() + + # Determine recipients + recipients = [] + if job.assigned_to: + recipients.append(job.assigned_to.email) + + # Add admin users as fallback or additional recipients + admin_users = User.objects.filter(is_staff=True) + if not recipients: # If no assigned user, send to all admins + recipients = [admin.email for admin in admin_users] + + if not recipients: + logger.warning(f"No recipients found for job {job_id} 15-minute reminder") + return + + # Create email content + subject = f"FINAL REMINDER: Job '{job.title}' closes in 15 minutes" + + html_message = f""" + + +

    ⚠️ FINAL REMINDER

    +

    Job Title: {job.title}

    +

    Application Deadline: {job.application_deadline.strftime('%B %d, %Y at %I:%M %p')}

    +

    Current Applications: {application_count}

    +

    Status: {job.get_status_display()}

    + +

    This job posting will close in 15 minutes. This is your final reminder to review any pending applications.

    + +

    View Job Details Now

    + +
    +

    This is an automated final reminder from the KAAUH Recruitment System.

    + + + """ + + # Send email to each recipient + for recipient_email in recipients: + _task_send_individual_email(subject, html_message, recipient_email, None, None, None) + + logger.info(f"Sent 15-minute reminder for job {job_id} to {len(recipients)} recipients") + + except JobPosting.DoesNotExist: + logger.error(f"Job {job_id} not found for 15-minute reminder") + except Exception as e: + logger.error(f"Error sending 15-minute reminder for job {job_id}: {str(e)}") + + +def send_job_closed_notification(job_id): + """ + Send notification when job has closed and update job status. + """ + try: + job = JobPosting.objects.get(pk=job_id) + + # Only proceed if job is currently active + if job.status != 'ACTIVE': + logger.info(f"Job {job_id} is already not active, skipping closed notification") + return + + # Get final application count + application_count = Application.objects.filter(job=job).count() + + # Update job status to closed + job.status = 'CLOSED' + job.save(update_fields=['status']) + + # Also close the form template + if job.template_form: + job.template_form.is_active = False + job.template_form.save(update_fields=['is_active']) + + # Determine recipients + recipients = [] + if job.assigned_to: + recipients.append(job.assigned_to.email) + + # Add admin users as fallback or additional recipients + admin_users = User.objects.filter(is_staff=True) + if not recipients: # If no assigned user, send to all admins + recipients = [admin.email for admin in admin_users] + + if not recipients: + logger.warning(f"No recipients found for job {job_id} closed notification") + return + + # Create email content + subject = f"Job '{job.title}' has closed - {application_count} applications received" + + html_message = f""" + + +

    Job Closed Notification

    +

    Job Title: {job.title}

    +

    Application Deadline: {job.application_deadline.strftime('%B %d, %Y at %I:%M %p')}

    +

    Total Applications Received: {application_count}

    +

    Status: {job.get_status_display()}

    + +

    The job posting has been automatically closed and is no longer accepting applications.

    + +

    View Job Details

    +

    View Applications

    + +
    +

    This is an automated notification from the KAAUH Recruitment System.

    + + + """ + + # Send email to each recipient + for recipient_email in recipients: + _task_send_individual_email(subject, html_message, recipient_email, None, None, None) + + logger.info(f"Sent job closed notification for job {job_id} to {len(recipients)} recipients") + + except JobPosting.DoesNotExist: + logger.error(f"Job {job_id} not found for closed notification") + except Exception as e: + logger.error(f"Error sending job closed notification for job {job_id}: {str(e)}") diff --git a/recruitment/urls.py b/recruitment/urls.py index c5b4ecb..9c2c256 100644 --- a/recruitment/urls.py +++ b/recruitment/urls.py @@ -264,11 +264,11 @@ urlpatterns = [ # path('api/templates/save/', views.save_form_template, name='save_form_template'), # path('api/templates//', views.load_form_template, name='load_form_template'), # path('api/templates//delete/', views.delete_form_template, name='delete_form_template'), - # path( - # "jobs//calendar/", - # views.interview_calendar_view, - # name="interview_calendar", - # ), + path( + "jobs//calendar/", + views.interview_calendar_view, + name="interview_calendar", + ), # path( # "jobs//calendar/interview//", # views.interview_detail_view, @@ -283,8 +283,13 @@ urlpatterns = [ name="user_profile_image_update", ), path("easy_logs/", views.easy_logs, name="easy_logs"), - path('settings/',views.settings,name="settings"), - path("settings/admin/", views.admin_settings, name="admin_settings"), + path("settings/", views.admin_settings, name="admin_settings"), + path("settings/list/", views.settings_list, name="settings_list"), + path("settings/create/", views.settings_create, name="settings_create"), + path("settings//", views.settings_detail, name="settings_detail"), + path("settings//update/", views.settings_update, name="settings_update"), + path("settings//delete/", views.settings_delete, name="settings_delete"), + path("settings//toggle/", views.settings_toggle_status, name="settings_toggle_status"), path("staff/create", views.create_staff_user, name="create_staff_user"), path( "set_staff_password//", diff --git a/recruitment/utils.py b/recruitment/utils.py index 919c8a1..95e9d68 100644 --- a/recruitment/utils.py +++ b/recruitment/utils.py @@ -1,7 +1,6 @@ -# import os -# import fitz # PyMuPDF -# import spacy -# import requests +""" +Utility functions for recruitment app +""" from recruitment import models from django.conf import settings from datetime import datetime, timedelta, time, date @@ -9,41 +8,282 @@ from django.utils import timezone from .models import ScheduledInterview from django.template.loader import render_to_string from django.core.mail import send_mail -import random -# nlp = spacy.load("en_core_web_sm") -# def extract_text_from_pdf(pdf_path): -# text = "" -# with fitz.open(pdf_path) as doc: -# for page in doc: -# text += page.get_text() -# return text - -# def extract_summary_from_pdf(pdf_path): -# if not os.path.exists(pdf_path): -# return {'error': 'File not found'} - -# text = extract_text_from_pdf(pdf_path) -# doc = nlp(text) -# summary = { -# 'name': doc.ents[0].text if doc.ents else '', -# 'skills': [chunk.text for chunk in doc.noun_chunks if len(chunk.text.split()) > 1], -# 'summary': text[:500] -# } -# return summary - -import requests -from PyPDF2 import PdfReader import os import json import logging +import requests +from PyPDF2 import PdfReader +from django.conf import settings +from .models import Settings, Application + logger = logging.getLogger(__name__) -OPENROUTER_API_KEY ='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' -OPENROUTER_MODEL = 'qwen/qwen-2.5-72b-instruct:free' +def get_setting(key, default=None): + """ + Get a setting value from the database, with fallback to environment variables and default + + Args: + key (str): The setting key to retrieve + default: Default value if not found in database or environment + + Returns: + The setting value from database, environment variable, or default + """ + try: + # First try to get from database + setting = Settings.objects.get(key=key) + return setting.value + except Settings.DoesNotExist: + # Fall back to environment variable + env_value = os.getenv(key) + if env_value is not None: + return env_value + # Finally return the default + return default + except Exception: + # In case of any database error, fall back to environment or default + env_value = os.getenv(key) + if env_value is not None: + return env_value + return default + + +def set_setting(key, value): + """ + Set a setting value in the database + + Args: + key (str): The setting key + value: The setting value + + Returns: + Settings: The created or updated setting object + """ + setting, created = Settings.objects.update_or_create( + key=key, + defaults={'value': str(value)} + ) + return setting + + +def get_zoom_config(): + """ + Get all Zoom configuration settings + + Returns: + dict: Dictionary containing all Zoom settings + """ + return { + 'ZOOM_ACCOUNT_ID': get_setting('ZOOM_ACCOUNT_ID'), + 'ZOOM_CLIENT_ID': get_setting('ZOOM_CLIENT_ID'), + 'ZOOM_CLIENT_SECRET': get_setting('ZOOM_CLIENT_SECRET'), + 'ZOOM_WEBHOOK_API_KEY': get_setting('ZOOM_WEBHOOK_API_KEY'), + 'SECRET_TOKEN': get_setting('SECRET_TOKEN'), + } + + +def get_linkedin_config(): + """ + Get all LinkedIn configuration settings + + Returns: + dict: Dictionary containing all LinkedIn settings + """ + return { + 'LINKEDIN_CLIENT_ID': get_setting('LINKEDIN_CLIENT_ID'), + 'LINKEDIN_CLIENT_SECRET': get_setting('LINKEDIN_CLIENT_SECRET'), + 'LINKEDIN_REDIRECT_URI': get_setting('LINKEDIN_REDIRECT_URI'), + } + + +def get_applications_from_request(request): + """ + Extract application IDs from request and return Application objects + """ + application_ids = request.POST.getlist("candidate_ids") + if application_ids: + return Application.objects.filter(id__in=application_ids) + return Application.objects.none() + + +def schedule_interviews(schedule, applications): + """ + Schedule interviews for multiple applications based on a schedule template + """ + from .models import ScheduledInterview + from datetime import datetime, timedelta + + scheduled_interviews = [] + available_slots = get_available_time_slots(schedule) + + for i, application in enumerate(applications): + if i < len(available_slots): + slot = available_slots[i] + interview = ScheduledInterview.objects.create( + application=application, + job=schedule.job, + interview_date=slot['date'], + interview_time=slot['time'], + status='scheduled' + ) + scheduled_interviews.append(interview) + + return scheduled_interviews + + +def get_available_time_slots(schedule): + """ + Calculate available time slots for interviews based on schedule + """ + from datetime import datetime, timedelta, time + import calendar + + slots = [] + current_date = schedule.start_date + + while current_date <= schedule.end_date: + # Check if current date is a working day + weekday = current_date.weekday() + if str(weekday) in schedule.working_days: + # Calculate slots for this day + day_slots = _calculate_day_slots(schedule, current_date) + slots.extend(day_slots) + + current_date += timedelta(days=1) + + return slots + + +def _calculate_day_slots(schedule, date): + """ + Calculate available slots for a specific day + """ + from datetime import datetime, timedelta, time + + slots = [] + current_time = schedule.start_time + end_time = schedule.end_time + + # Convert to datetime for easier calculation + current_datetime = datetime.combine(date, current_time) + end_datetime = datetime.combine(date, end_time) + + # Calculate break times + break_start = None + break_end = None + if schedule.break_start_time and schedule.break_end_time: + break_start = datetime.combine(date, schedule.break_start_time) + break_end = datetime.combine(date, schedule.break_end_time) + + while current_datetime + timedelta(minutes=schedule.interview_duration) <= end_datetime: + # Skip break time + if break_start and break_end: + if break_start <= current_datetime < break_end: + current_datetime = break_end + continue + + slots.append({ + 'date': date, + 'time': current_datetime.time() + }) + + # Move to next slot + current_datetime += timedelta(minutes=schedule.interview_duration + schedule.buffer_time) + + return slots + + +def json_to_markdown_table(data): + """ + Convert JSON data to markdown table format + """ + if not data: + return "" + + if isinstance(data, list): + if not data: + return "" + + # Get headers from first item + first_item = data[0] + if isinstance(first_item, dict): + headers = list(first_item.keys()) + rows = [] + for item in data: + row = [] + for header in headers: + value = item.get(header, '') + if isinstance(value, (dict, list)): + value = str(value) + row.append(str(value)) + rows.append(row) + else: + # Simple list + headers = ['Value'] + rows = [[str(item)] for item in data] + elif isinstance(data, dict): + headers = ['Key', 'Value'] + rows = [] + for key, value in data.items(): + if isinstance(value, (dict, list)): + value = str(value) + rows.append([str(key), str(value)]) + else: + # Single value + return str(data) + + # Build markdown table + if not headers or not rows: + return str(data) + + # Header row + table = "| " + " | ".join(headers) + " |\n" + + # Separator row + table += "| " + " | ".join(["---"] * len(headers)) + " |\n" + + # Data rows + for row in rows: + # Escape pipe characters in cells + escaped_row = [cell.replace("|", "\\|") for cell in row] + table += "| " + " | ".join(escaped_row) + " |\n" + + return table + + +def initialize_default_settings(): + """ + Initialize default settings in the database from current hardcoded values + This should be run once to migrate existing settings + """ + # Zoom settings + zoom_settings = { + 'ZOOM_ACCOUNT_ID': getattr(settings, 'ZOOM_ACCOUNT_ID', ''), + 'ZOOM_CLIENT_ID': getattr(settings, 'ZOOM_CLIENT_ID', ''), + 'ZOOM_CLIENT_SECRET': getattr(settings, 'ZOOM_CLIENT_SECRET', ''), + 'ZOOM_WEBHOOK_API_KEY': getattr(settings, 'ZOOM_WEBHOOK_API_KEY', ''), + 'SECRET_TOKEN': getattr(settings, 'SECRET_TOKEN', ''), + } + + # LinkedIn settings + linkedin_settings = { + 'LINKEDIN_CLIENT_ID': getattr(settings, 'LINKEDIN_CLIENT_ID', ''), + 'LINKEDIN_CLIENT_SECRET': getattr(settings, 'LINKEDIN_CLIENT_SECRET', ''), + 'LINKEDIN_REDIRECT_URI': getattr(settings, 'LINKEDIN_REDIRECT_URI', ''), + } + + # Create settings if they don't exist + all_settings = {**zoom_settings, **linkedin_settings} + + for key, value in all_settings.items(): + if value: # Only set if value exists + set_setting(key, value) + + + +##################################### -if not OPENROUTER_API_KEY: - logger.warning("OPENROUTER_API_KEY not set. Resume scoring will be skipped.") def extract_text_from_pdf(file_path): print("text extraction") @@ -60,8 +300,12 @@ def extract_text_from_pdf(file_path): def score_resume_with_openrouter(prompt): print("model call") + OPENROUTER_API_URL = get_setting('OPENROUTER_API_URL') + OPENROUTER_API_KEY = get_setting('OPENROUTER_API_KEY') + OPENROUTER_MODEL = get_setting('OPENROUTER_MODEL') + response = requests.post( - url="https://openrouter.ai/api/v1/chat/completions", + url=OPENROUTER_API_URL, headers={ "Authorization": f"Bearer {OPENROUTER_API_KEY}", "Content-Type": "application/json", @@ -75,7 +319,6 @@ def score_resume_with_openrouter(prompt): # print(response.status_code) # print(response.json()) res = {} - print(response.status_code) if response.status_code == 200: res = response.json() content = res["choices"][0]['message']['content'] @@ -123,21 +366,22 @@ def dashboard_callback(request, context): def get_access_token(): """Obtain an access token using server-to-server OAuth.""" - client_id = settings.ZOOM_CLIENT_ID - client_secret = settings.ZOOM_CLIENT_SECRET + ZOOM_ACCOUNT_ID = get_setting("ZOOM_ACCOUNT_ID") + ZOOM_CLIENT_ID = get_setting("ZOOM_CLIENT_ID") + ZOOM_CLIENT_SECRET = get_setting("ZOOM_CLIENT_SECRET") + ZOOM_AUTH_URL = get_setting("ZOOM_AUTH_URL") - auth_url = "https://zoom.us/oauth/token" headers = { "Content-Type": "application/x-www-form-urlencoded", } data = { "grant_type": "account_credentials", - "account_id": settings.ZOOM_ACCOUNT_ID, + "account_id": ZOOM_ACCOUNT_ID, } - auth = (client_id, client_secret) + auth = (ZOOM_CLIENT_ID, ZOOM_CLIENT_SECRET) - response = requests.post(auth_url, headers=headers, data=data, auth=auth) + response = requests.post(ZOOM_AUTH_URL, headers=headers, data=data, auth=auth) if response.status_code == 200: return response.json().get("access_token") @@ -181,8 +425,9 @@ def create_zoom_meeting(topic, start_time, duration): "Authorization": f"Bearer {access_token}", "Content-Type": "application/json" } + ZOOM_MEETING_URL = get_setting('ZOOM_MEETING_URL') response = requests.post( - "https://api.zoom.us/v2/users/me/meetings", + ZOOM_MEETING_URL, headers=headers, json=meeting_details ) @@ -585,19 +830,15 @@ def get_applications_from_request(request): def update_meeting(instance, updated_data): result = update_zoom_meeting(instance.meeting_id, updated_data) if result["status"] == "success": - # Fetch the latest details from Zoom after successful update details_result = get_zoom_meeting_details(instance.meeting_id) - + if details_result["status"] == "success": zoom_details = details_result["meeting_details"] - # Update instance with fetched details instance.topic = zoom_details.get("topic", instance.topic) - instance.duration = zoom_details.get("duration", instance.duration) - # instance.details_url = zoom_details.get("join_url", instance.details_url) + instance.details_url = zoom_details.get("join_url", instance.details_url) instance.password = zoom_details.get("password", instance.password) - # Corrected status assignment: instance.status, not instance.password instance.status = zoom_details.get("status") instance.zoom_gateway_response = details_result.get("meeting_details") # Store full response diff --git a/recruitment/views.py b/recruitment/views.py index 7a42f3a..2f4da27 100644 --- a/recruitment/views.py +++ b/recruitment/views.py @@ -40,6 +40,7 @@ from .forms import ( RemoteInterviewForm, OnsiteInterviewForm, BulkInterviewTemplateForm, + SettingsForm, InterviewCancelForm ) from .utils import generate_random_password @@ -107,15 +108,16 @@ from django.views.generic import ( DeleteView, ) from .utils import ( - create_zoom_meeting, - delete_zoom_meeting, get_applications_from_request, - update_meeting, - update_zoom_meeting, - get_zoom_meeting_details, schedule_interviews, get_available_time_slots, ) +from .zoom_api import ( + create_zoom_meeting, + delete_zoom_meeting, + update_zoom_meeting, + get_zoom_meeting_details, +) from django.views.decorators.csrf import ensure_csrf_cookie from django.views.decorators.http import require_POST from .models import ( @@ -141,7 +143,8 @@ from .models import ( Message, Document, Interview, - BulkInterviewTemplate + BulkInterviewTemplate, + Settings ) @@ -242,7 +245,7 @@ class PersonUpdateView( UpdateView,LoginRequiredMixin,StaffOrAgencyRequiredMixin success_url = reverse_lazy("person_list") def form_valid(self, form): - + if self.request.POST.get("view") == "portal": form.save() return redirect("agency_portal_persons_list") @@ -534,7 +537,7 @@ def job_detail(request, slug): job_status = status_form.cleaned_data["status"] form_template = job.form_template if job_status == "ACTIVE": - + form_template.is_active = True form_template.save(update_fields=["is_active"]) else: @@ -625,7 +628,7 @@ def job_detail(request, slug): if avg_t_in_exam_duration else 0 ) - + category_data = ( applications.filter( ai_analysis_data__analysis_data_en__category__isnull=False @@ -640,7 +643,7 @@ def job_detail(request, slug): ) .order_by("ai_analysis_data__analysis_data_en__category") ) - + # Prepare data for Chart.js categories = [item["category"] for item in category_data] applications_count = [item["application_count"] for item in category_data] @@ -757,7 +760,7 @@ def download_ready_cvs(request, slug): if job.status != 'CLOSED': messages.info('request',_("You can request bulk CV dowload only if the job status is changed to CLOSED")) return redirect('job_detail',kwargs={slug:job.slug}) - + if not job.applications.exists(): messages.warning(request, _("No applications found for this job. ZIP file download unavailable.")) return redirect('job_detail', slug=slug) @@ -1369,14 +1372,14 @@ def application_submit(request, template_slug): # address = submission.responses.get(field__label="Address") gpa = submission.responses.get(field__label="GPA") if gpa and gpa.value: - gpa_str = gpa.value.replace("/","").strip() + gpa_str = gpa.value.replace("/","").strip() if not re.match(r'^\d+(\.\d+)?$', gpa_str): # --- FIX APPLIED HERE --- return JsonResponse( {"success": False, "message": _("GPA must be a numeric value.")} ) - + try: gpa_float = float(gpa_str) except ValueError: @@ -1390,7 +1393,7 @@ def application_submit(request, template_slug): return JsonResponse( {"success": False, "message": _("GPA must be between 0.0 and 4.0.")} ) - + resume = submission.responses.get(field__label="Resume Upload") @@ -2103,7 +2106,8 @@ def applications_document_review_view(request, slug): @require_POST @staff_user_required def reschedule_meeting_for_application(request, slug): - schedule_interview = get_object_or_404(ScheduledInterview, slug=slug) + from .utils import update_meeting + schedule = get_object_or_404(ScheduledInterview, slug=slug) if request.method == "POST": form = ScheduledInterviewForm(request.POST) if form.is_valid(): @@ -2115,7 +2119,7 @@ def reschedule_meeting_for_application(request, slug): "start_time": start_time.isoformat() + "Z", "duration": duration, } - result = update_meeting(schedule_interview.interview, updated_data) + result = update_meeting(schedule.interview, updated_data) if result["status"] == "success": messages.success(request, result["message"]) @@ -2124,7 +2128,7 @@ def reschedule_meeting_for_application(request, slug): else: print(form.errors) messages.error(request, "Invalid data submitted.") - return redirect("interview_detail", slug=schedule_interview.slug) + return redirect("interview_detail", slug=schedule.slug) # context = {"job": job, "application": application, "meeting": meeting, "form": form} # return render(request, "meetings/reschedule_meeting.html", context) @@ -3420,7 +3424,7 @@ def deactivate_agency(request, slug): messages.success(request, f'Agency "{agency.name}" deactivated successfully!') return redirect("agency_detail", slug=agency.slug) -@login_required +@login_required @staff_user_required def agency_detail(request, slug): """View details of a specific hiring agency""" @@ -4746,6 +4750,7 @@ def message_list(request): status_filter = request.GET.get("status", "") message_type_filter = request.GET.get("type", "") search_query = request.GET.get("q", "") + job_filter = request.GET.get("job_filter", "") # Base queryset - get messages where user is either sender or recipient message_list = ( @@ -4754,16 +4759,20 @@ def message_list(request): .order_by("-created_at") ) + jobs = JobPosting.objects.all() + # Apply filters if status_filter: if status_filter == "read": message_list = message_list.filter(is_read=True) elif status_filter == "unread": message_list = message_list.filter(is_read=False) - if message_type_filter: message_list = message_list.filter(message_type=message_type_filter) + if request.user.user_type == "staff" and job_filter: + job = get_object_or_404(JobPosting, pk=job_filter) + message_list = message_list.filter(job=job) if search_query: message_list = message_list.filter( Q(subject__icontains=search_query) | Q(content__icontains=search_query) @@ -4785,6 +4794,8 @@ def message_list(request): "status_filter": status_filter, "type_filter": message_type_filter, "search_query": search_query, + "job_filter": job_filter, + "jobs": jobs, } if request.user.user_type != "staff": return render(request, "messages/application_message_list.html", context) @@ -4813,14 +4824,14 @@ 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) @login_required def message_create(request): """Create a new message""" - from .email_service import EmailService + from .email_service import EmailService if request.method == "POST": form = MessageForm(request.user, request.POST) @@ -4832,7 +4843,7 @@ def message_create(request): if message.recipient and message.recipient.email: if request.user.user_type != "staff": - message=message.content + message=message.content else: message=message.content.append(f"\n\n Sent by: {request.user.get_full_name()} ({request.user.email})") try: @@ -4864,34 +4875,34 @@ def message_create(request): messages.error(request, "Please correct the errors below.") else: form = MessageForm(request.user) - + form.fields["job"].widget.attrs.update({"hx-get": "/en/messages/create/", "hx-target": "#id_recipient", "hx-select": "#id_recipient", "hx-swap": "outerHTML",}) if request.user.user_type == "staff": job_id = request.GET.get("job") - if job_id: + if job_id: job = get_object_or_404(JobPosting, id=job_id) applications=job.applications.all() applicant_users = User.objects.filter(person_profile__in=applications.values_list('person', flat=True)) agency_users = User.objects.filter(id__in=AgencyJobAssignment.objects.filter(job=job).values_list('agency__user', flat=True)) form.fields["recipient"].queryset = applicant_users | agency_users - + # form.fields["recipient"].queryset = User.objects.filter(person_profile__) else: - - form.fields['recipient'].widget = HiddenInput() + + form.fields['recipient'].widget = HiddenInput() if request.method == "GET" and "HX-Request" in request.headers and request.user.user_type in ["candidate","agency"]: print() job_id = request.GET.get("job") - if job_id: + if job_id: job = get_object_or_404(JobPosting, id=job_id) form.fields["recipient"].queryset = User.objects.filter(id=job.assigned_to.id) form.fields["recipient"].initial = job.assigned_to - - + + context = { "form": form, } @@ -5400,15 +5411,15 @@ def interview_create_remote(request, application_slug): if form.is_valid(): try: with transaction.atomic(): - # Create ScheduledInterview record schedule = ScheduledInterview.objects.create(application=application,job=application.job,interview_date=form.cleaned_data["interview_date"],interview_time=form.cleaned_data["interview_time"]) - async_task( - "recruitment.tasks.create_interview_and_meeting", - application.pk, application.job.pk, schedule.pk, schedule.interview_date,schedule.interview_time, form.cleaned_data['duration'] - ) + start_time = timezone.make_aware(datetime.combine(schedule.interview_date, schedule.interview_time)) + interview = Interview.objects.create(topic=form.cleaned_data["topic"],location_type="Remote",start_time=start_time,duration=form.cleaned_data['duration']) + schedule.interview = interview + schedule.save() + async_task("recruitment.tasks.create_interview_and_meeting",schedule.pk) messages.success(request, f"Remote interview scheduled for {application.name}") - return redirect('interview_detail', slug=schedule.slug) + return redirect('applications_interview_view', slug=application_slug) except Exception as e: messages.error(request, f"Error creating remote interview: {str(e)}") @@ -5443,7 +5454,7 @@ def interview_create_onsite(request, application_slug): 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"]) messages.success(request, f"Onsite interview scheduled for {application.name}") - return redirect('interview_detail', slug=schedule.slug) + return redirect('applications_interview_view', slug=application_slug) except Exception as e: messages.error(request, f"Error creating onsite interview: {str(e)}") @@ -5504,37 +5515,37 @@ def update_interview_status(request,slug): @staff_user_required # Assuming only staff can cancel def cancel_interview_for_application(request, slug): """ - Handles POST request to cancel an interview, setting the status + Handles POST request to cancel an interview, setting the status and saving the form data (likely a reason for cancellation). """ interview = get_object_or_404(Interview, slug=slug) scheduled_interview = get_object_or_404(ScheduledInterview, interview=interview) form = InterviewCancelForm(request.POST, instance=interview) - - + + if form.is_valid(): - + interview.status = interview.Status.CANCELLED scheduled_interview.status = scheduled_interview.InterviewStatus.CANCELLED scheduled_interview.save(update_fields=['status']) interview.save(update_fields=['status']) # Saves the new status - + form.save() # Saves form data - - - + + + messages.success(request, _("Interview cancelled successfully.")) return redirect('interview_detail', slug=scheduled_interview.slug) else: - + error_list = [f"{field}: {', '.join(errors)}" for field, errors in form.errors.items()] error_message = _("Please correct the following errors: ") + " ".join(error_list) messages.error(request, error_message) - - + + return redirect('interview_detail', slug=scheduled_interview.slug) - + @login_required @@ -5723,7 +5734,7 @@ def compose_application_email(request, job_slug): subject=subject, content=message, job=job, - message_type='email', + message_type='job_related', is_email_sent=True, email_address=application.person.email if application.person.email else application.email ) @@ -6084,13 +6095,16 @@ def interview_detail(request, slug): """View details of a specific interview""" from .forms import ScheduledInterviewUpdateStatusForm - interview = get_object_or_404(ScheduledInterview, slug=slug) - + schedule = get_object_or_404(ScheduledInterview, slug=slug) + interview = schedule.interview reschedule_form = ScheduledInterviewForm() - reschedule_form.initial['topic'] = interview.interview.topic - meeting=interview.interview + if interview: + reschedule_form.initial['topic'] = interview.topic + reschedule_form.initial['start_time'] = interview.start_time + reschedule_form.initial['duration'] = interview.duration context = { + 'schedule': schedule, 'interview': interview, 'reschedule_form':reschedule_form, 'interview_status_form':ScheduledInterviewUpdateStatusForm(), @@ -6830,30 +6844,136 @@ def interview_add_note(request, slug): form.initial['interview'] = interview form.fields['interview'].widget = HiddenInput() - form.fields['application'].widget = HiddenInput() + form.fields['author'].widget = HiddenInput() form.initial['author'] = request.user form.fields['author'].widget = HiddenInput() return render(request, 'recruitment/partials/note_form.html', {'form': form,'instance':interview,'notes':interview.notes.all()}) -# @login_required -# @staff_user_required -# def archieve_application_bank_view(request): -# """View to list all applications in the application bank""" -# applications = Application.objects.filter(stage="Applied").se -# lect_related('person', 'job_posting').all().order_by('-created_at') +# Settings CRUD Views +@staff_user_required +def settings_list(request): + """List all settings with search and pagination""" + search_query = request.GET.get('q', '') + settings = Settings.objects.all() -# paginator = Paginator(applications, 20) # Show 20 applications per page -# page_number = request.GET.get('page') -# page_obj = paginator.get_page(page_number) + if search_query: + settings = settings.filter(key__icontains=search_query) -# context = { -# 'page_obj': page_obj, -# 'applications': applications, -# } -# return render(request, 'jobs/archieve_applications_bank.html', context) + # Order by key alphabetically + settings = settings.order_by('key') + + # Pagination + paginator = Paginator(settings, 20) # Show 20 settings per page + page_number = request.GET.get('page') + page_obj = paginator.get_page(page_number) + + context = { + 'page_obj': page_obj, + 'search_query': search_query, + 'total_settings': settings.count(), + } + return render(request, 'recruitment/settings_list.html', context) +@staff_user_required +def settings_create(request): + """Create a new setting""" + if request.method == 'POST': + form = SettingsForm(request.POST) + if form.is_valid(): + setting = form.save() + messages.success(request, f'Setting "{setting.key}" created successfully!') + return redirect('settings_list') + else: + messages.error(request, 'Please correct the errors below.') + else: + form = SettingsForm() -# In your views.py (or where the application views are defined) + context = { + 'form': form, + 'title': 'Create New Setting', + 'button_text': 'Create Setting', + } + return render(request, 'recruitment/settings_form.html', context) + + +@staff_user_required +def settings_detail(request, pk): + """View details of a specific setting""" + setting = get_object_or_404(Settings, pk=pk) + + context = { + 'setting': setting, + } + return render(request, 'recruitment/settings_detail.html', context) + + +@staff_user_required +def settings_update(request, pk): + """Update an existing setting""" + setting = get_object_or_404(Settings, pk=pk) + + if request.method == 'POST': + form = SettingsForm(request.POST, instance=setting) + if form.is_valid(): + form.save() + messages.success(request, f'Setting "{setting.key}" updated successfully!') + return redirect('settings_detail', pk=setting.pk) + else: + messages.error(request, 'Please correct the errors below.') + else: + form = SettingsForm(instance=setting) + + context = { + 'form': form, + 'setting': setting, + 'title': f'Edit Setting: {setting.key}', + 'button_text': 'Update Setting', + } + return render(request, 'recruitment/settings_form.html', context) + + +@staff_user_required +def settings_delete(request, pk): + """Delete a setting""" + setting = get_object_or_404(Settings, pk=pk) + + if request.method == 'POST': + setting_name = setting.key + setting.delete() + messages.success(request, f'Setting "{setting_name}" deleted successfully!') + return redirect('settings_list') + + context = { + 'setting': setting, + 'title': 'Delete Setting', + 'message': f'Are you sure you want to delete the setting "{setting_name}"?', + 'cancel_url': reverse('settings_detail', kwargs={'pk': setting.pk}), + } + return render(request, 'recruitment/settings_confirm_delete.html', context) + + +@staff_user_required +def settings_toggle_status(request, pk): + """Toggle active status of a setting""" + setting = get_object_or_404(Settings, pk=pk) + + if request.method == 'POST': + setting.is_active = not setting.is_active + setting.save(update_fields=['is_active']) + + status_text = 'activated' if setting.is_active else 'deactivated' + messages.success(request, f'Setting "{setting.key}" {status_text} successfully!') + return redirect('settings_detail', pk=setting.pk) + + # For GET requests or HTMX, return JSON response + if request.headers.get('HX-Request'): + return JsonResponse({ + 'success': True, + 'is_active': setting.is_active, + 'message': f'Setting "{setting.key}" {status_text} successfully!' + }) + + return redirect('settings_detail', pk=setting.pk) diff --git a/recruitment/zoom_api.py b/recruitment/zoom_api.py index 468094e..d1ab6cd 100644 --- a/recruitment/zoom_api.py +++ b/recruitment/zoom_api.py @@ -1,19 +1,22 @@ import requests import jwt import time +from .utils import get_zoom_config -ZOOM_API_KEY = 'your_zoom_api_key' -ZOOM_API_SECRET = 'your_zoom_api_secret' def generate_zoom_jwt(): + """Generate JWT token using dynamic Zoom configuration""" + config = get_zoom_config() payload = { - 'iss': ZOOM_API_KEY, + 'iss': config['ZOOM_ACCOUNT_ID'], 'exp': time.time() + 3600 } - token = jwt.encode(payload, ZOOM_API_SECRET, algorithm='HS256') + token = jwt.encode(payload, config['ZOOM_CLIENT_SECRET'], algorithm='HS256') return token + def create_zoom_meeting(topic, start_time, duration, host_email): + """Create a Zoom meeting using dynamic configuration""" jwt_token = generate_zoom_jwt() headers = { 'Authorization': f'Bearer {jwt_token}', @@ -28,4 +31,37 @@ def create_zoom_meeting(topic, start_time, duration, host_email): "settings": {"join_before_host": True} } url = f"https://api.zoom.us/v2/users/{host_email}/meetings" - return requests.post(url, json=data, headers=headers) \ No newline at end of file + return requests.post(url, json=data, headers=headers) + + +def update_zoom_meeting(meeting_id, updated_data): + """Update an existing Zoom meeting""" + jwt_token = generate_zoom_jwt() + headers = { + 'Authorization': f'Bearer {jwt_token}', + 'Content-Type': 'application/json' + } + url = f"https://api.zoom.us/v2/meetings/{meeting_id}" + return requests.patch(url, json=updated_data, headers=headers) + + +def delete_zoom_meeting(meeting_id): + """Delete a Zoom meeting""" + jwt_token = generate_zoom_jwt() + headers = { + 'Authorization': f'Bearer {jwt_token}', + 'Content-Type': 'application/json' + } + url = f"https://api.zoom.us/v2/meetings/{meeting_id}" + return requests.delete(url, headers=headers) + + +def get_zoom_meeting_details(meeting_id): + """Get details of a Zoom meeting""" + jwt_token = generate_zoom_jwt() + headers = { + 'Authorization': f'Bearer {jwt_token}', + 'Content-Type': 'application/json' + } + url = f"https://api.zoom.us/v2/meetings/{meeting_id}" + return requests.get(url, headers=headers) diff --git a/templates/applicant/partials/candidate_facing_base.html b/templates/applicant/partials/candidate_facing_base.html index d0cc67c..67efe51 100644 --- a/templates/applicant/partials/candidate_facing_base.html +++ b/templates/applicant/partials/candidate_facing_base.html @@ -43,7 +43,6 @@ body { min-height: 100vh; - background-color: #f0f0f5; padding-top: 0; } @@ -322,9 +321,9 @@ {% comment %} {% endcomment %} - - + + - - + +