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'(ul|ol|div)>', '\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'(ul|ol|div)>', '\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 %}
{% trans "Applications" %}
{% endcomment %}
-
-
+
+
{% if request.user.user_type == 'candidate' and request.user.is_authenticated and request.user.profile_image.url %}
@@ -339,8 +338,8 @@
{% trans "Careers" %}
-
-
+
+