Compare commits
3 Commits
0357921e3d
...
1aa8b6800a
| Author | SHA1 | Date | |
|---|---|---|---|
| 1aa8b6800a | |||
| 1e04b5736d | |||
| 8df69bf1c0 |
BIN
NorahUniversity/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
NorahUniversity/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
NorahUniversity/__pycache__/settings.cpython-313.pyc
Normal file
BIN
NorahUniversity/__pycache__/settings.cpython-313.pyc
Normal file
Binary file not shown.
BIN
NorahUniversity/__pycache__/urls.cpython-313.pyc
Normal file
BIN
NorahUniversity/__pycache__/urls.cpython-313.pyc
Normal file
Binary file not shown.
BIN
NorahUniversity/__pycache__/wsgi.cpython-313.pyc
Normal file
BIN
NorahUniversity/__pycache__/wsgi.cpython-313.pyc
Normal file
Binary file not shown.
@ -38,6 +38,7 @@ INSTALLED_APPS = [
|
||||
"unfold.contrib.guardian", # optional, if django-guardian package is used
|
||||
"unfold.contrib.simple_history",
|
||||
'django.contrib.admin',
|
||||
'django.contrib.humanize',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
@ -53,8 +54,10 @@ INSTALLED_APPS = [
|
||||
'allauth.socialaccount.providers.linkedin_oauth2',
|
||||
'channels',
|
||||
'django_filters',
|
||||
|
||||
|
||||
'crispy_forms',
|
||||
'crispy_bootstrap5',
|
||||
'django_extensions',
|
||||
'template_partials',
|
||||
]
|
||||
|
||||
SITE_ID = 1
|
||||
@ -135,11 +138,30 @@ AUTH_PASSWORD_VALIDATORS = [
|
||||
},
|
||||
]
|
||||
|
||||
# Crispy Forms Configuration
|
||||
CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5"
|
||||
CRISPY_TEMPLATE_PACK = "bootstrap5"
|
||||
|
||||
# Bootstrap 5 Configuration
|
||||
CRISPY_BS5 = {
|
||||
'include_placeholder_text': True,
|
||||
'use_css_helpers': True,
|
||||
}
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/5.2/topics/i18n/
|
||||
|
||||
LANGUAGES = [
|
||||
('en', 'English'),
|
||||
('ar', 'Arabic'),
|
||||
]
|
||||
|
||||
LANGUAGE_CODE = 'en-us'
|
||||
|
||||
LOCALE_PATHS = [
|
||||
BASE_DIR / 'locale',
|
||||
]
|
||||
|
||||
TIME_ZONE = 'UTC'
|
||||
|
||||
USE_I18N = True
|
||||
@ -184,3 +206,14 @@ UNFOLD = {
|
||||
lambda request: static("unfold/js/app.js"),
|
||||
],
|
||||
}
|
||||
|
||||
ZOOM_ACCOUNT_ID = 'HoGikHXsQB2GNDC5Rvyw9A'
|
||||
ZOOM_CLIENT_ID = 'brC39920R8C8azfudUaQgA'
|
||||
ZOOM_CLIENT_SECRET = 'rvfhjlbID4ychXPOvZ2lYsoAC0B0Ny2L'
|
||||
SECRET_TOKEN = '6KdTGyF0SSCSL_V4Xa34aw'
|
||||
|
||||
# Maximum file upload size (in bytes)
|
||||
DATA_UPLOAD_MAX_MEMORY_SIZE = 10485760 # 10MB
|
||||
FILE_UPLOAD_MAX_MEMORY_SIZE = 10485760 # 10MB
|
||||
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
@ -23,7 +23,7 @@ from rest_framework.routers import DefaultRouter
|
||||
from recruitment import views
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'jobs', views.JobViewSet)
|
||||
router.register(r'jobs', views.JobPostingViewSet)
|
||||
router.register(r'candidates', views.CandidateViewSet)
|
||||
|
||||
urlpatterns = [
|
||||
|
||||
BIN
db.sqlite3
BIN
db.sqlite3
Binary file not shown.
BIN
recruitment/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
recruitment/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
recruitment/__pycache__/admin.cpython-313.pyc
Normal file
BIN
recruitment/__pycache__/admin.cpython-313.pyc
Normal file
Binary file not shown.
BIN
recruitment/__pycache__/apps.cpython-313.pyc
Normal file
BIN
recruitment/__pycache__/apps.cpython-313.pyc
Normal file
Binary file not shown.
BIN
recruitment/__pycache__/dashboard.cpython-313.pyc
Normal file
BIN
recruitment/__pycache__/dashboard.cpython-313.pyc
Normal file
Binary file not shown.
BIN
recruitment/__pycache__/forms.cpython-313.pyc
Normal file
BIN
recruitment/__pycache__/forms.cpython-313.pyc
Normal file
Binary file not shown.
BIN
recruitment/__pycache__/linkedin_service.cpython-313.pyc
Normal file
BIN
recruitment/__pycache__/linkedin_service.cpython-313.pyc
Normal file
Binary file not shown.
BIN
recruitment/__pycache__/models.cpython-313.pyc
Normal file
BIN
recruitment/__pycache__/models.cpython-313.pyc
Normal file
Binary file not shown.
BIN
recruitment/__pycache__/serializers.cpython-313.pyc
Normal file
BIN
recruitment/__pycache__/serializers.cpython-313.pyc
Normal file
Binary file not shown.
BIN
recruitment/__pycache__/urls.cpython-313.pyc
Normal file
BIN
recruitment/__pycache__/urls.cpython-313.pyc
Normal file
Binary file not shown.
BIN
recruitment/__pycache__/utils.cpython-313.pyc
Normal file
BIN
recruitment/__pycache__/utils.cpython-313.pyc
Normal file
Binary file not shown.
BIN
recruitment/__pycache__/validators.cpython-313.pyc
Normal file
BIN
recruitment/__pycache__/validators.cpython-313.pyc
Normal file
Binary file not shown.
BIN
recruitment/__pycache__/views.cpython-313.pyc
Normal file
BIN
recruitment/__pycache__/views.cpython-313.pyc
Normal file
Binary file not shown.
BIN
recruitment/__pycache__/views_frontend.cpython-313.pyc
Normal file
BIN
recruitment/__pycache__/views_frontend.cpython-313.pyc
Normal file
Binary file not shown.
@ -44,7 +44,7 @@ def parse_resumes(modeladmin, request, queryset):
|
||||
|
||||
@admin.register(models.Candidate)
|
||||
class CandidateAdmin(ModelAdmin):
|
||||
list_display = ('name', 'email', 'job', 'applied', 'created_at')
|
||||
list_display = ('first_name','last_name','phone', 'email', 'job', 'applied', 'created_at')
|
||||
list_filter = ('applied', 'job')
|
||||
search_fields = ('name', 'email')
|
||||
# readonly_fields = ('parsed_summary',)
|
||||
|
||||
@ -1,7 +1,2 @@
|
||||
import pandas as pd
|
||||
from . import models
|
||||
|
||||
def get_dashboard_data():
|
||||
df = pd.DataFrame(list(models.Candidate.objects.all().values('applied', 'created_at')))
|
||||
summary = df['applied'].value_counts().to_dict()
|
||||
return summary
|
||||
# This file is intentionally left empty
|
||||
# The dashboard functionality has been moved to views_frontend.py
|
||||
|
||||
@ -1,7 +1,368 @@
|
||||
from django import forms
|
||||
from . import models
|
||||
from .validators import validate_hash_tags
|
||||
from crispy_forms.helper import FormHelper
|
||||
from django.core.validators import URLValidator
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from crispy_forms.layout import Layout, Submit, HTML, Div, Field
|
||||
from .models import ZoomMeeting, Candidate,Job,TrainingMaterial,JobPosting
|
||||
|
||||
class CandidateForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = models.Candidate
|
||||
fields = ['name', 'email', 'resume']
|
||||
model = Candidate
|
||||
fields = ['job', 'first_name', 'last_name', 'phone', 'email', 'resume', 'stage']
|
||||
labels = {
|
||||
'first_name': _('First Name'),
|
||||
'last_name': _('Last Name'),
|
||||
'phone': _('Phone'),
|
||||
'email': _('Email'),
|
||||
'resume': _('Resume'),
|
||||
'stage': _('Application Stage'),
|
||||
}
|
||||
widgets = {
|
||||
'first_name': forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Enter first name')}),
|
||||
'last_name': forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Enter last name')}),
|
||||
'phone': forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Enter phone number')}),
|
||||
'email': forms.EmailInput(attrs={'class': 'form-control', 'placeholder': _('Enter email')}),
|
||||
'stage': forms.Select(attrs={'class': 'form-select'}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_method = 'post'
|
||||
self.helper.form_class = 'form-horizontal'
|
||||
self.helper.label_class = 'col-md-3'
|
||||
self.helper.field_class = 'col-md-9'
|
||||
|
||||
# Make job field read-only if it's being pre-populated
|
||||
job_value = self.initial.get('job')
|
||||
if job_value:
|
||||
self.fields['job'].widget.attrs['readonly'] = True
|
||||
|
||||
self.helper.layout = Layout(
|
||||
Field('job', css_class='form-control'),
|
||||
Field('first_name', css_class='form-control'),
|
||||
Field('last_name', css_class='form-control'),
|
||||
Field('phone', css_class='form-control'),
|
||||
Field('email', css_class='form-control'),
|
||||
Field('stage', css_class='form-control'),
|
||||
Field('resume', css_class='form-control'),
|
||||
Submit('submit', _('Submit'), css_class='btn btn-primary')
|
||||
)
|
||||
|
||||
class CandidateStageForm(forms.ModelForm):
|
||||
"""Form specifically for updating candidate stage with validation"""
|
||||
|
||||
class Meta:
|
||||
model = Candidate
|
||||
fields = ['stage']
|
||||
labels = {
|
||||
'stage': _('New Application Stage'),
|
||||
}
|
||||
widgets = {
|
||||
'stage': forms.Select(attrs={'class': 'form-select'}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
# Get the current candidate instance for validation
|
||||
self.candidate = kwargs.pop('candidate', None)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Dynamically filter stage choices based on current stage
|
||||
if self.candidate and self.candidate.pk:
|
||||
current_stage = self.candidate.stage
|
||||
available_stages = self.candidate.get_available_stages()
|
||||
|
||||
# Filter choices to only include available stages
|
||||
choices = [(stage, self.candidate.Stage(stage).label)
|
||||
for stage in available_stages]
|
||||
self.fields['stage'].choices = choices
|
||||
|
||||
# Set initial value to current stage
|
||||
self.fields['stage'].initial = current_stage
|
||||
else:
|
||||
# For new candidates, only show 'Applied' stage
|
||||
self.fields['stage'].choices = [('Applied', _('Applied'))]
|
||||
self.fields['stage'].initial = 'Applied'
|
||||
|
||||
def clean_stage(self):
|
||||
"""Validate stage transition"""
|
||||
new_stage = self.cleaned_data.get('stage')
|
||||
if not new_stage:
|
||||
raise forms.ValidationError(_('Please select a stage.'))
|
||||
|
||||
# Use model validation for stage transitions
|
||||
if self.candidate and self.candidate.pk:
|
||||
current_stage = self.candidate.stage
|
||||
if new_stage != current_stage:
|
||||
if not self.candidate.can_transition_to(new_stage):
|
||||
allowed_stages = self.candidate.get_available_stages()
|
||||
raise forms.ValidationError(
|
||||
_('Cannot transition from "%(current)s" to "%(new)s". '
|
||||
'Allowed transitions: %(allowed)s') % {
|
||||
'current': current_stage,
|
||||
'new': new_stage,
|
||||
'allowed': ', '.join(allowed_stages) or 'None (final stage)'
|
||||
}
|
||||
)
|
||||
|
||||
return new_stage
|
||||
|
||||
class ZoomMeetingForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = ZoomMeeting
|
||||
fields = ['topic', 'start_time', 'duration']
|
||||
labels = {
|
||||
'topic': _('Topic'),
|
||||
'start_time': _('Start Time'),
|
||||
'duration': _('Duration'),
|
||||
}
|
||||
widgets = {
|
||||
'topic': forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Enter meeting topic'),}),
|
||||
'start_time': forms.DateTimeInput(attrs={'class': 'form-control','type': 'datetime-local'}),
|
||||
'duration': forms.NumberInput(attrs={'class': 'form-control','min': 1, 'placeholder': _('60')}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_method = 'post'
|
||||
self.helper.form_class = 'form-horizontal'
|
||||
self.helper.label_class = 'col-md-3'
|
||||
self.helper.field_class = 'col-md-9'
|
||||
self.helper.layout = Layout(
|
||||
Field('topic', css_class='form-control'),
|
||||
Field('start_time', css_class='form-control'),
|
||||
Field('duration', css_class='form-control'),
|
||||
Submit('submit', _('Create Meeting'), css_class='btn btn-primary')
|
||||
)
|
||||
|
||||
# Old JobForm removed - replaced by JobPostingForm
|
||||
|
||||
class TrainingMaterialForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = TrainingMaterial
|
||||
fields = ['title', 'content', 'video_link', 'file']
|
||||
labels = {
|
||||
'title': _('Title'),
|
||||
'content': _('Content'),
|
||||
'video_link': _('Video Link'),
|
||||
'file': _('File'),
|
||||
}
|
||||
widgets = {
|
||||
'title': forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Enter title')}),
|
||||
'content': forms.Textarea(attrs={'rows': 6, 'class': 'form-control', 'placeholder': _('Enter material content')}),
|
||||
'video_link': forms.URLInput(attrs={'class': 'form-control', 'placeholder': _('https://www.youtube.com/watch?v=...')}),
|
||||
'file': forms.FileInput(attrs={'class': 'form-control'}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_method = 'post'
|
||||
self.helper.form_class = 'form-horizontal'
|
||||
self.helper.label_class = 'col-md-3'
|
||||
self.helper.field_class = 'col-md-9'
|
||||
self.helper.layout = Layout(
|
||||
Field('title', css_class='form-control'),
|
||||
Field('content', css_class='form-control'),
|
||||
Div(
|
||||
Field('video_link', css_class='form-control'),
|
||||
Field('file', css_class='form-control'),
|
||||
css_class='row'
|
||||
),
|
||||
Submit('submit', _('Save Material'), css_class='btn btn-primary mt-3')
|
||||
)
|
||||
|
||||
|
||||
class JobPostingForm(forms.ModelForm):
|
||||
"""Form for creating and editing job postings"""
|
||||
|
||||
class Meta:
|
||||
model = JobPosting
|
||||
fields = [
|
||||
'title', 'department', 'job_type', 'workplace_type',
|
||||
'location_city', 'location_state', 'location_country',
|
||||
'description', 'qualifications', 'salary_range', 'benefits',
|
||||
'application_url', 'application_deadline', 'application_instructions',
|
||||
'position_number', 'reporting_to', 'start_date', 'status',
|
||||
'created_by','open_positions','hash_tags'
|
||||
]
|
||||
widgets = {
|
||||
# Basic Information
|
||||
'title': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Assistant Professor of Computer Science',
|
||||
'required': True
|
||||
}),
|
||||
'department': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Computer Science, Human Resources, etc.'
|
||||
}),
|
||||
'job_type': forms.Select(attrs={
|
||||
'class': 'form-select',
|
||||
'required': True
|
||||
}),
|
||||
'workplace_type': forms.Select(attrs={
|
||||
'class': 'form-select',
|
||||
'required': True
|
||||
}),
|
||||
|
||||
# Location
|
||||
'location_city': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Boston'
|
||||
}),
|
||||
'location_state': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'MA'
|
||||
}),
|
||||
'location_country': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'value': 'United States'
|
||||
}),
|
||||
|
||||
# Job Details
|
||||
'description': forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 6,
|
||||
'placeholder': 'Provide a comprehensive description of the role, responsibilities, and expectations...',
|
||||
'required': True
|
||||
}),
|
||||
'qualifications': forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 4,
|
||||
'placeholder': 'List required qualifications, skills, education, and experience...'
|
||||
}),
|
||||
'salary_range': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': '$60,000 - $80,000'
|
||||
}),
|
||||
'benefits': forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 2,
|
||||
'placeholder': 'Health insurance, retirement plans, tuition reimbursement, etc.'
|
||||
}),
|
||||
|
||||
# Application Information
|
||||
'application_url': forms.URLInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'https://university.edu/careers/job123',
|
||||
'required': True
|
||||
}),
|
||||
'application_deadline': forms.DateInput(attrs={
|
||||
'class': 'form-control',
|
||||
'type': 'date'
|
||||
}),
|
||||
'application_instructions': forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 3,
|
||||
'placeholder': 'Special instructions for applicants (e.g., required documents, reference requirements, etc.)'
|
||||
}),
|
||||
'open_positions': forms.NumberInput(attrs={
|
||||
'class': 'form-control',
|
||||
'min': 1,
|
||||
'placeholder': 'Number of open positions'
|
||||
}),
|
||||
'hash_tags': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': '#hiring,#jobopening',
|
||||
'validators':validate_hash_tags,
|
||||
|
||||
}),
|
||||
|
||||
# Internal Information
|
||||
'position_number': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'UNIV-2025-001'
|
||||
}),
|
||||
'reporting_to': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Department Chair, Director, etc.'
|
||||
}),
|
||||
'start_date': forms.DateInput(attrs={
|
||||
'class': 'form-control',
|
||||
'type': 'date'
|
||||
}),
|
||||
|
||||
'created_by': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'University Administrator'
|
||||
}),
|
||||
}
|
||||
|
||||
def __init__(self,*args,**kwargs):
|
||||
|
||||
# Extract your custom argument BEFORE calling super()
|
||||
self.is_anonymous_user = kwargs.pop('is_anonymous_user', False)
|
||||
# Now call the parent __init__ with remaining args
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if not self.instance.pk:# Creating new job posting
|
||||
if not self.is_anonymous_user:
|
||||
self.fields['created_by'].initial = 'University Administrator'
|
||||
self.fields['status'].initial = 'Draft'
|
||||
self.fields['location_city'].initial='Riyadh'
|
||||
self.fields['location_state'].initial='Riyadh Province'
|
||||
self.fields['location_country'].initial='Saudi Arabia'
|
||||
|
||||
|
||||
def clean_hash_tags(self):
|
||||
hash_tags=self.cleaned_data.get('hash_tags')
|
||||
if hash_tags:
|
||||
tags=[tag.strip() for tag in hash_tags.split(',') if tag.strip()]
|
||||
for tag in tags:
|
||||
if not tag.startswith('#'):
|
||||
raise forms.ValidationError("Each hashtag must start with '#' symbol and must be comma(,) sepearted.")
|
||||
return ','.join(tags)
|
||||
return hash_tags # Allow blank
|
||||
|
||||
def clean_title(self):
|
||||
title=self.cleaned_data.get('title')
|
||||
if not title or len(title.strip())<3:
|
||||
raise forms.ValidationError("Job title must be at least 3 characters long.")
|
||||
if len(title)>200:
|
||||
raise forms.ValidationError("Job title cannot exceed 200 characters.")
|
||||
return title.strip()
|
||||
|
||||
def clean_description(self):
|
||||
description=self.cleaned_data.get('description')
|
||||
if not description or len(description.strip())<20:
|
||||
raise forms.ValidationError("Job description must be at least 20 characters long.")
|
||||
return description.strip() # to remove leading/trailing whitespace
|
||||
|
||||
def clean_application_url(self):
|
||||
url=self.cleaned_data.get('application_url')
|
||||
if url:
|
||||
validator=URLValidator()
|
||||
try:
|
||||
validator(url)
|
||||
except forms.ValidationError:
|
||||
raise forms.ValidationError('Please enter a valid URL (e.g., https://example.com)')
|
||||
return url
|
||||
|
||||
def clean(self):
|
||||
"""Cross-field validation"""
|
||||
cleaned_data = super().clean()
|
||||
|
||||
# Validate dates
|
||||
start_date = cleaned_data.get('start_date')
|
||||
application_deadline = cleaned_data.get('application_deadline')
|
||||
|
||||
# Perform cross-field validation only if both fields have values
|
||||
if start_date and application_deadline:
|
||||
if application_deadline > start_date:
|
||||
self.add_error('application_deadline',
|
||||
'The application deadline must be set BEFORE the job start date.')
|
||||
|
||||
# # Validate that if status is ACTIVE, we have required fields
|
||||
# status = cleaned_data.get('status')
|
||||
# if status == 'ACTIVE':
|
||||
# if not cleaned_data.get('application_url'):
|
||||
# self.add_error('application_url',
|
||||
# 'Application URL is required for active jobs.')
|
||||
# if not cleaned_data.get('description'):
|
||||
# self.add_error('description',
|
||||
# 'Job description is required for active jobs.')
|
||||
|
||||
return cleaned_data
|
||||
|
||||
552
recruitment/linkedin_service.py
Normal file
552
recruitment/linkedin_service.py
Normal file
@ -0,0 +1,552 @@
|
||||
# jobs/linkedin_service.py
|
||||
import uuid
|
||||
from urllib.parse import quote
|
||||
import requests
|
||||
import logging
|
||||
from django.conf import settings
|
||||
from urllib.parse import urlencode, quote
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
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
|
||||
self.access_token = None
|
||||
|
||||
def get_auth_url(self):
|
||||
"""Generate LinkedIn OAuth URL"""
|
||||
params = {
|
||||
'response_type': 'code',
|
||||
'client_id': self.client_id,
|
||||
'redirect_uri': self.redirect_uri,
|
||||
'scope': 'w_member_social openid profile',
|
||||
'state': 'university_ats_linkedin'
|
||||
}
|
||||
return f"https://www.linkedin.com/oauth/v2/authorization?{urlencode(params)}"
|
||||
|
||||
def get_access_token(self, code):
|
||||
"""Exchange authorization code for access token"""
|
||||
# This function exchanges LinkedIn’s temporary authorization code for a usable access token.
|
||||
url = "https://www.linkedin.com/oauth/v2/accessToken"
|
||||
data = {
|
||||
'grant_type': 'authorization_code',
|
||||
'code': code,
|
||||
'redirect_uri': self.redirect_uri,
|
||||
'client_id': self.client_id,
|
||||
'client_secret': self.client_secret
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(url, data=data, timeout=60)
|
||||
response.raise_for_status()
|
||||
token_data = response.json()
|
||||
"""
|
||||
Example response:{
|
||||
"access_token": "AQXq8HJkLmNpQrStUvWxYz...",
|
||||
"expires_in": 5184000
|
||||
}
|
||||
"""
|
||||
self.access_token = token_data.get('access_token')
|
||||
return self.access_token
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting access token: {e}")
|
||||
raise
|
||||
|
||||
def get_user_profile(self):
|
||||
"""Get user profile information"""
|
||||
if not self.access_token:
|
||||
raise Exception("No access token available")
|
||||
|
||||
url = "https://api.linkedin.com/v2/userinfo"
|
||||
headers = {'Authorization': f'Bearer {self.access_token}'}
|
||||
|
||||
try:
|
||||
response = requests.get(url, headers=headers, timeout=60)
|
||||
response.raise_for_status() # Ensure we raise an error for bad responses(4xx, 5xx) and does nothing for 2xx(success)
|
||||
return response.json() # returns a dict from json response (deserialize)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting user profile: {e}")
|
||||
raise
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def register_image_upload(self, person_urn):
|
||||
"""Step 1: Register image upload with LinkedIn"""
|
||||
url = "https://api.linkedin.com/v2/assets?action=registerUpload"
|
||||
headers = {
|
||||
'Authorization': f'Bearer {self.access_token}',
|
||||
'Content-Type': 'application/json',
|
||||
'X-Restli-Protocol-Version': '2.0.0'
|
||||
}
|
||||
|
||||
payload = {
|
||||
"registerUploadRequest": {
|
||||
"recipes": ["urn:li:digitalmediaRecipe:feedshare-image"],
|
||||
"owner": f"urn:li:person:{person_urn}",
|
||||
"serviceRelationships": [{
|
||||
"relationshipType": "OWNER",
|
||||
"identifier": "urn:li:userGeneratedContent"
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
response = requests.post(url, headers=headers, json=payload, timeout=30)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
return {
|
||||
'upload_url': data['value']['uploadMechanism']['com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest']['uploadUrl'],
|
||||
'asset': data['value']['asset']
|
||||
}
|
||||
|
||||
def upload_image_to_linkedin(self, upload_url, image_file):
|
||||
"""Step 2: Upload actual image file to LinkedIn"""
|
||||
# Open and read the Django ImageField
|
||||
image_file.open()
|
||||
image_content = image_file.read()
|
||||
image_file.close()
|
||||
|
||||
headers = {
|
||||
'Authorization': f'Bearer {self.access_token}',
|
||||
}
|
||||
|
||||
response = requests.post(upload_url, headers=headers, data=image_content, timeout=60)
|
||||
response.raise_for_status()
|
||||
return True
|
||||
def create_job_post(self, job_posting):
|
||||
"""Create a job announcement post on LinkedIn (with image support)"""
|
||||
if not self.access_token:
|
||||
raise Exception("Not authenticated with LinkedIn")
|
||||
|
||||
try:
|
||||
# Get user profile for person URN
|
||||
profile = self.get_user_profile()
|
||||
person_urn = profile.get('sub')
|
||||
|
||||
if not person_urn:
|
||||
raise Exception("Could not retrieve LinkedIn user ID")
|
||||
|
||||
# Check if job has an image
|
||||
try:
|
||||
image_upload = job_posting.files.first()
|
||||
has_image = image_upload and image_upload.linkedinpost_image
|
||||
except Exception:
|
||||
has_image = False
|
||||
|
||||
if has_image:
|
||||
# === POST WITH IMAGE ===
|
||||
try:
|
||||
# Step 1: Register image upload
|
||||
upload_info = self.register_image_upload(person_urn)
|
||||
|
||||
# Step 2: Upload image
|
||||
self.upload_image_to_linkedin(
|
||||
upload_info['upload_url'],
|
||||
image_upload.linkedinpost_image
|
||||
)
|
||||
|
||||
# Step 3: Create post with image
|
||||
return self.create_job_post_with_image(
|
||||
job_posting,
|
||||
image_upload.linkedinpost_image,
|
||||
person_urn,
|
||||
upload_info['asset']
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Image upload failed: {e}")
|
||||
# Fall back to text-only post if image upload fails
|
||||
has_image = False
|
||||
|
||||
# === FALLBACK TO URL/ARTICLE POST ===
|
||||
# Add unique timestamp to prevent duplicates
|
||||
from django.utils import timezone
|
||||
import random
|
||||
unique_suffix = f"\n\nPosted: {timezone.now().strftime('%b %d, %Y at %I:%M %p')} (ID: {random.randint(1000, 9999)})"
|
||||
|
||||
message_parts = [f"🚀 **We're Hiring: {job_posting.title}**"]
|
||||
if job_posting.department:
|
||||
message_parts.append(f"**Department:** {job_posting.department}")
|
||||
if job_posting.description:
|
||||
message_parts.append(f"\n{job_posting.description}")
|
||||
|
||||
details = []
|
||||
if job_posting.job_type:
|
||||
details.append(f"💼 {job_posting.get_job_type_display()}")
|
||||
if job_posting.get_location_display() != 'Not specified':
|
||||
details.append(f"📍 {job_posting.get_location_display()}")
|
||||
if job_posting.workplace_type:
|
||||
details.append(f"🏠 {job_posting.get_workplace_type_display()}")
|
||||
if job_posting.salary_range:
|
||||
details.append(f"💰 {job_posting.salary_range}")
|
||||
|
||||
if details:
|
||||
message_parts.append("\n" + " | ".join(details))
|
||||
|
||||
if job_posting.application_url:
|
||||
message_parts.append(f"\n🔗 **Apply now:** {job_posting.application_url}")
|
||||
|
||||
hashtags = self.hashtags_list(job_posting.hash_tags)
|
||||
if job_posting.department:
|
||||
dept_hashtag = f"#{job_posting.department.replace(' ', '')}"
|
||||
hashtags.insert(0, dept_hashtag)
|
||||
|
||||
message_parts.append("\n\n" + " ".join(hashtags))
|
||||
message_parts.append(unique_suffix)
|
||||
message = "\n".join(message_parts)
|
||||
|
||||
# 🔥 FIX URL - REMOVE TRAILING SPACES 🔥
|
||||
url = "https://api.linkedin.com/v2/ugcPosts"
|
||||
headers = {
|
||||
'Authorization': f'Bearer {self.access_token}',
|
||||
'Content-Type': 'application/json',
|
||||
'X-Restli-Protocol-Version': '2.0.0'
|
||||
}
|
||||
|
||||
payload = {
|
||||
"author": f"urn:li:person:{person_urn}",
|
||||
"lifecycleState": "PUBLISHED",
|
||||
"specificContent": {
|
||||
"com.linkedin.ugc.ShareContent": {
|
||||
"shareCommentary": {"text": message},
|
||||
"shareMediaCategory": "ARTICLE",
|
||||
"media": [{
|
||||
"status": "READY",
|
||||
"description": {"text": f"Apply for {job_posting.title} at our university!"},
|
||||
"originalUrl": job_posting.application_url,
|
||||
"title": {"text": job_posting.title}
|
||||
}]
|
||||
}
|
||||
},
|
||||
"visibility": {
|
||||
"com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"
|
||||
}
|
||||
}
|
||||
|
||||
response = requests.post(url, headers=headers, json=payload, timeout=60)
|
||||
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 ""
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'post_id': post_id,
|
||||
'post_url': post_url,
|
||||
'status_code': response.status_code
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating LinkedIn post: {e}")
|
||||
return {
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
'status_code': getattr(e.response, 'status_code', 500) if hasattr(e, 'response') else 500
|
||||
}
|
||||
|
||||
# def create_job_post_with_image(self, job_posting, image_file, person_urn, asset_urn):
|
||||
# """Step 3: Create post with uploaded image"""
|
||||
# url = "https://api.linkedin.com/v2/ugcPosts"
|
||||
# headers = {
|
||||
# 'Authorization': f'Bearer {self.access_token}',
|
||||
# 'Content-Type': 'application/json',
|
||||
# 'X-Restli-Protocol-Version': '2.0.0'
|
||||
# }
|
||||
|
||||
# # Build the same message as before
|
||||
# message_parts = [f"🚀 **We're Hiring: {job_posting.title}**"]
|
||||
# if job_posting.department:
|
||||
# message_parts.append(f"**Department:** {job_posting.department}")
|
||||
# if job_posting.description:
|
||||
# message_parts.append(f"\n{job_posting.description}")
|
||||
|
||||
# details = []
|
||||
# if job_posting.job_type:
|
||||
# details.append(f"💼 {job_posting.get_job_type_display()}")
|
||||
# if job_posting.get_location_display() != 'Not specified':
|
||||
# details.append(f"📍 {job_posting.get_location_display()}")
|
||||
# if job_posting.workplace_type:
|
||||
# details.append(f"🏠 {job_posting.get_workplace_type_display()}")
|
||||
# if job_posting.salary_range:
|
||||
# details.append(f"💰 {job_posting.salary_range}")
|
||||
|
||||
# if details:
|
||||
# message_parts.append("\n" + " | ".join(details))
|
||||
|
||||
# if job_posting.application_url:
|
||||
# message_parts.append(f"\n🔗 **Apply now:** {job_posting.application_url}")
|
||||
|
||||
# hashtags = self.hashtags_list(job_posting.hash_tags)
|
||||
# if job_posting.department:
|
||||
# dept_hashtag = f"#{job_posting.department.replace(' ', '')}"
|
||||
# hashtags.insert(0, dept_hashtag)
|
||||
|
||||
# message_parts.append("\n\n" + " ".join(hashtags))
|
||||
# message = "\n".join(message_parts)
|
||||
|
||||
# # Create image post payload
|
||||
# payload = {
|
||||
# "author": f"urn:li:person:{person_urn}",
|
||||
# "lifecycleState": "PUBLISHED",
|
||||
# "specificContent": {
|
||||
# "com.linkedin.ugc.ShareContent": {
|
||||
# "shareCommentary": {"text": message},
|
||||
# "shareMediaCategory": "IMAGE",
|
||||
# "media": [{
|
||||
# "status": "READY",
|
||||
# "media": asset_urn
|
||||
# }]
|
||||
# }
|
||||
# },
|
||||
# "visibility": {
|
||||
# "com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"
|
||||
# }
|
||||
# }
|
||||
|
||||
# response = requests.post(url, headers=headers, json=payload, timeout=30)
|
||||
# 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 ""
|
||||
|
||||
# return {
|
||||
# 'success': True,
|
||||
# 'post_id': post_id,
|
||||
# 'post_url': post_url,
|
||||
# 'status_code': response.status_code
|
||||
# }
|
||||
|
||||
def hashtags_list(self,hash_tags_str):
|
||||
"""Convert comma-separated hashtags string to list"""
|
||||
if not hash_tags_str:
|
||||
return [""]
|
||||
tags = [tag.strip() for tag in hash_tags_str.split(',') if tag.strip()]
|
||||
if not tags:
|
||||
return ["#HigherEd", "#Hiring", "#FacultyJobs", "#UniversityJobs"]
|
||||
|
||||
return tags
|
||||
|
||||
|
||||
# def create_job_post(self, job_posting):
|
||||
# """Create a job announcement post on LinkedIn (with image support)"""
|
||||
# if not self.access_token:
|
||||
# raise Exception("Not authenticated with LinkedIn")
|
||||
|
||||
# try:
|
||||
# # Get user profile for person URN
|
||||
# profile = self.get_user_profile()
|
||||
# person_urn = profile.get('sub')
|
||||
|
||||
# if not person_urn:
|
||||
# raise Exception("Could not retrieve LinkedIn user ID")
|
||||
|
||||
# # Check if job has an image
|
||||
# try:
|
||||
# image_upload = job_posting.files.first()
|
||||
# has_image = image_upload and image_upload.linkedinpost_image
|
||||
# except Exception:
|
||||
# has_image = False
|
||||
|
||||
# if has_image:
|
||||
# # === POST WITH IMAGE ===
|
||||
# upload_info = self.register_image_upload(person_urn)
|
||||
# self.upload_image_to_linkedin(
|
||||
# upload_info['upload_url'],
|
||||
# image_upload.linkedinpost_image
|
||||
# )
|
||||
# return self.create_job_post_with_image(
|
||||
# job_posting,
|
||||
# image_upload.linkedinpost_image,
|
||||
# person_urn,
|
||||
# upload_info['asset']
|
||||
# )
|
||||
|
||||
# else:
|
||||
# # === FALLBACK TO URL/ARTICLE POST ===
|
||||
# # 🔥 ADD UNIQUE TIMESTAMP TO PREVENT DUPLICATES 🔥
|
||||
# from django.utils import timezone
|
||||
# import random
|
||||
# unique_suffix = f"\n\nPosted: {timezone.now().strftime('%b %d, %Y at %I:%M %p')} (ID: {random.randint(1000, 9999)})"
|
||||
|
||||
# message_parts = [f"🚀 **We're Hiring: {job_posting.title}**"]
|
||||
# if job_posting.department:
|
||||
# message_parts.append(f"**Department:** {job_posting.department}")
|
||||
# if job_posting.description:
|
||||
# message_parts.append(f"\n{job_posting.description}")
|
||||
|
||||
# details = []
|
||||
# if job_posting.job_type:
|
||||
# details.append(f"💼 {job_posting.get_job_type_display()}")
|
||||
# if job_posting.get_location_display() != 'Not specified':
|
||||
# details.append(f"📍 {job_posting.get_location_display()}")
|
||||
# if job_posting.workplace_type:
|
||||
# details.append(f"🏠 {job_posting.get_workplace_type_display()}")
|
||||
# if job_posting.salary_range:
|
||||
# details.append(f"💰 {job_posting.salary_range}")
|
||||
|
||||
# if details:
|
||||
# message_parts.append("\n" + " | ".join(details))
|
||||
|
||||
# if job_posting.application_url:
|
||||
# message_parts.append(f"\n🔗 **Apply now:** {job_posting.application_url}")
|
||||
|
||||
# hashtags = self.hashtags_list(job_posting.hash_tags)
|
||||
# if job_posting.department:
|
||||
# dept_hashtag = f"#{job_posting.department.replace(' ', '')}"
|
||||
# hashtags.insert(0, dept_hashtag)
|
||||
|
||||
# message_parts.append("\n\n" + " ".join(hashtags))
|
||||
# message_parts.append(unique_suffix) # 🔥 Add unique suffix
|
||||
# message = "\n".join(message_parts)
|
||||
|
||||
# # 🔥 FIX URL - REMOVE TRAILING SPACES 🔥
|
||||
# url = "https://api.linkedin.com/v2/ugcPosts"
|
||||
# headers = {
|
||||
# 'Authorization': f'Bearer {self.access_token}',
|
||||
# 'Content-Type': 'application/json',
|
||||
# 'X-Restli-Protocol-Version': '2.0.0'
|
||||
# }
|
||||
|
||||
# payload = {
|
||||
# "author": f"urn:li:person:{person_urn}",
|
||||
# "lifecycleState": "PUBLISHED",
|
||||
# "specificContent": {
|
||||
# "com.linkedin.ugc.ShareContent": {
|
||||
# "shareCommentary": {"text": message},
|
||||
# "shareMediaCategory": "ARTICLE",
|
||||
# "media": [{
|
||||
# "status": "READY",
|
||||
# "description": {"text": f"Apply for {job_posting.title} at our university!"},
|
||||
# "originalUrl": job_posting.application_url,
|
||||
# "title": {"text": job_posting.title}
|
||||
# }]
|
||||
# }
|
||||
# },
|
||||
# "visibility": {
|
||||
# "com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"
|
||||
# }
|
||||
# }
|
||||
|
||||
# response = requests.post(url, headers=headers, json=payload, timeout=60)
|
||||
# response.raise_for_status()
|
||||
|
||||
# post_id = response.headers.get('x-restli-id', '')
|
||||
# # 🔥 FIX POST URL - REMOVE TRAILING SPACES 🔥
|
||||
# post_url = f"https://www.linkedin.com/feed/update/{quote(post_id)}/" if post_id else ""
|
||||
|
||||
# return {
|
||||
# 'success': True,
|
||||
# 'post_id': post_id,
|
||||
# 'post_url': post_url,
|
||||
# 'status_code': response.status_code
|
||||
# }
|
||||
|
||||
# except Exception as e:
|
||||
# logger.error(f"Error creating LinkedIn post: {e}")
|
||||
# return {
|
||||
# 'success': False,
|
||||
# 'error': str(e),
|
||||
# 'status_code': getattr(e.response, 'status_code', 500) if hasattr(e, 'response') else 500
|
||||
# }
|
||||
# def create_job_post(self, job_posting):
|
||||
# """Create a job announcement post on LinkedIn"""
|
||||
# if not self.access_token:
|
||||
# raise Exception("Not authenticated with LinkedIn")
|
||||
|
||||
# try:
|
||||
# # Get user profile for person URN
|
||||
# profile = self.get_user_profile()
|
||||
# person_urn = profile.get('sub')
|
||||
|
||||
# if not person_urn: # uniform resource name used to uniquely identify linked-id for internal systems and apis
|
||||
# raise Exception("Could not retrieve LinkedIn user ID")
|
||||
|
||||
# # Build professional job post message
|
||||
# message_parts = [f"🚀 **We're Hiring: {job_posting.title}**"]
|
||||
|
||||
# if job_posting.department:
|
||||
# message_parts.append(f"**Department:** {job_posting.department}")
|
||||
|
||||
# if job_posting.description:
|
||||
# message_parts.append(f"\n{job_posting.description}")
|
||||
|
||||
# # Add job details
|
||||
# details = []
|
||||
# if job_posting.job_type:
|
||||
# details.append(f"💼 {job_posting.get_job_type_display()}")
|
||||
# if job_posting.get_location_display() != 'Not specified':
|
||||
# details.append(f"📍 {job_posting.get_location_display()}")
|
||||
# if job_posting.workplace_type:
|
||||
# details.append(f"🏠 {job_posting.get_workplace_type_display()}")
|
||||
# if job_posting.salary_range:
|
||||
# details.append(f"💰 {job_posting.salary_range}")
|
||||
|
||||
# if details:
|
||||
# message_parts.append("\n" + " | ".join(details))
|
||||
|
||||
# # Add application link
|
||||
# if job_posting.application_url:
|
||||
# message_parts.append(f"\n🔗 **Apply now:** {job_posting.application_url}")
|
||||
|
||||
# # Add hashtags
|
||||
# hashtags = ["#HigherEd", "#Hiring", "#FacultyJobs", "#UniversityJobs"]
|
||||
# if job_posting.department:
|
||||
# dept_hashtag = f"#{job_posting.department.replace(' ', '')}"
|
||||
# hashtags.insert(0, dept_hashtag)
|
||||
|
||||
# message_parts.append("\n\n" + " ".join(hashtags))
|
||||
# message = "\n".join(message_parts)
|
||||
|
||||
# # Create LinkedIn post
|
||||
# url = "https://api.linkedin.com/v2/ugcPosts"
|
||||
# headers = {
|
||||
# 'Authorization': f'Bearer {self.access_token}',
|
||||
# 'Content-Type': 'application/json',
|
||||
# 'X-Restli-Protocol-Version': '2.0.0'
|
||||
# }
|
||||
|
||||
# payload = {
|
||||
# "author": f"urn:li:person:{person_urn}",
|
||||
# "lifecycleState": "PUBLISHED",
|
||||
# "specificContent": {
|
||||
# "com.linkedin.ugc.ShareContent": {
|
||||
# "shareCommentary": {"text": message},
|
||||
# "shareMediaCategory": "ARTICLE",
|
||||
# "media": [{
|
||||
# "status": "READY",
|
||||
# "description": {"text": f"Apply for {job_posting.title} at our university!"},
|
||||
# "originalUrl": job_posting.application_url,
|
||||
# "title": {"text": job_posting.title}
|
||||
# }]
|
||||
# }
|
||||
# },
|
||||
# "visibility": {
|
||||
# "com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"
|
||||
# }
|
||||
# }
|
||||
|
||||
# response = requests.post(url, headers=headers, json=payload, timeout=60)
|
||||
# response.raise_for_status()
|
||||
|
||||
# # Extract post ID from response
|
||||
# post_id = response.headers.get('x-restli-id', '')
|
||||
# post_url = f"https://www.linkedin.com/feed/update/{quote(post_id)}/" if post_id else ""
|
||||
|
||||
# return {
|
||||
# 'success': True,
|
||||
# 'post_id': post_id,
|
||||
# 'post_url': post_url,
|
||||
# 'status_code': response.status_code
|
||||
# }
|
||||
|
||||
# except Exception as e:
|
||||
# logger.error(f"Error creating LinkedIn post: {e}")
|
||||
# return {
|
||||
# 'success': False,
|
||||
# 'error': str(e),
|
||||
# 'status_code': getattr(e.response, 'status_code', 500) if hasattr(e, 'response') else 500
|
||||
# }
|
||||
BIN
recruitment/management/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
recruitment/management/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
36
recruitment/migrations/0005_zoommeeting.py
Normal file
36
recruitment/migrations/0005_zoommeeting.py
Normal file
@ -0,0 +1,36 @@
|
||||
# Generated by Django 5.2.6 on 2025-09-29 09:14
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0004_remove_candidate_status_candidate_applied'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ZoomMeeting',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('topic', models.CharField(max_length=255)),
|
||||
('meeting_id', models.CharField(max_length=20, unique=True)),
|
||||
('start_time', models.DateTimeField()),
|
||||
('duration', models.PositiveIntegerField()),
|
||||
('timezone', models.CharField(max_length=50)),
|
||||
('join_url', models.URLField()),
|
||||
('password', models.CharField(blank=True, max_length=50, null=True)),
|
||||
('host_email', models.EmailField(max_length=254)),
|
||||
('status', models.CharField(choices=[('waiting', 'Waiting'), ('started', 'Started'), ('ended', 'Ended')], default='waiting', max_length=10)),
|
||||
('host_video', models.BooleanField(default=True)),
|
||||
('participant_video', models.BooleanField(default=True)),
|
||||
('join_before_host', models.BooleanField(default=False)),
|
||||
('mute_upon_entry', models.BooleanField(default=False)),
|
||||
('waiting_room', models.BooleanField(default=False)),
|
||||
('zoom_gateway_response', models.JSONField(blank=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,318 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-02 14:14
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import recruitment.validators
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0005_zoommeeting'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='JobPosting',
|
||||
fields=[
|
||||
('title', models.CharField(max_length=200)),
|
||||
('department', models.CharField(blank=True, max_length=100)),
|
||||
('job_type', models.CharField(choices=[('FULL_TIME', 'Full-time'), ('PART_TIME', 'Part-time'), ('CONTRACT', 'Contract'), ('INTERNSHIP', 'Internship'), ('FACULTY', 'Faculty'), ('TEMPORARY', 'Temporary')], default='FULL_TIME', max_length=20)),
|
||||
('workplace_type', models.CharField(choices=[('ON_SITE', 'On-site'), ('REMOTE', 'Remote'), ('HYBRID', 'Hybrid')], default='ON_SITE', max_length=20)),
|
||||
('location_city', models.CharField(blank=True, max_length=100)),
|
||||
('location_state', models.CharField(blank=True, max_length=100)),
|
||||
('location_country', models.CharField(default='United States', max_length=100)),
|
||||
('description', models.TextField(help_text='Full job description including responsibilities and requirements')),
|
||||
('qualifications', models.TextField(blank=True, help_text='Required qualifications and skills')),
|
||||
('salary_range', models.CharField(blank=True, help_text='e.g., $60,000 - $80,000', max_length=200)),
|
||||
('benefits', models.TextField(blank=True, help_text='Benefits offered')),
|
||||
('application_url', models.URLField(help_text='URL where candidates apply', validators=[django.core.validators.URLValidator()])),
|
||||
('application_deadline', models.DateField(blank=True, null=True)),
|
||||
('application_instructions', models.TextField(blank=True, help_text='Special instructions for applicants')),
|
||||
('internal_job_id', models.CharField(editable=False, max_length=50, primary_key=True, serialize=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('created_by', models.CharField(blank=True, help_text='Name of person who created this job', max_length=100)),
|
||||
('status', models.CharField(choices=[('DRAFT', 'Draft'), ('ACTIVE', 'Active'), ('CLOSED', 'Closed'), ('ARCHIVED', 'Archived')], default='DRAFT', max_length=20)),
|
||||
('hash_tags', models.CharField(blank=True, help_text='Comma-separated hashtags for linkedin post like #hiring,#jobopening', max_length=200, validators=[recruitment.validators.validate_hash_tags])),
|
||||
('linkedin_post_id', models.CharField(blank=True, help_text='LinkedIn post ID after posting', max_length=200)),
|
||||
('linkedin_post_url', models.URLField(blank=True, help_text='Direct URL to LinkedIn post')),
|
||||
('posted_to_linkedin', models.BooleanField(default=False)),
|
||||
('linkedin_post_status', models.CharField(blank=True, help_text='Status of LinkedIn posting', max_length=50)),
|
||||
('linkedin_posted_at', models.DateTimeField(blank=True, null=True)),
|
||||
('position_number', models.CharField(blank=True, help_text='University position number', max_length=50)),
|
||||
('reporting_to', models.CharField(blank=True, help_text='Who this position reports to', max_length=100)),
|
||||
('start_date', models.DateField(blank=True, help_text='Desired start date', null=True)),
|
||||
('open_positions', models.PositiveIntegerField(default=1, help_text='Number of open positions for this job')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Job Posting',
|
||||
'verbose_name_plural': 'Job Postings',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='candidate',
|
||||
options={'verbose_name': 'Candidate', 'verbose_name_plural': 'Candidates'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='job',
|
||||
options={'verbose_name': 'Job', 'verbose_name_plural': 'Jobs'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='trainingmaterial',
|
||||
options={'verbose_name': 'Training Material', 'verbose_name_plural': 'Training Materials'},
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='zoommeeting',
|
||||
name='host_email',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='zoommeeting',
|
||||
name='host_video',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='zoommeeting',
|
||||
name='password',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='zoommeeting',
|
||||
name='status',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='candidate',
|
||||
name='exam_date',
|
||||
field=models.DateField(blank=True, null=True, verbose_name='Exam Date'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='candidate',
|
||||
name='exam_status',
|
||||
field=models.CharField(blank=True, choices=[('Passed', 'Passed'), ('Failed', 'Failed')], max_length=100, null=True, verbose_name='Exam Status'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='candidate',
|
||||
name='first_name',
|
||||
field=models.CharField(default='user', max_length=255, verbose_name='First Name'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='candidate',
|
||||
name='interview_date',
|
||||
field=models.DateField(blank=True, null=True, verbose_name='Interview Date'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='candidate',
|
||||
name='interview_status',
|
||||
field=models.CharField(blank=True, choices=[('Accepted', 'Accepted'), ('Rejected', 'Rejected')], max_length=100, null=True, verbose_name='Interview Status'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='candidate',
|
||||
name='join_date',
|
||||
field=models.DateField(blank=True, null=True, verbose_name='Join Date'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='candidate',
|
||||
name='last_name',
|
||||
field=models.CharField(default='user', max_length=255, verbose_name='Last Name'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='candidate',
|
||||
name='offer_date',
|
||||
field=models.DateField(blank=True, null=True, verbose_name='Offer Date'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='candidate',
|
||||
name='offer_status',
|
||||
field=models.CharField(blank=True, choices=[('Accepted', 'Accepted'), ('Rejected', 'Rejected')], max_length=100, null=True, verbose_name='Offer Status'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='candidate',
|
||||
name='phone',
|
||||
field=models.CharField(default='0569874562', max_length=20, verbose_name='Phone'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='candidate',
|
||||
name='stage',
|
||||
field=models.CharField(default='Applied', max_length=100, verbose_name='Stage'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='candidate',
|
||||
name='applied',
|
||||
field=models.BooleanField(default=False, verbose_name='Applied'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='candidate',
|
||||
name='created_at',
|
||||
field=models.DateTimeField(auto_now_add=True, verbose_name='Created at'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='candidate',
|
||||
name='email',
|
||||
field=models.EmailField(max_length=254, verbose_name='Email'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='candidate',
|
||||
name='name',
|
||||
field=models.CharField(max_length=255, verbose_name='Name'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='candidate',
|
||||
name='parsed_summary',
|
||||
field=models.TextField(blank=True, verbose_name='Parsed Summary'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='candidate',
|
||||
name='resume',
|
||||
field=models.FileField(upload_to='resumes/', verbose_name='Resume'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='candidate',
|
||||
name='updated_at',
|
||||
field=models.DateTimeField(auto_now=True, verbose_name='Updated at'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='job',
|
||||
name='created_at',
|
||||
field=models.DateTimeField(auto_now_add=True, verbose_name='Created at'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='job',
|
||||
name='description_ar',
|
||||
field=models.TextField(verbose_name='Description Arabic'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='job',
|
||||
name='description_en',
|
||||
field=models.TextField(verbose_name='Description English'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='job',
|
||||
name='is_published',
|
||||
field=models.BooleanField(default=False, verbose_name='Published'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='job',
|
||||
name='posted_to_linkedin',
|
||||
field=models.BooleanField(default=False, verbose_name='Posted to LinkedIn'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='job',
|
||||
name='title',
|
||||
field=models.CharField(max_length=255, verbose_name='Title'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='job',
|
||||
name='updated_at',
|
||||
field=models.DateTimeField(auto_now=True, verbose_name='Updated at'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='trainingmaterial',
|
||||
name='content',
|
||||
field=models.TextField(blank=True, verbose_name='Content'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='trainingmaterial',
|
||||
name='created_at',
|
||||
field=models.DateTimeField(auto_now_add=True, verbose_name='Created at'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='trainingmaterial',
|
||||
name='created_by',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Created by'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='trainingmaterial',
|
||||
name='file',
|
||||
field=models.FileField(blank=True, upload_to='training_materials/', verbose_name='File'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='trainingmaterial',
|
||||
name='title',
|
||||
field=models.CharField(max_length=255, verbose_name='Title'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='trainingmaterial',
|
||||
name='updated_at',
|
||||
field=models.DateTimeField(auto_now=True, verbose_name='Updated at'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='trainingmaterial',
|
||||
name='video_link',
|
||||
field=models.URLField(blank=True, verbose_name='Video Link'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='zoommeeting',
|
||||
name='created_at',
|
||||
field=models.DateTimeField(auto_now_add=True, verbose_name='Created at'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='zoommeeting',
|
||||
name='duration',
|
||||
field=models.PositiveIntegerField(verbose_name='Duration'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='zoommeeting',
|
||||
name='join_before_host',
|
||||
field=models.BooleanField(default=False, verbose_name='Join Before Host'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='zoommeeting',
|
||||
name='join_url',
|
||||
field=models.URLField(verbose_name='Join URL'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='zoommeeting',
|
||||
name='meeting_id',
|
||||
field=models.CharField(max_length=20, unique=True, verbose_name='Meeting ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='zoommeeting',
|
||||
name='mute_upon_entry',
|
||||
field=models.BooleanField(default=False, verbose_name='Mute Upon Entry'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='zoommeeting',
|
||||
name='participant_video',
|
||||
field=models.BooleanField(default=True, verbose_name='Participant Video'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='zoommeeting',
|
||||
name='start_time',
|
||||
field=models.DateTimeField(verbose_name='Start Time'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='zoommeeting',
|
||||
name='timezone',
|
||||
field=models.CharField(max_length=50, verbose_name='Timezone'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='zoommeeting',
|
||||
name='topic',
|
||||
field=models.CharField(max_length=255, verbose_name='Topic'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='zoommeeting',
|
||||
name='updated_at',
|
||||
field=models.DateTimeField(auto_now=True, verbose_name='Updated at'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='zoommeeting',
|
||||
name='waiting_room',
|
||||
field=models.BooleanField(default=False, verbose_name='Waiting Room'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='zoommeeting',
|
||||
name='zoom_gateway_response',
|
||||
field=models.JSONField(blank=True, null=True, verbose_name='Zoom Gateway Response'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='candidate',
|
||||
name='job',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='candidates', to='recruitment.jobposting', verbose_name='Job'),
|
||||
),
|
||||
]
|
||||
18
recruitment/migrations/0007_alter_jobposting_status.py
Normal file
18
recruitment/migrations/0007_alter_jobposting_status.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-02 14:32
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0006_jobposting_alter_candidate_options_alter_job_options_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='jobposting',
|
||||
name='status',
|
||||
field=models.CharField(blank=True, choices=[('DRAFT', 'Draft'), ('ACTIVE', 'Active'), ('CLOSED', 'Closed'), ('ARCHIVED', 'Archived')], default='DRAFT', max_length=20, null=True),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-02 14:33
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0007_alter_jobposting_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='jobposting',
|
||||
name='published_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='jobposting',
|
||||
name='status',
|
||||
field=models.CharField(blank=True, choices=[('DRAFT', 'Draft'), ('PUBLISHED', 'Published'), ('CLOSED', 'Closed'), ('ARCHIVED', 'Archived')], default='DRAFT', max_length=20, null=True),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,49 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-02 14:39
|
||||
|
||||
import django_extensions.db.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0008_jobposting_published_at_alter_jobposting_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='candidate',
|
||||
name='slug',
|
||||
field=django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='job',
|
||||
name='slug',
|
||||
field=django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='jobposting',
|
||||
name='slug',
|
||||
field=django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='trainingmaterial',
|
||||
name='slug',
|
||||
field=django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='zoommeeting',
|
||||
name='slug',
|
||||
field=django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='jobposting',
|
||||
name='created_at',
|
||||
field=models.DateTimeField(auto_now_add=True, verbose_name='Created at'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='jobposting',
|
||||
name='updated_at',
|
||||
field=models.DateTimeField(auto_now=True, verbose_name='Updated at'),
|
||||
),
|
||||
]
|
||||
17
recruitment/migrations/0010_remove_candidate_name.py
Normal file
17
recruitment/migrations/0010_remove_candidate_name.py
Normal file
@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-02 15:16
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0009_candidate_slug_job_slug_jobposting_slug_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='candidate',
|
||||
name='name',
|
||||
),
|
||||
]
|
||||
18
recruitment/migrations/0011_alter_candidate_stage.py
Normal file
18
recruitment/migrations/0011_alter_candidate_stage.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-02 16:00
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0010_remove_candidate_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='candidate',
|
||||
name='stage',
|
||||
field=models.CharField(choices=[('Applied', 'Applied'), ('Exam', 'Exam'), ('Interview', 'Interview'), ('Offer', 'Offer')], default='Applied', max_length=100, verbose_name='Stage'),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,57 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-04 12:39
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0011_alter_candidate_stage'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Form',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=200)),
|
||||
('description', models.TextField(blank=True)),
|
||||
('structure', models.JSONField(default=dict)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='FormSubmission',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('submission_data', models.JSONField(default=dict)),
|
||||
('submitted_at', models.DateTimeField(auto_now_add=True)),
|
||||
('ip_address', models.GenericIPAddressField(blank=True, null=True)),
|
||||
('user_agent', models.TextField(blank=True)),
|
||||
('form', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='submissions', to='recruitment.form')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-submitted_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='UploadedFile',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('field_id', models.CharField(max_length=100)),
|
||||
('file', models.FileField(upload_to='form_uploads/%Y/%m/%d/')),
|
||||
('original_filename', models.CharField(max_length=255)),
|
||||
('uploaded_at', models.DateTimeField(auto_now_add=True)),
|
||||
('submission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to='recruitment.formsubmission')),
|
||||
],
|
||||
),
|
||||
]
|
||||
BIN
recruitment/migrations/__pycache__/0001_initial.cpython-313.pyc
Normal file
BIN
recruitment/migrations/__pycache__/0001_initial.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
recruitment/migrations/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
recruitment/migrations/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
@ -1,39 +1,315 @@
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from .validators import validate_hash_tags
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.validators import URLValidator
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_extensions.db.fields import RandomCharField
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
class Base(models.Model):
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('Created at'))
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name=_('Updated at'))
|
||||
slug = RandomCharField(length=8, unique=True, editable=False, verbose_name=_('Slug'))
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
# Create your models here.
|
||||
class Job(models.Model):
|
||||
title = models.CharField(max_length=255)
|
||||
description_en = models.TextField()
|
||||
description_ar = models.TextField()
|
||||
is_published = models.BooleanField(default=False)
|
||||
posted_to_linkedin = models.BooleanField(default=False)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
class Job(Base):
|
||||
title = models.CharField(max_length=255, verbose_name=_('Title'))
|
||||
description_en = models.TextField(verbose_name=_('Description English'))
|
||||
description_ar = models.TextField(verbose_name=_('Description Arabic'))
|
||||
is_published = models.BooleanField(default=False, verbose_name=_('Published'))
|
||||
posted_to_linkedin = models.BooleanField(default=False, verbose_name=_('Posted to LinkedIn'))
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('Created at'))
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name=_('Updated at'))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Job')
|
||||
verbose_name_plural = _('Jobs')
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
class JobPosting(Base):
|
||||
# Basic Job Information
|
||||
JOB_TYPES = [
|
||||
('FULL_TIME', 'Full-time'),
|
||||
('PART_TIME', 'Part-time'),
|
||||
('CONTRACT', 'Contract'),
|
||||
('INTERNSHIP', 'Internship'),
|
||||
('FACULTY', 'Faculty'),
|
||||
('TEMPORARY', 'Temporary'),
|
||||
]
|
||||
|
||||
WORKPLACE_TYPES = [
|
||||
('ON_SITE', 'On-site'),
|
||||
('REMOTE', 'Remote'),
|
||||
('HYBRID', 'Hybrid'),
|
||||
]
|
||||
|
||||
# Core Fields
|
||||
title = models.CharField(max_length=200)
|
||||
department = models.CharField(max_length=100, blank=True)
|
||||
job_type = models.CharField(max_length=20, choices=JOB_TYPES, default='FULL_TIME')
|
||||
workplace_type = models.CharField(max_length=20, choices=WORKPLACE_TYPES, default='ON_SITE')
|
||||
|
||||
|
||||
# Location
|
||||
location_city = models.CharField(max_length=100, blank=True)
|
||||
location_state = models.CharField(max_length=100, blank=True)
|
||||
location_country = models.CharField(max_length=100, default='United States')
|
||||
|
||||
# Job Details
|
||||
description = models.TextField(help_text="Full job description including responsibilities and requirements")
|
||||
qualifications = models.TextField(blank=True, help_text="Required qualifications and skills")
|
||||
salary_range = models.CharField(max_length=200, blank=True, help_text="e.g., $60,000 - $80,000")
|
||||
benefits = models.TextField(blank=True, help_text="Benefits offered")
|
||||
|
||||
|
||||
# Application Information
|
||||
application_url = models.URLField(validators=[URLValidator()], help_text="URL where candidates apply")
|
||||
application_deadline = models.DateField(null=True, blank=True)
|
||||
application_instructions = models.TextField(blank=True, help_text="Special instructions for applicants")
|
||||
|
||||
# Internal Tracking
|
||||
internal_job_id = models.CharField(max_length=50, primary_key=True, editable=False)
|
||||
created_by = models.CharField(max_length=100, blank=True, help_text="Name of person who created this job")
|
||||
|
||||
# Status Fields
|
||||
STATUS_CHOICES = [
|
||||
('DRAFT', 'Draft'),
|
||||
('PUBLISHED', 'Published'),
|
||||
('CLOSED', 'Closed'),
|
||||
('ARCHIVED', 'Archived'),
|
||||
]
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='DRAFT',null=True, blank=True)
|
||||
|
||||
#hashtags for social media
|
||||
hash_tags = models.CharField(max_length=200, blank=True, help_text="Comma-separated hashtags for linkedin post like #hiring,#jobopening",validators=[validate_hash_tags])
|
||||
|
||||
# LinkedIn Integration Fields
|
||||
linkedin_post_id = models.CharField(max_length=200, blank=True, help_text="LinkedIn post ID after posting")
|
||||
linkedin_post_url = models.URLField(blank=True, help_text="Direct URL to LinkedIn post")
|
||||
posted_to_linkedin = models.BooleanField(default=False)
|
||||
linkedin_post_status = models.CharField(max_length=50, blank=True, help_text="Status of LinkedIn posting")
|
||||
linkedin_posted_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
published_at = models.DateTimeField(null=True, blank=True)
|
||||
# University Specific Fields
|
||||
position_number = models.CharField(max_length=50, blank=True, help_text="University position number")
|
||||
reporting_to = models.CharField(max_length=100, blank=True, help_text="Who this position reports to")
|
||||
start_date = models.DateField(null=True, blank=True, help_text="Desired start date")
|
||||
open_positions = models.PositiveIntegerField(default=1, help_text="Number of open positions for this job")
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
verbose_name = "Job Posting"
|
||||
verbose_name_plural = "Job Postings"
|
||||
|
||||
class Candidate(models.Model):
|
||||
job = models.ForeignKey(Job, on_delete=models.CASCADE, related_name='candidates')
|
||||
name = models.CharField(max_length=255)
|
||||
email = models.EmailField()
|
||||
resume = models.FileField(upload_to='resumes/')
|
||||
parsed_summary = models.TextField(blank=True)
|
||||
applied = models.BooleanField(default=False)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
def __str__(self):
|
||||
return f"{self.title} - {self.get_status_display()}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Generate unique internal job ID if not exists
|
||||
if not self.internal_job_id:
|
||||
prefix = "UNIV"
|
||||
year = timezone.now().year
|
||||
# Get next sequential number
|
||||
last_job = JobPosting.objects.filter(
|
||||
internal_job_id__startswith=f"{prefix}-{year}-"
|
||||
).order_by('internal_job_id').last()
|
||||
|
||||
if last_job:
|
||||
last_num = int(last_job.internal_job_id.split('-')[-1])
|
||||
next_num = last_num + 1
|
||||
else:
|
||||
next_num = 1
|
||||
|
||||
self.internal_job_id = f"{prefix}-{year}-{next_num:04d}"
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def get_location_display(self):
|
||||
"""Return formatted location string"""
|
||||
parts = []
|
||||
if self.location_city:
|
||||
parts.append(self.location_city)
|
||||
if self.location_state:
|
||||
parts.append(self.location_state)
|
||||
if self.location_country and self.location_country != 'United States':
|
||||
parts.append(self.location_country)
|
||||
return ', '.join(parts) if parts else 'Not specified'
|
||||
|
||||
def is_expired(self):
|
||||
"""Check if application deadline has passed"""
|
||||
if self.application_deadline:
|
||||
return self.application_deadline < timezone.now().date()
|
||||
return False
|
||||
|
||||
|
||||
class Candidate(Base):
|
||||
class Stage(models.TextChoices):
|
||||
APPLIED = 'Applied', _('Applied')
|
||||
EXAM = 'Exam', _('Exam')
|
||||
INTERVIEW = 'Interview', _('Interview')
|
||||
OFFER = 'Offer', _('Offer')
|
||||
class ExamStatus(models.TextChoices):
|
||||
PASSED = 'Passed', _('Passed')
|
||||
FAILED = 'Failed', _('Failed')
|
||||
class Status(models.TextChoices):
|
||||
ACCEPTED = 'Accepted', _('Accepted')
|
||||
REJECTED = 'Rejected', _('Rejected')
|
||||
|
||||
# Stage transition validation constants
|
||||
STAGE_SEQUENCE = {
|
||||
'Applied': ['Exam', 'Interview', 'Offer'],
|
||||
'Exam': ['Interview', 'Offer'],
|
||||
'Interview': ['Offer'],
|
||||
'Offer': [] # Final stage - no further transitions
|
||||
}
|
||||
|
||||
job = models.ForeignKey(JobPosting, on_delete=models.CASCADE, related_name='candidates', verbose_name=_('Job'))
|
||||
first_name = models.CharField(max_length=255, verbose_name=_('First Name'))
|
||||
last_name = models.CharField(max_length=255, verbose_name=_('Last Name'))
|
||||
email = models.EmailField(verbose_name=_('Email'))
|
||||
phone = models.CharField(max_length=20, verbose_name=_('Phone'))
|
||||
resume = models.FileField(upload_to='resumes/', verbose_name=_('Resume'))
|
||||
parsed_summary = models.TextField(blank=True, verbose_name=_('Parsed Summary'))
|
||||
applied = models.BooleanField(default=False, verbose_name=_('Applied'))
|
||||
stage = models.CharField(max_length=100, default='Applied', choices=Stage.choices, verbose_name=_('Stage'))
|
||||
|
||||
exam_date = models.DateField(null=True, blank=True, verbose_name=_('Exam Date'))
|
||||
exam_status = models.CharField(choices=ExamStatus.choices,max_length=100, null=True, blank=True, verbose_name=_('Exam Status'))
|
||||
interview_date = models.DateField(null=True, blank=True, verbose_name=_('Interview Date'))
|
||||
interview_status = models.CharField(choices=Status.choices,max_length=100, null=True, blank=True, verbose_name=_('Interview Status'))
|
||||
offer_date = models.DateField(null=True, blank=True, verbose_name=_('Offer Date'))
|
||||
offer_status = models.CharField(choices=Status.choices,max_length=100, null=True, blank=True, verbose_name=_('Offer Status'))
|
||||
join_date = models.DateField(null=True, blank=True, verbose_name=_('Join Date'))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Candidate')
|
||||
verbose_name_plural = _('Candidates')
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return f"{self.first_name} {self.last_name}"
|
||||
@property
|
||||
def full_name(self):
|
||||
return self.name
|
||||
|
||||
def clean(self):
|
||||
"""Validate stage transitions"""
|
||||
# Only validate if this is an existing record (not being created)
|
||||
if self.pk and self.stage != self.__class__.objects.get(pk=self.pk).stage:
|
||||
old_stage = self.__class__.objects.get(pk=self.pk).stage
|
||||
allowed_next_stages = self.STAGE_SEQUENCE.get(old_stage, [])
|
||||
|
||||
if self.stage not in allowed_next_stages:
|
||||
raise ValidationError({
|
||||
'stage': f'Cannot transition from "{old_stage}" to "{self.stage}". '
|
||||
f'Allowed transitions: {", ".join(allowed_next_stages) or "None (final stage)"}'
|
||||
})
|
||||
|
||||
# Validate that the stage is a valid choice
|
||||
if self.stage not in [choice[0] for choice in self.Stage.choices]:
|
||||
raise ValidationError({
|
||||
'stage': f'Invalid stage. Must be one of: {", ".join(choice[0] for choice in self.Stage.choices)}'
|
||||
})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Override save to ensure validation is called"""
|
||||
self.clean() # Call validation before saving
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def can_transition_to(self, new_stage):
|
||||
"""Check if a stage transition is allowed"""
|
||||
if not self.pk: # New record - can be in Applied stage
|
||||
return new_stage == 'Applied'
|
||||
|
||||
old_stage = self.__class__.objects.get(pk=self.pk).stage
|
||||
allowed_next_stages = self.STAGE_SEQUENCE.get(old_stage, [])
|
||||
return new_stage in allowed_next_stages
|
||||
|
||||
def get_available_stages(self):
|
||||
"""Get list of stages this candidate can transition to"""
|
||||
if not self.pk: # New record
|
||||
return ['Applied']
|
||||
|
||||
old_stage = self.__class__.objects.get(pk=self.pk).stage
|
||||
return self.STAGE_SEQUENCE.get(old_stage, [])
|
||||
|
||||
def __str__(self):
|
||||
return self.full_name
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class TrainingMaterial(Base):
|
||||
title = models.CharField(max_length=255, verbose_name=_('Title'))
|
||||
content = models.TextField(blank=True, verbose_name=_('Content'))
|
||||
video_link = models.URLField(blank=True, verbose_name=_('Video Link'))
|
||||
file = models.FileField(upload_to='training_materials/', blank=True, verbose_name=_('File'))
|
||||
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, verbose_name=_('Created by'))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Training Material')
|
||||
verbose_name_plural = _('Training Materials')
|
||||
|
||||
class TrainingMaterial(models.Model):
|
||||
title = models.CharField(max_length=255)
|
||||
content = models.TextField(blank=True)
|
||||
video_link = models.URLField(blank=True)
|
||||
file = models.FileField(upload_to='training_materials/', blank=True)
|
||||
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
|
||||
class ZoomMeeting(Base):
|
||||
# Basic meeting details
|
||||
topic = models.CharField(max_length=255, verbose_name=_('Topic'))
|
||||
meeting_id = models.CharField(max_length=20, unique=True, verbose_name=_('Meeting ID')) # Unique identifier for the meeting
|
||||
start_time = models.DateTimeField(verbose_name=_('Start Time'))
|
||||
duration = models.PositiveIntegerField(verbose_name=_('Duration')) # Duration in minutes
|
||||
timezone = models.CharField(max_length=50, verbose_name=_('Timezone'))
|
||||
join_url = models.URLField(verbose_name=_('Join URL')) # URL for participants to join
|
||||
participant_video = models.BooleanField(default=True, verbose_name=_('Participant Video'))
|
||||
join_before_host = models.BooleanField(default=False, verbose_name=_('Join Before Host'))
|
||||
mute_upon_entry = models.BooleanField(default=False, verbose_name=_('Mute Upon Entry'))
|
||||
waiting_room = models.BooleanField(default=False, verbose_name=_('Waiting Room'))
|
||||
|
||||
zoom_gateway_response = models.JSONField(blank=True, null=True, verbose_name=_('Zoom Gateway Response'))
|
||||
# Timestamps
|
||||
|
||||
def __str__(self):
|
||||
return self.topic
|
||||
|
||||
|
||||
class Form(models.Model):
|
||||
title = models.CharField(max_length=200)
|
||||
description = models.TextField(blank=True)
|
||||
structure = models.JSONField(default=dict) # Stores the form schema
|
||||
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
class FormSubmission(models.Model):
|
||||
form = models.ForeignKey(Form, on_delete=models.CASCADE, related_name='submissions')
|
||||
submission_data = models.JSONField(default=dict) # Stores form responses
|
||||
submitted_at = models.DateTimeField(auto_now_add=True)
|
||||
ip_address = models.GenericIPAddressField(null=True, blank=True)
|
||||
user_agent = models.TextField(blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-submitted_at']
|
||||
|
||||
class UploadedFile(models.Model):
|
||||
submission = models.ForeignKey(FormSubmission, on_delete=models.CASCADE, related_name='files')
|
||||
field_id = models.CharField(max_length=100)
|
||||
file = models.FileField(upload_to='form_uploads/%Y/%m/%d/')
|
||||
original_filename = models.CharField(max_length=255)
|
||||
uploaded_at = models.DateTimeField(auto_now_add=True)
|
||||
@ -1,12 +1,14 @@
|
||||
from rest_framework import serializers
|
||||
from . import models
|
||||
from .models import JobPosting, Candidate
|
||||
|
||||
class JobSerializer(serializers.ModelSerializer):
|
||||
class JobPostingSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = models.Job
|
||||
model = JobPosting
|
||||
fields = '__all__'
|
||||
|
||||
class CandidateSerializer(serializers.ModelSerializer):
|
||||
job_title = serializers.CharField(source='job.title', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.Candidate
|
||||
model = Candidate
|
||||
fields = '__all__'
|
||||
@ -1,11 +1,62 @@
|
||||
from django.urls import path
|
||||
from . import views_frontend
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path('careers/', views_frontend.job_list, name='job_list'),
|
||||
path('careers/<int:job_id>/', views_frontend.job_detail, name='job_detail'),
|
||||
path('training/', views_frontend.training_list, name='training_list'),
|
||||
path('candidate/<int:candidate_id>/view/', views_frontend.candidate_detail, name='candidate_detail'),
|
||||
path('dashboard/', views_frontend.dashboard_view, name='dashboard'),
|
||||
]
|
||||
|
||||
# Job URLs (using JobPosting model)
|
||||
path('jobs/', views_frontend.JobListView.as_view(), name='job_list'),
|
||||
path('jobs/create/', views.create_job, name='job_create'),
|
||||
path('jobs/<slug:slug>/update/', views.edit_job, name='job_update'),
|
||||
# path('jobs/<slug:slug>/delete/', views., name='job_delete'),
|
||||
path('jobs/<slug:slug>/', views.job_detail, name='job_detail'),
|
||||
|
||||
# LinkedIn Integration URLs
|
||||
path('jobs/<slug:slug>/post-to-linkedin/', views.post_to_linkedin, name='post_to_linkedin'),
|
||||
path('jobs/linkedin/login/', views.linkedin_login, name='linkedin_login'),
|
||||
path('jobs/linkedin/callback/', views.linkedin_callback, name='linkedin_callback'),
|
||||
|
||||
# Candidate URLs
|
||||
path('candidates/', views_frontend.CandidateListView.as_view(), name='candidate_list'),
|
||||
path('candidates/create/', views_frontend.CandidateCreateView.as_view(), name='candidate_create'),
|
||||
path('candidates/create/<slug:slug>/', views_frontend.CandidateCreateView.as_view(), name='candidate_create_for_job'),
|
||||
path('jobs/<slug:slug>/candidates/', views_frontend.JobCandidatesListView.as_view(), name='job_candidates_list'),
|
||||
path('candidates/<slug:slug>/update/', views_frontend.CandidateUpdateView.as_view(), name='candidate_update'),
|
||||
path('candidates/<slug:slug>/delete/', views_frontend.CandidateDeleteView.as_view(), name='candidate_delete'),
|
||||
path('candidate/<slug:slug>/view/', views_frontend.candidate_detail, name='candidate_detail'),
|
||||
path('candidate/<slug:slug>/update-stage/', views_frontend.candidate_update_stage, name='candidate_update_stage'),
|
||||
|
||||
# Training URLs
|
||||
path('training/', views_frontend.TrainingListView.as_view(), name='training_list'),
|
||||
path('training/create/', views_frontend.TrainingCreateView.as_view(), name='training_create'),
|
||||
path('training/<slug:slug>/', views_frontend.TrainingDetailView.as_view(), name='training_detail'),
|
||||
path('training/<slug:slug>/update/', views_frontend.TrainingUpdateView.as_view(), name='training_update'),
|
||||
path('training/<slug:slug>/delete/', views_frontend.TrainingDeleteView.as_view(), name='training_delete'),
|
||||
|
||||
# Meeting URLs
|
||||
path('', views.ZoomMeetingListView.as_view(), name='list_meetings'),
|
||||
path('create-meeting/', views.ZoomMeetingCreateView.as_view(), name='create_meeting'),
|
||||
path('meeting-details/<slug:slug>/', views.ZoomMeetingDetailsView.as_view(), name='meeting_details'),
|
||||
path('update-meeting/<slug:slug>/', views.ZoomMeetingUpdateView.as_view(), name='update_meeting'),
|
||||
path('delete-meeting/<slug:slug>/', views.ZoomMeetingDeleteView, name='delete_meeting'),
|
||||
|
||||
# JobPosting functional views URLs (keeping for compatibility)
|
||||
path('api/create/', views.create_job, name='create_job_api'),
|
||||
path('api/<slug:slug>/edit/', views.edit_job, name='edit_job_api'),
|
||||
|
||||
#
|
||||
path('form_builder/', views.form_builder, name='form_builder'),
|
||||
|
||||
# Form Preview URLs
|
||||
path('forms/', views.form_list, name='form_list'),
|
||||
path('forms/<int:form_id>/', views.form_preview, name='form_preview'),
|
||||
path('forms/<int:form_id>/submit/', views.form_submit, name='form_submit'),
|
||||
path('forms/<int:form_id>/embed/', views.form_embed, name='form_embed'),
|
||||
path('forms/<int:form_id>/submissions/', views.form_submissions, name='form_submissions'),
|
||||
path('forms/<int:form_id>/edit/', views.edit_form, name='edit_form'),
|
||||
path('api/forms/save/', views.save_form_builder, name='save_form_builder'),
|
||||
path('api/forms/<int:form_id>/load/', views.load_form, name='load_form'),
|
||||
path('api/forms/<int:form_id>/update/', views.update_form_builder, name='update_form_builder'),
|
||||
|
||||
]
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import os
|
||||
import fitz # PyMuPDF
|
||||
import spacy
|
||||
import os
|
||||
import requests
|
||||
from recruitment import models
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
nlp = spacy.load("en_core_web_sm")
|
||||
|
||||
@ -44,3 +45,270 @@ 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
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
auth = (client_id, client_secret)
|
||||
|
||||
response = requests.post(auth_url, headers=headers, data=data, auth=auth)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json().get("access_token")
|
||||
else:
|
||||
raise Exception(f"Failed to obtain access token: {response.json()}")
|
||||
|
||||
def create_zoom_meeting(topic, start_time, duration):
|
||||
"""
|
||||
Create a Zoom meeting using the Zoom API.
|
||||
|
||||
Args:
|
||||
topic (str): The topic of the meeting.
|
||||
start_time (str): The start time of the meeting in ISO 8601 format (e.g., "2023-10-01T10:00:00Z").
|
||||
duration (int): The duration of the meeting in minutes.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary containing the meeting details if successful, or an error message if failed.
|
||||
"""
|
||||
try:
|
||||
access_token = get_access_token()
|
||||
|
||||
meeting_details = {
|
||||
"topic": topic,
|
||||
"type": 2,
|
||||
"start_time": start_time,
|
||||
"duration": duration,
|
||||
"timezone": "UTC",
|
||||
"settings": {
|
||||
"host_video": True,
|
||||
"participant_video": True,
|
||||
"join_before_host": True,
|
||||
"mute_upon_entry": False,
|
||||
"approval_type": 2,
|
||||
"audio": "both",
|
||||
"auto_recording": "none"
|
||||
}
|
||||
}
|
||||
|
||||
# Make API request to Zoom to create the meeting
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
response = requests.post(
|
||||
"https://api.zoom.us/v2/users/me/meetings",
|
||||
headers=headers,
|
||||
json=meeting_details
|
||||
)
|
||||
|
||||
# Check response status
|
||||
if response.status_code == 201:
|
||||
meeting_data = response.json()
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Meeting created successfully.",
|
||||
"meeting_details": {
|
||||
"join_url": meeting_data['join_url'],
|
||||
"meeting_id": meeting_data['id'],
|
||||
"password": meeting_data['password'],
|
||||
"host_email": meeting_data['host_email']
|
||||
},
|
||||
"zoom_gateway_response": meeting_data
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "Failed to create meeting.",
|
||||
"details": response.json()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": str(e)
|
||||
}
|
||||
|
||||
|
||||
def list_zoom_meetings(next_page_token=None):
|
||||
"""
|
||||
List all meetings for a user using the Zoom API.
|
||||
|
||||
Args:
|
||||
next_page_token (str, optional): The token for paginated results. Defaults to None.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary containing the list of meetings or an error message.
|
||||
"""
|
||||
try:
|
||||
access_token = get_access_token()
|
||||
user_id = 'me'
|
||||
|
||||
params = {}
|
||||
if next_page_token:
|
||||
params['next_page_token'] = next_page_token
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
response = requests.get(
|
||||
f"https://api.zoom.us/v2/users/{user_id}/meetings",
|
||||
headers=headers,
|
||||
params=params
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
meetings_data = response.json()
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Meetings retrieved successfully.",
|
||||
"meetings": meetings_data.get("meetings", []),
|
||||
"next_page_token": meetings_data.get("next_page_token")
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "Failed to retrieve meetings.",
|
||||
"details": response.json()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": str(e)
|
||||
}
|
||||
|
||||
|
||||
def get_zoom_meeting_details(meeting_id):
|
||||
"""
|
||||
Retrieve details of a specific meeting using the Zoom API.
|
||||
|
||||
Args:
|
||||
meeting_id (str): The ID of the meeting to retrieve.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary containing the meeting details or an error message.
|
||||
"""
|
||||
try:
|
||||
access_token = get_access_token()
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
response = requests.get(
|
||||
f"https://api.zoom.us/v2/meetings/{meeting_id}",
|
||||
headers=headers
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
meeting_data = response.json()
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Meeting details retrieved successfully.",
|
||||
"meeting_details": meeting_data
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "Failed to retrieve meeting details.",
|
||||
"details": response.json()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": str(e)
|
||||
}
|
||||
|
||||
|
||||
def update_zoom_meeting(meeting_id, updated_data):
|
||||
"""
|
||||
Update a Zoom meeting using the Zoom API.
|
||||
|
||||
Args:
|
||||
meeting_id (str): The ID of the meeting to update.
|
||||
updated_data (dict): A dictionary containing the fields to update (e.g., topic, start_time, duration).
|
||||
|
||||
Returns:
|
||||
dict: A dictionary containing the updated meeting details or an error message.
|
||||
"""
|
||||
try:
|
||||
access_token = get_access_token()
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
response = requests.patch(
|
||||
f"https://api.zoom.us/v2/meetings/{meeting_id}",
|
||||
headers=headers,
|
||||
json=updated_data
|
||||
)
|
||||
|
||||
if response.status_code == 204:
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Meeting updated successfully."
|
||||
}
|
||||
else:
|
||||
print(response.json())
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "Failed to update meeting.",
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": str(e)
|
||||
}
|
||||
|
||||
|
||||
def delete_zoom_meeting(meeting_id):
|
||||
"""
|
||||
Delete a Zoom meeting using the Zoom API.
|
||||
|
||||
Args:
|
||||
meeting_id (str): The ID of the meeting to delete.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary indicating success or failure.
|
||||
"""
|
||||
try:
|
||||
access_token = get_access_token()
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}"
|
||||
}
|
||||
response = requests.delete(
|
||||
f"https://api.zoom.us/v2/meetings/{meeting_id}",
|
||||
headers=headers
|
||||
)
|
||||
|
||||
if response.status_code == 204:
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Meeting deleted successfully."
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "Failed to delete meeting.",
|
||||
"details": response.json()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": str(e)
|
||||
}
|
||||
15
recruitment/validators.py
Normal file
15
recruitment/validators.py
Normal file
@ -0,0 +1,15 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
def validate_image_size(image):
|
||||
max_size_mb = 2
|
||||
if image.size > max_size_mb * 1024 * 1024:
|
||||
raise ValidationError(f"Image size should not exceed {max_size_mb}MB.")
|
||||
|
||||
def validate_hash_tags(value):
|
||||
if value:
|
||||
tags = [tag.strip() for tag in value.split(',')]
|
||||
for tag in tags:
|
||||
if ' ' in tag:
|
||||
raise ValidationError("Hash tags should not contain spaces.")
|
||||
if not tag.startswith('#'):
|
||||
raise ValidationError("Each hash tag should start with '#' symbol.")
|
||||
@ -1,14 +1,577 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
import json
|
||||
import requests
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from django.http import JsonResponse
|
||||
from recruitment.models import FormSubmission,Form,UploadedFile
|
||||
from datetime import datetime
|
||||
from django.views import View
|
||||
from django.db.models import Q
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from .forms import ZoomMeetingForm,JobPostingForm
|
||||
from rest_framework import viewsets
|
||||
from . import models
|
||||
from . import serializers
|
||||
from django.contrib import messages
|
||||
from django.core.paginator import Paginator
|
||||
from .linkedin_service import LinkedInService
|
||||
from .models import ZoomMeeting, Job, Candidate, JobPosting
|
||||
from .serializers import JobPostingSerializer, CandidateSerializer
|
||||
from django.shortcuts import get_object_or_404, render, redirect
|
||||
from django.views.generic import CreateView,UpdateView,DetailView,ListView
|
||||
from .utils import create_zoom_meeting, delete_zoom_meeting, update_zoom_meeting
|
||||
|
||||
class JobViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.Job.objects.all()
|
||||
serializer_class = serializers.JobSerializer
|
||||
import logging
|
||||
logger=logging.getLogger(__name__)
|
||||
|
||||
|
||||
class JobPostingViewSet(viewsets.ModelViewSet):
|
||||
queryset = JobPosting.objects.all()
|
||||
serializer_class = JobPostingSerializer
|
||||
|
||||
class CandidateViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.Candidate.objects.all()
|
||||
serializer_class = serializers.CandidateSerializer
|
||||
queryset = Candidate.objects.all()
|
||||
serializer_class = CandidateSerializer
|
||||
|
||||
|
||||
class ZoomMeetingCreateView(CreateView):
|
||||
model = ZoomMeeting
|
||||
template_name = 'meetings/create_meeting.html'
|
||||
form_class = ZoomMeetingForm
|
||||
success_url = '/'
|
||||
|
||||
def form_valid(self, form):
|
||||
instance = form.save(commit=False)
|
||||
try:
|
||||
topic = instance.topic
|
||||
if instance.start_time < timezone.now():
|
||||
messages.error(self.request, "Start time must be in the future.")
|
||||
return redirect('/create-meeting/', status=400)
|
||||
start_time = instance.start_time.isoformat() + "Z"
|
||||
duration = instance.duration
|
||||
|
||||
result = create_zoom_meeting(topic, start_time, duration)
|
||||
|
||||
if result["status"] == "success":
|
||||
instance.meeting_id = result['meeting_details']['meeting_id']
|
||||
instance.join_url = result['meeting_details']['join_url']
|
||||
instance.host_email = result['meeting_details']['host_email']
|
||||
instance.zoom_gateway_response = result['zoom_gateway_response']
|
||||
instance.save()
|
||||
messages.success(self.request, result["message"])
|
||||
|
||||
return redirect('/', status=201)
|
||||
else:
|
||||
messages.error(self.request, result["message"])
|
||||
return redirect('/', status=400)
|
||||
except Exception as e:
|
||||
return redirect('/', status=500)
|
||||
|
||||
class ZoomMeetingListView(ListView):
|
||||
model = ZoomMeeting
|
||||
template_name = 'meetings/list_meetings.html'
|
||||
context_object_name = 'meetings'
|
||||
paginate_by = 10
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset().order_by('-start_time')
|
||||
|
||||
# Handle search
|
||||
search_query = self.request.GET.get('search', '')
|
||||
if search_query:
|
||||
queryset = queryset.filter(
|
||||
Q(topic__icontains=search_query) |
|
||||
Q(meeting_id__icontains=search_query) |
|
||||
Q(host_email__icontains=search_query)
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['search_query'] = self.request.GET.get('search', '')
|
||||
return context
|
||||
|
||||
class ZoomMeetingDetailsView(DetailView):
|
||||
model = ZoomMeeting
|
||||
template_name = 'meetings/meeting_details.html'
|
||||
context_object_name = 'meeting'
|
||||
|
||||
class ZoomMeetingUpdateView(UpdateView):
|
||||
model = ZoomMeeting
|
||||
form_class = ZoomMeetingForm
|
||||
context_object_name = 'meeting'
|
||||
template_name = 'meetings/update_meeting.html'
|
||||
success_url = '/'
|
||||
|
||||
def form_valid(self, form):
|
||||
instance = form.save(commit=False)
|
||||
updated_data = {
|
||||
'topic': instance.topic,
|
||||
'start_time': instance.start_time.isoformat() + "Z",
|
||||
'duration': instance.duration
|
||||
}
|
||||
if instance.start_time < timezone.now():
|
||||
messages.error(self.request, "Start time must be in the future.")
|
||||
return redirect(f'/update-meeting/{instance.pk}/', status=400)
|
||||
|
||||
result = update_zoom_meeting(instance.meeting_id, updated_data)
|
||||
if result["status"] == "success":
|
||||
instance.save()
|
||||
messages.success(self.request, result["message"])
|
||||
return redirect(reverse('meeting_details', kwargs={'pk': instance.pk}))
|
||||
else:
|
||||
messages.error(self.request, result["message"])
|
||||
return redirect(reverse('meeting_details', kwargs={'pk': instance.pk}))
|
||||
|
||||
def ZoomMeetingDeleteView(request, pk):
|
||||
meeting = get_object_or_404(ZoomMeeting, pk=pk)
|
||||
meeting_id = meeting.meeting_id
|
||||
try:
|
||||
result = delete_zoom_meeting(meeting_id)
|
||||
if result["status"] == "success":
|
||||
meeting.delete()
|
||||
messages.success(request, result["message"])
|
||||
else:
|
||||
messages.error(request, result["message"])
|
||||
return redirect('/')
|
||||
except Exception as e:
|
||||
messages.error(request, str(e))
|
||||
return redirect('/')
|
||||
|
||||
|
||||
#Job Posting
|
||||
def job_list(request):
|
||||
"""Display the list of job postings order by creation date descending"""
|
||||
jobs=JobPosting.objects.all().order_by('-created_at')
|
||||
|
||||
# Filter by status if provided
|
||||
status=request.GET.get('status')
|
||||
if status:
|
||||
jobs=jobs.filter(status=status)
|
||||
|
||||
#pagination
|
||||
paginator=Paginator(jobs,10) # Show 10 jobs per page
|
||||
page_number=request.GET.get('page')
|
||||
page_obj=paginator.get_page(page_number)
|
||||
return render(request, 'jobs/job_list.html', {
|
||||
'page_obj': page_obj,
|
||||
'status_filter': status
|
||||
})
|
||||
|
||||
|
||||
def create_job(request):
|
||||
"""Create a new job posting"""
|
||||
if request.method=='POST':
|
||||
|
||||
form=JobPostingForm(request.POST,is_anonymous_user=not request.user.is_authenticated)
|
||||
#to check user is authenticated or not
|
||||
if form.is_valid():
|
||||
try:
|
||||
job=form.save(commit=False)
|
||||
if request.user.is_authenticated:
|
||||
job.created_by=request.user.get_full_name() or request.user.username
|
||||
else:
|
||||
job.created_by=request.POST.get('created_by','').strip()
|
||||
if not job.created_by:
|
||||
job.created_by="University Administrator"
|
||||
job.save()
|
||||
messages.success(request,f'Job "{job.title}" created successfully!')
|
||||
return redirect('job_list')
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating job: {e}")
|
||||
messages.error(request,f"Error creating job: {e}")
|
||||
else:
|
||||
messages.error(request, f'Please correct the errors below.{form.errors}')
|
||||
else:
|
||||
form=JobPostingForm(is_anonymous_user=not request.user.is_authenticated)
|
||||
return render(request,'jobs/create_job.html',{'form':form})
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def edit_job(request,slug):
|
||||
"""Edit an existing job posting"""
|
||||
if request.method=='POST':
|
||||
job=get_object_or_404(JobPosting,slug=slug)
|
||||
form=JobPostingForm(request.POST,instance=job,is_anonymous_user=not request.user.is_authenticated)
|
||||
if form.is_valid():
|
||||
try:
|
||||
job=form.save(commit=False)
|
||||
if request.user.is_authenticated:
|
||||
job.created_by=request.user.get_full_name() or request.user.username
|
||||
else:
|
||||
job.created_by=request.POST.get('created_by','').strip()
|
||||
if not job.created_by:
|
||||
job.created_by="University Administrator"
|
||||
job.save()
|
||||
messages.success(request,f'Job "{job.title}" updated successfully!')
|
||||
return redirect('job_list')
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating job: {e}")
|
||||
messages.error(request,f"Error updating job: {e}")
|
||||
else:
|
||||
messages.error(request, 'Please correct the errors below.')
|
||||
else:
|
||||
job=get_object_or_404(JobPosting,slug=slug)
|
||||
form=JobPostingForm(instance=job,is_anonymous_user=not request.user.is_authenticated)
|
||||
return render(request,'jobs/edit_job.html',{'form':form,'job':job})
|
||||
|
||||
def job_detail(request, slug):
|
||||
"""View details of a specific job"""
|
||||
job = get_object_or_404(JobPosting, slug=slug)
|
||||
|
||||
# Get all candidates for this job, ordered by most recent
|
||||
candidates = job.candidates.all().order_by('-created_at')
|
||||
|
||||
# Count candidates by stage for summary statistics
|
||||
total_candidates = candidates.count()
|
||||
applied_count = candidates.filter(stage='Applied').count()
|
||||
interview_count = candidates.filter(stage='Interview').count()
|
||||
offer_count = candidates.filter(stage='Offer').count()
|
||||
|
||||
context = {
|
||||
'job': job,
|
||||
'candidates': candidates,
|
||||
'total_candidates': total_candidates,
|
||||
'applied_count': applied_count,
|
||||
'interview_count': interview_count,
|
||||
'offer_count': offer_count,
|
||||
}
|
||||
return render(request, 'jobs/job_detail.html', context)
|
||||
\
|
||||
|
||||
def post_to_linkedin(request,slug):
|
||||
"""Post a job to LinkedIn"""
|
||||
job=get_object_or_404(JobPosting,slug=slug)
|
||||
if job.status!='ACTIVE':
|
||||
messages.info(request,'Only active jobs can be posted to LinkedIn.')
|
||||
return redirect('job_list')
|
||||
|
||||
if request.method=='POST':
|
||||
try:
|
||||
# Check if user is authenticated with LinkedIn
|
||||
if 'linkedin_access_token' not in request.session:
|
||||
messages.error(request,'Please authenticate with LinkedIn first.')
|
||||
return redirect('linkedin_login')
|
||||
|
||||
# Clear previous LinkedIn data for re-posting
|
||||
job.posted_to_linkedin=False
|
||||
job.linkedin_post_id=''
|
||||
job.linkedin_post_url=''
|
||||
job.linkedin_post_status=''
|
||||
job.linkedin_posted_at=None
|
||||
job.save()
|
||||
|
||||
# Initialize LinkedIn service
|
||||
service=LinkedInService()
|
||||
service.access_token=request.session['linkedin_access_token']
|
||||
|
||||
# Post to LinkedIn
|
||||
result=service.create_job_post(job)
|
||||
if result['success']:
|
||||
# Update job with LinkedIn info
|
||||
job.posted_to_linkedin=True
|
||||
job.linkedin_post_id=result['post_id']
|
||||
job.linkedin_post_url=result['post_url']
|
||||
job.linkedin_post_status='SUCCESS'
|
||||
job.linkedin_posted_at=timezone.now()
|
||||
job.save()
|
||||
|
||||
messages.success(request,'Job posted to LinkedIn successfully!')
|
||||
else:
|
||||
error_msg=result.get('error','Unknown error')
|
||||
job.linkedin_post_status=f'ERROR: {error_msg}'
|
||||
job.save()
|
||||
messages.error(request,f'Error posting to LinkedIn: {error_msg}')
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in post_to_linkedin: {e}")
|
||||
job.linkedin_post_status = f'ERROR: {str(e)}'
|
||||
job.save()
|
||||
messages.error(request, f'Error posting to LinkedIn: {e}')
|
||||
|
||||
return redirect('job_detail', slug=job.slug)
|
||||
|
||||
def linkedin_login(request):
|
||||
"""Redirect to LinkedIn OAuth"""
|
||||
service=LinkedInService()
|
||||
auth_url=service.get_auth_url()
|
||||
"""
|
||||
It creates a special URL that:
|
||||
Sends the user to LinkedIn to log in
|
||||
Asks the user to grant your app permission to post on their behalf
|
||||
Tells LinkedIn where to send the user back after they approve (your redirect_uri)
|
||||
http://yoursite.com/linkedin/callback/?code=TEMPORARY_CODE_HERE
|
||||
"""
|
||||
return redirect(auth_url)
|
||||
|
||||
|
||||
def linkedin_callback(request):
|
||||
"""Handle LinkedIn OAuth callback"""
|
||||
code=request.GET.get('code')
|
||||
if not code:
|
||||
messages.error(request,'No authorization code received from LinkedIn.')
|
||||
return redirect('job_list')
|
||||
|
||||
try:
|
||||
service=LinkedInService()
|
||||
#get_access_token(code)->It makes a POST request to LinkedIn’s token endpoint with parameters
|
||||
access_token=service.get_access_token(code)
|
||||
request.session['linkedin_access_token']=access_token
|
||||
request.session['linkedin_authenticated']=True
|
||||
messages.success(request,'Successfully authenticated with LinkedIn!')
|
||||
except Exception as e:
|
||||
logger.error(f"LinkedIn authentication error: {e}")
|
||||
messages.error(request,f'LinkedIn authentication failed: {e}')
|
||||
|
||||
return redirect('job_list')
|
||||
|
||||
|
||||
#applicant views
|
||||
def applicant_job_detail(request,slug):
|
||||
"""View job details for applicants"""
|
||||
job=get_object_or_404(JobPosting,slug=slug,status='ACTIVE')
|
||||
return render(request,'jobs/applicant_job_detail.html',{'job':job})
|
||||
|
||||
def form_builder(request):
|
||||
return render(request,'form_builder.html')
|
||||
|
||||
|
||||
# Form Preview Views
|
||||
from django.http import JsonResponse
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.core.paginator import Paginator
|
||||
from django.contrib.auth.decorators import login_required
|
||||
import json
|
||||
|
||||
def form_list(request):
|
||||
"""Display list of all available forms"""
|
||||
forms = Form.objects.filter(is_active=True).order_by('-created_at')
|
||||
|
||||
# Pagination
|
||||
paginator = Paginator(forms, 12)
|
||||
page_number = request.GET.get('page')
|
||||
page_obj = paginator.get_page(page_number)
|
||||
|
||||
return render(request, 'forms/form_list.html', {
|
||||
'page_obj': page_obj
|
||||
})
|
||||
|
||||
def form_preview(request, form_id):
|
||||
"""Display form preview for end users"""
|
||||
form = get_object_or_404(Form, id=form_id, is_active=True)
|
||||
|
||||
# Get submission count for analytics
|
||||
submission_count = form.submissions.count()
|
||||
|
||||
return render(request, 'forms/form_preview.html', {
|
||||
'form': form,
|
||||
'submission_count': submission_count,
|
||||
'is_embed': request.GET.get('embed', 'false') == 'true'
|
||||
})
|
||||
|
||||
@csrf_exempt
|
||||
def form_submit(request, form_id):
|
||||
"""Handle form submission via AJAX"""
|
||||
if request.method != 'POST':
|
||||
return JsonResponse({'success': False, 'error': 'Only POST method allowed'}, status=405)
|
||||
|
||||
form = get_object_or_404(Form, id=form_id, is_active=True)
|
||||
|
||||
try:
|
||||
# Parse form data
|
||||
submission_data = {}
|
||||
files = {}
|
||||
|
||||
# Process regular form fields
|
||||
for key, value in request.POST.items():
|
||||
if key != 'csrfmiddlewaretoken':
|
||||
submission_data[key] = value
|
||||
|
||||
# Process file uploads
|
||||
for key, file in request.FILES.items():
|
||||
if file:
|
||||
files[key] = file
|
||||
|
||||
# Create form submission
|
||||
submission = FormSubmission.objects.create(
|
||||
form=form,
|
||||
submission_data=submission_data,
|
||||
ip_address=request.META.get('REMOTE_ADDR'),
|
||||
user_agent=request.META.get('HTTP_USER_AGENT', '')
|
||||
)
|
||||
|
||||
# Handle file uploads
|
||||
for field_id, file in files.items():
|
||||
UploadedFile.objects.create(
|
||||
submission=submission,
|
||||
field_id=field_id,
|
||||
file=file,
|
||||
original_filename=file.name
|
||||
)
|
||||
|
||||
# TODO: Send email notification if configured
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'message': 'Form submitted successfully!',
|
||||
'submission_id': submission.id
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error submitting form {form_id}: {e}")
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': 'An error occurred while submitting the form. Please try again.'
|
||||
}, status=500)
|
||||
|
||||
def form_embed(request, form_id):
|
||||
"""Display embeddable version of form"""
|
||||
form = get_object_or_404(Form, id=form_id, is_active=True)
|
||||
|
||||
return render(request, 'forms/form_embed.html', {
|
||||
'form': form,
|
||||
'is_embed': True
|
||||
})
|
||||
|
||||
@login_required
|
||||
def save_form_builder(request):
|
||||
"""Save form from builder to database"""
|
||||
if request.method != 'POST':
|
||||
return JsonResponse({'success': False, 'error': 'Only POST method allowed'}, status=405)
|
||||
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
form_data = data.get('form', {})
|
||||
|
||||
# Check if this is an update or create
|
||||
form_id = data.get('form_id')
|
||||
|
||||
if form_id:
|
||||
# Update existing form
|
||||
form = Form.objects.get(id=form_id, created_by=request.user)
|
||||
form.title = form_data.get('title', 'Untitled Form')
|
||||
form.description = form_data.get('description', '')
|
||||
form.structure = form_data
|
||||
form.save()
|
||||
else:
|
||||
# Create new form
|
||||
form = Form.objects.create(
|
||||
title=form_data.get('title', 'Untitled Form'),
|
||||
description=form_data.get('description', ''),
|
||||
structure=form_data,
|
||||
created_by=request.user
|
||||
)
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'form_id': form.id,
|
||||
'message': 'Form saved successfully!'
|
||||
})
|
||||
|
||||
except json.JSONDecodeError:
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': 'Invalid JSON data'
|
||||
}, status=400)
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving form: {e}")
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': 'An error occurred while saving the form'
|
||||
}, status=500)
|
||||
|
||||
@login_required
|
||||
def load_form(request, form_id):
|
||||
"""Load form data for editing in builder"""
|
||||
try:
|
||||
form = get_object_or_404(Form, id=form_id, created_by=request.user)
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'form': {
|
||||
'id': form.id,
|
||||
'title': form.title,
|
||||
'description': form.description,
|
||||
'structure': form.structure
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading form {form_id}: {e}")
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': 'An error occurred while loading the form'
|
||||
}, status=500)
|
||||
|
||||
@csrf_exempt
|
||||
def update_form_builder(request, form_id):
|
||||
"""Update existing form from builder"""
|
||||
if request.method != 'POST':
|
||||
return JsonResponse({'success': False, 'error': 'Only POST method allowed'}, status=405)
|
||||
|
||||
try:
|
||||
form = get_object_or_404(Form, id=form_id)
|
||||
|
||||
# Check if user has permission to edit this form
|
||||
if form.created_by != request.user:
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': 'You do not have permission to edit this form'
|
||||
}, status=403)
|
||||
|
||||
data = json.loads(request.body)
|
||||
form_data = data.get('form', {})
|
||||
|
||||
# Update form
|
||||
form.title = form_data.get('title', 'Untitled Form')
|
||||
form.description = form_data.get('description', '')
|
||||
form.structure = form_data
|
||||
form.save()
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'form_id': form.id,
|
||||
'message': 'Form updated successfully!'
|
||||
})
|
||||
|
||||
except json.JSONDecodeError:
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': 'Invalid JSON data'
|
||||
}, status=400)
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating form {form_id}: {e}")
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': 'An error occurred while updating the form'
|
||||
}, status=500)
|
||||
|
||||
def edit_form(request, form_id):
|
||||
"""Display form edit page"""
|
||||
form = get_object_or_404(Form, id=form_id)
|
||||
|
||||
# Check if user has permission to edit this form
|
||||
if form.created_by != request.user:
|
||||
messages.error(request, 'You do not have permission to edit this form.')
|
||||
return redirect('form_list')
|
||||
|
||||
return render(request, 'forms/edit_form.html', {
|
||||
'form': form
|
||||
})
|
||||
|
||||
def form_submissions(request, form_id):
|
||||
"""View submissions for a specific form"""
|
||||
form = get_object_or_404(Form, id=form_id, created_by=request.user)
|
||||
submissions = form.submissions.all().order_by('-submitted_at')
|
||||
|
||||
# Pagination
|
||||
paginator = Paginator(submissions, 20)
|
||||
page_number = request.GET.get('page')
|
||||
page_obj = paginator.get_page(page_number)
|
||||
|
||||
return render(request, 'forms/form_submissions.html', {
|
||||
'form': form,
|
||||
'page_obj': page_obj
|
||||
})
|
||||
|
||||
@ -1,20 +1,192 @@
|
||||
from django.shortcuts import render, get_object_or_404
|
||||
from django.http import JsonResponse
|
||||
from . import models
|
||||
from django.utils.translation import get_language
|
||||
from . import forms
|
||||
from django.contrib.auth.decorators import login_required, user_passes_test
|
||||
from django.contrib.auth.decorators import login_required
|
||||
import ast
|
||||
from .dashboard import get_dashboard_data
|
||||
from django.template.loader import render_to_string
|
||||
# from .dashboard import get_dashboard_data
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.views.generic import ListView, CreateView, UpdateView, DeleteView, DetailView
|
||||
# JobForm removed - using JobPostingForm instead
|
||||
from django.urls import reverse_lazy
|
||||
from django.db.models import Q
|
||||
|
||||
def job_list(request):
|
||||
jobs = models.Job.objects.filter(is_published=True).order_by('-created_at')
|
||||
lang = get_language()
|
||||
return render(request, 'recruitment/job_list.html', {'jobs': jobs, 'lang': lang})
|
||||
from datastar_py.django import (
|
||||
DatastarResponse,
|
||||
ServerSentEventGenerator as SSE,
|
||||
read_signals,
|
||||
)
|
||||
|
||||
def job_detail(request, job_id):
|
||||
job = get_object_or_404(models.Job, id=job_id, is_published=True)
|
||||
class JobListView(LoginRequiredMixin, ListView):
|
||||
model = models.JobPosting
|
||||
template_name = 'jobs/job_list.html'
|
||||
context_object_name = 'jobs'
|
||||
paginate_by = 10
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset().order_by('-created_at')
|
||||
|
||||
# Handle search
|
||||
search_query = self.request.GET.get('search', '')
|
||||
if search_query:
|
||||
queryset = queryset.filter(
|
||||
Q(title__icontains=search_query) |
|
||||
Q(description__icontains=search_query) |
|
||||
Q(department__icontains=search_query)
|
||||
)
|
||||
|
||||
# Filter for non-staff users
|
||||
if not self.request.user.is_staff:
|
||||
queryset = queryset.filter(status='Published')
|
||||
|
||||
return queryset
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['search_query'] = self.request.GET.get('search', '')
|
||||
context['lang'] = get_language()
|
||||
return context
|
||||
|
||||
|
||||
class JobCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView):
|
||||
model = models.JobPosting
|
||||
form_class = forms.JobPostingForm
|
||||
template_name = 'jobs/create_job.html'
|
||||
success_url = reverse_lazy('job_list')
|
||||
success_message = 'Job created successfully.'
|
||||
|
||||
|
||||
class JobUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView):
|
||||
model = models.JobPosting
|
||||
form_class = forms.JobPostingForm
|
||||
template_name = 'jobs/edit_job.html'
|
||||
success_url = reverse_lazy('job_list')
|
||||
success_message = 'Job updated successfully.'
|
||||
slug_url_kwarg = 'slug'
|
||||
|
||||
|
||||
class JobDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView):
|
||||
model = models.JobPosting
|
||||
template_name = 'jobs/partials/delete_modal.html'
|
||||
success_url = reverse_lazy('job_list')
|
||||
success_message = 'Job deleted successfully.'
|
||||
slug_url_kwarg = 'slug'
|
||||
|
||||
class JobCandidatesListView(LoginRequiredMixin, ListView):
|
||||
model = models.Candidate
|
||||
template_name = 'jobs/job_candidates_list.html'
|
||||
context_object_name = 'candidates'
|
||||
paginate_by = 10
|
||||
|
||||
def get_queryset(self):
|
||||
# Get the job by slug
|
||||
self.job = get_object_or_404(models.JobPosting, slug=self.kwargs['slug'])
|
||||
|
||||
# Filter candidates for this specific job
|
||||
queryset = models.Candidate.objects.filter(job=self.job)
|
||||
|
||||
# Handle search
|
||||
search_query = self.request.GET.get('search', '')
|
||||
if search_query:
|
||||
queryset = queryset.filter(
|
||||
Q(first_name__icontains=search_query) |
|
||||
Q(last_name__icontains=search_query) |
|
||||
Q(email__icontains=search_query) |
|
||||
Q(phone__icontains=search_query) |
|
||||
Q(stage__icontains=search_query)
|
||||
)
|
||||
|
||||
# Filter for non-staff users
|
||||
if not self.request.user.is_staff:
|
||||
return models.Candidate.objects.none() # Restrict for non-staff
|
||||
|
||||
return queryset.order_by('-created_at')
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['search_query'] = self.request.GET.get('search', '')
|
||||
context['job'] = getattr(self, 'job', None)
|
||||
return context
|
||||
|
||||
|
||||
class CandidateListView(LoginRequiredMixin, ListView):
|
||||
model = models.Candidate
|
||||
template_name = 'recruitment/candidate_list.html'
|
||||
context_object_name = 'candidates'
|
||||
paginate_by = 10
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
|
||||
# Handle search
|
||||
search_query = self.request.GET.get('search', '')
|
||||
if search_query:
|
||||
queryset = queryset.filter(
|
||||
Q(first_name__icontains=search_query) |
|
||||
Q(last_name__icontains=search_query) |
|
||||
Q(email__icontains=search_query) |
|
||||
Q(phone__icontains=search_query) |
|
||||
Q(stage__icontains=search_query) |
|
||||
Q(job__title__icontains=search_query)
|
||||
)
|
||||
|
||||
# Filter for non-staff users
|
||||
if not self.request.user.is_staff:
|
||||
return models.Candidate.objects.none() # Restrict for non-staff
|
||||
|
||||
return queryset.order_by('-created_at')
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['search_query'] = self.request.GET.get('search', '')
|
||||
return context
|
||||
|
||||
|
||||
class CandidateCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView):
|
||||
model = models.Candidate
|
||||
form_class = forms.CandidateForm
|
||||
template_name = 'recruitment/candidate_create.html'
|
||||
success_url = reverse_lazy('candidate_list')
|
||||
success_message = 'Candidate created successfully.'
|
||||
|
||||
def get_initial(self):
|
||||
initial = super().get_initial()
|
||||
if 'slug' in self.kwargs:
|
||||
job = get_object_or_404(models.JobPosting, slug=self.kwargs['slug'])
|
||||
initial['job'] = job
|
||||
return initial
|
||||
|
||||
def form_valid(self, form):
|
||||
if 'slug' in self.kwargs:
|
||||
job = get_object_or_404(models.JobPosting, slug=self.kwargs['slug'])
|
||||
form.instance.job = job
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
class CandidateUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView):
|
||||
model = models.Candidate
|
||||
form_class = forms.CandidateForm
|
||||
template_name = 'recruitment/candidate_update.html'
|
||||
success_url = reverse_lazy('candidate_list')
|
||||
success_message = 'Candidate updated successfully.'
|
||||
slug_url_kwarg = 'slug'
|
||||
|
||||
|
||||
class CandidateDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView):
|
||||
model = models.Candidate
|
||||
template_name = 'recruitment/candidate_delete.html'
|
||||
success_url = reverse_lazy('candidate_list')
|
||||
success_message = 'Candidate deleted successfully.'
|
||||
slug_url_kwarg = 'slug'
|
||||
|
||||
|
||||
def job_detail(request, slug):
|
||||
job = get_object_or_404(models.JobPosting, slug=slug, status='Published')
|
||||
form = forms.CandidateForm()
|
||||
return render(request, 'recruitment/job_detail.html', {'job': job, 'form': form})
|
||||
return render(request, 'jobs/job_detail.html', {'job': job, 'form': form})
|
||||
|
||||
|
||||
@login_required
|
||||
@ -23,22 +195,163 @@ def training_list(request):
|
||||
return render(request, 'recruitment/training_list.html', {'materials': materials})
|
||||
|
||||
|
||||
def candidate_detail(request, candidate_id):
|
||||
candidate = get_object_or_404(models.Candidate, id=candidate_id)
|
||||
def candidate_detail(request, slug):
|
||||
candidate = get_object_or_404(models.Candidate, slug=slug)
|
||||
try:
|
||||
parsed = ast.literal_eval(candidate.parsed_summary)
|
||||
except:
|
||||
parsed = {}
|
||||
|
||||
# Create stage update form for staff users
|
||||
stage_form = None
|
||||
if request.user.is_staff:
|
||||
stage_form = forms.CandidateStageForm(candidate=candidate)
|
||||
|
||||
return render(request, 'recruitment/candidate_detail.html', {
|
||||
'candidate': candidate,
|
||||
'parsed': parsed,
|
||||
'stage_form': stage_form,
|
||||
})
|
||||
|
||||
def candidate_update_stage(request, slug):
|
||||
"""Handle HTMX stage update requests"""
|
||||
from time import sleep
|
||||
sleep(5)
|
||||
try:
|
||||
if not request.user.is_staff:
|
||||
return render(request, 'recruitment/partials/error.html', {'error': 'Permission denied'}, status=403)
|
||||
|
||||
candidate = get_object_or_404(models.Candidate, slug=slug)
|
||||
|
||||
if request.method != 'POST':
|
||||
return render(request, 'recruitment/partials/error.html', {'error': 'Only POST method is allowed'}, status=405)
|
||||
|
||||
# Handle form data
|
||||
form = forms.CandidateStageForm(request.POST, candidate=candidate)
|
||||
if form.is_valid():
|
||||
stage_value = form.cleaned_data['stage']
|
||||
|
||||
# Validate stage value
|
||||
valid_stages = [choice[0] for choice in models.Candidate.Stage.choices]
|
||||
if stage_value not in valid_stages:
|
||||
return render(request, 'recruitment/partials/error.html', {'error': f'Invalid stage value. Must be one of: {", ".join(valid_stages)}'}, status=400)
|
||||
|
||||
# Check transition rules
|
||||
if candidate.pk and stage_value != candidate.stage:
|
||||
old_stage = candidate.stage
|
||||
if not candidate.can_transition_to(stage_value):
|
||||
return render(request, 'recruitment/partials/error.html', {'error': f'Cannot transition from "{old_stage}" to "{stage_value}". Transition not allowed.'}, status=400)
|
||||
|
||||
# Update the stage
|
||||
old_stage = candidate.stage
|
||||
candidate.stage = stage_value
|
||||
candidate.save()
|
||||
|
||||
# Return success template
|
||||
context = {
|
||||
'form': form,
|
||||
'success': True,
|
||||
'message': f'Stage updated from "{old_stage}" to "{candidate.stage}"',
|
||||
'new_stage': candidate.stage,
|
||||
'new_stage_display': candidate.get_stage_display(),
|
||||
'candidate': candidate
|
||||
}
|
||||
def response():
|
||||
stage_form = forms.CandidateStageForm(candidate=candidate)
|
||||
context['stage_form'] = stage_form
|
||||
stage_form_partial = render_to_string('recruitment/partials/stage_update_modal.html#id-stage', context)
|
||||
success_html = render_to_string('recruitment/partials/stage_update_success.html', context)
|
||||
yield SSE.patch_elements(stage_form_partial,"#id_stage")
|
||||
yield SSE.patch_elements(success_html,"#availableStagesInfo")
|
||||
yield SSE.patch_signals({'stage':candidate.stage})
|
||||
|
||||
return DatastarResponse(response())
|
||||
# return render(request, 'recruitment/partials/stage_update_success.html', context)
|
||||
else:
|
||||
# Return form with errors
|
||||
context = {
|
||||
'form': form,
|
||||
'candidate': candidate,
|
||||
'stage_form': forms.CandidateStageForm(candidate=candidate)
|
||||
}
|
||||
return render(request, 'recruitment/partials/stage_update_form.html', context)
|
||||
|
||||
except Exception as e:
|
||||
# Log the error for debugging
|
||||
import traceback
|
||||
error_details = traceback.format_exc()
|
||||
print(f"Error in candidate_update_stage: {error_details}")
|
||||
|
||||
return render(request, 'partials/error.html', {'error': f'Internal server error: {str(e)}'}, status=500)
|
||||
|
||||
|
||||
class TrainingListView(LoginRequiredMixin, ListView):
|
||||
model = models.TrainingMaterial
|
||||
template_name = 'recruitment/training_list.html'
|
||||
context_object_name = 'materials'
|
||||
paginate_by = 10
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
|
||||
# Handle search
|
||||
search_query = self.request.GET.get('search', '')
|
||||
if search_query:
|
||||
queryset = queryset.filter(
|
||||
Q(title__icontains=search_query) |
|
||||
Q(description__icontains=search_query)
|
||||
)
|
||||
|
||||
# Filter for non-staff users
|
||||
if not self.request.user.is_staff:
|
||||
return models.TrainingMaterial.objects.none() # Restrict for non-staff
|
||||
|
||||
return queryset.filter(created_by=self.request.user).order_by('-created_at')
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['search_query'] = self.request.GET.get('search', '')
|
||||
return context
|
||||
|
||||
|
||||
class TrainingCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView):
|
||||
model = models.TrainingMaterial
|
||||
form_class = forms.TrainingMaterialForm
|
||||
template_name = 'recruitment/training_create.html'
|
||||
success_url = reverse_lazy('training_list')
|
||||
success_message = 'Training material created successfully.'
|
||||
|
||||
def form_valid(self, form):
|
||||
form.instance.created_by = self.request.user
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
class TrainingUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView):
|
||||
model = models.TrainingMaterial
|
||||
form_class = forms.TrainingMaterialForm
|
||||
template_name = 'recruitment/training_update.html'
|
||||
success_url = reverse_lazy('training_list')
|
||||
success_message = 'Training material updated successfully.'
|
||||
slug_url_kwarg = 'slug'
|
||||
|
||||
|
||||
class TrainingDetailView(LoginRequiredMixin, DetailView):
|
||||
model = models.TrainingMaterial
|
||||
template_name = 'recruitment/training_detail.html'
|
||||
context_object_name = 'material'
|
||||
slug_url_kwarg = 'slug'
|
||||
|
||||
class TrainingDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView):
|
||||
model = models.TrainingMaterial
|
||||
template_name = 'recruitment/training_delete.html'
|
||||
success_url = reverse_lazy('training_list')
|
||||
success_message = 'Training material deleted successfully.'
|
||||
|
||||
|
||||
def dashboard_view(request):
|
||||
total_jobs = models.Job.objects.count()
|
||||
total_jobs = models.JobPosting.objects.count()
|
||||
total_candidates = models.Candidate.objects.count()
|
||||
jobs = models.Job.objects.all()
|
||||
jobs = models.JobPosting.objects.all()
|
||||
|
||||
job_titles = [job.title for job in jobs]
|
||||
job_app_counts = [job.candidates.count() for job in jobs]
|
||||
|
||||
40
run.py
Normal file
40
run.py
Normal file
@ -0,0 +1,40 @@
|
||||
import requests
|
||||
import jwt
|
||||
import time
|
||||
|
||||
ZOOM_API_KEY = 'OoDRW3uVTymEnnQWTZTsLQ'
|
||||
ZOOM_API_SECRET = 'ZJ0hCFMrwekG71jbR3Trvoor4tK3HAVP'
|
||||
|
||||
def generate_zoom_jwt():
|
||||
payload = {
|
||||
'iss': ZOOM_API_KEY,
|
||||
'exp': time.time() + 3600
|
||||
}
|
||||
token = jwt.encode(payload, ZOOM_API_SECRET, algorithm='HS256')
|
||||
return token
|
||||
|
||||
def create_zoom_meeting(topic, start_time, duration, host_email):
|
||||
jwt_token = generate_zoom_jwt()
|
||||
headers = {
|
||||
'Authorization': f'Bearer {jwt_token}',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
data = {
|
||||
"topic": topic,
|
||||
"type": 2,
|
||||
"start_time": start_time,
|
||||
"duration": duration,
|
||||
"schedule_for": 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)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
topic = "Your Meeting Topic"
|
||||
start_time = "2023-08-25T10:00:00"
|
||||
duration = 60
|
||||
host_email = "your_zoom_email"
|
||||
response = create_zoom_meeting(topic, start_time, duration, host_email)
|
||||
print(response.json())
|
||||
947
static/css/style.css
Normal file
947
static/css/style.css
Normal file
@ -0,0 +1,947 @@
|
||||
|
||||
/* Custom CSS for NorahUniversity ATS */
|
||||
/* Keep only essential custom styles that Bootstrap doesn't handle */
|
||||
|
||||
/* Primary Brand Color */
|
||||
:root {
|
||||
--primary-color: #1b8354;
|
||||
--primary-hover: #155f3e;
|
||||
}
|
||||
|
||||
/* Header and Navigation */
|
||||
.header {
|
||||
background-color: white !important;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1) !important;
|
||||
border-bottom: 1px solid #e0e0e0 !important;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
background-color: white !important;
|
||||
border-bottom: 1px solid #e0e0e0 !important;
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
background-color: var(--primary-color) !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background-color: var(--primary-color) !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color) !important;
|
||||
}
|
||||
|
||||
/* Buttons - Override Bootstrap primary color */
|
||||
.btn-primary {
|
||||
background-color: var(--primary-color) !important;
|
||||
border-color: var(--primary-color) !important;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--primary-hover) !important;
|
||||
border-color: var(--primary-hover) !important;
|
||||
}
|
||||
|
||||
.btn-outline-primary {
|
||||
border-color: var(--primary-color) !important;
|
||||
color: var(--primary-color) !important;
|
||||
}
|
||||
|
||||
.btn-outline-primary:hover {
|
||||
background-color: var(--primary-color) !important;
|
||||
border-color: var(--primary-color) !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
border: 1px solid #e0e0e0 !important;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05) !important;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
border-bottom: 1px solid #e0e0e0 !important;
|
||||
background-color: white !important;
|
||||
}
|
||||
|
||||
/* Table Improvements */
|
||||
.table-hover tbody tr:hover {
|
||||
background-color: rgba(27, 131, 84, 0.05) !important;
|
||||
}
|
||||
|
||||
/* Custom Badge Colors */
|
||||
.badge.bg-success {
|
||||
background-color: #28a745 !important;
|
||||
}
|
||||
|
||||
.badge.bg-warning {
|
||||
background-color: #ffc107 !important;
|
||||
}
|
||||
|
||||
/* Form Improvements */
|
||||
.form-control:focus {
|
||||
border-color: var(--primary-color) !important;
|
||||
box-shadow: 0 0 0 0.2rem rgba(27, 131, 84, 0.25) !important;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.nav-list {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Utility classes */
|
||||
.text-primary-custom {
|
||||
color: var(--primary-color) !important;
|
||||
}
|
||||
|
||||
.bg-primary-custom {
|
||||
background-color: var(--primary-color) !important;
|
||||
}
|
||||
|
||||
.border-primary-custom {
|
||||
border-color: var(--primary-color) !important;
|
||||
}
|
||||
|
||||
/* Loading states */
|
||||
.loading {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Icon Styling */
|
||||
.heroicon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin-right: 0.5rem;
|
||||
vertical-align: middle;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.size-6 {
|
||||
width: 1.5rem !important;
|
||||
height: 1.5rem !important;
|
||||
}
|
||||
|
||||
/* Responsive icon sizing */
|
||||
.icon-sm {
|
||||
width: 0.875rem !important;
|
||||
height: 0.875rem !important;
|
||||
margin-right: 0.375rem !important;
|
||||
}
|
||||
|
||||
.icon-md {
|
||||
width: 1.125rem !important;
|
||||
height: 1.125rem !important;
|
||||
margin-right: 0.625rem !important;
|
||||
}
|
||||
|
||||
.icon-lg {
|
||||
width: 1.5rem !important;
|
||||
height: 1.5rem !important;
|
||||
margin-right: 0.75rem !important;
|
||||
}
|
||||
|
||||
.icon-xl {
|
||||
width: 2rem !important;
|
||||
height: 2rem !important;
|
||||
margin-right: 1rem !important;
|
||||
}
|
||||
|
||||
/* Context-specific icon adjustments */
|
||||
.btn-sm .heroicon,
|
||||
.btn-sm .size-6,
|
||||
.btn-sm .icon-md {
|
||||
width: 0.875rem !important;
|
||||
height: 0.875rem !important;
|
||||
margin-right: 0.375rem !important;
|
||||
}
|
||||
|
||||
.nav-link .heroicon,
|
||||
.nav-link .size-6 {
|
||||
width: 1.25rem !important;
|
||||
height: 1.25rem !important;
|
||||
margin-right: 0.5rem !important;
|
||||
}
|
||||
|
||||
.card-header .heroicon,
|
||||
.card-header .size-6 {
|
||||
width: 1.375rem !important;
|
||||
height: 1.375rem !important;
|
||||
margin-right: 0.625rem !important;
|
||||
}
|
||||
|
||||
/* Print styles */
|
||||
@media print {
|
||||
.navbar,
|
||||
.header,
|
||||
.btn,
|
||||
.pagination {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.card {
|
||||
box-shadow: none !important;
|
||||
border: 1px solid #ccc !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive adjustments for icons */
|
||||
@media (max-width: 768px) {
|
||||
.nav-link .heroicon,
|
||||
.nav-link .size-6 {
|
||||
width: 1rem !important;
|
||||
height: 1rem !important;
|
||||
margin-right: 0.375rem !important;
|
||||
}
|
||||
|
||||
.card-header .heroicon,
|
||||
.card-header .size-6 {
|
||||
width: 1.125rem !important;
|
||||
height: 1.125rem !important;
|
||||
margin-right: 0.5rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Header and Search Enhancements */
|
||||
.card-header {
|
||||
background-color: white !important;
|
||||
border-bottom: 1px solid #e0e0e0 !important;
|
||||
padding: 1.25rem 1.5rem !important;
|
||||
}
|
||||
|
||||
.card-header h1,
|
||||
.card-header h2,
|
||||
.card-header h3 {
|
||||
margin-bottom: 0 !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
.card-header h1.h3,
|
||||
.card-header h2.h3,
|
||||
.card-header h3.h3 {
|
||||
font-size: 1.25rem !important;
|
||||
}
|
||||
|
||||
/* Search Form Enhancements */
|
||||
.search-form-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
flex: 1;
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
.input-group-text {
|
||||
background-color: #f8f9fa !important;
|
||||
border: 1px solid #ced4da !important;
|
||||
/* border-right: none !important; */
|
||||
color: #495057 !important;
|
||||
transition: all 0.2s ease !important;
|
||||
}
|
||||
|
||||
.input-group-text:hover {
|
||||
background-color: #e9ecef !important;
|
||||
}
|
||||
|
||||
.input-group-text .heroicon {
|
||||
width: 1rem !important;
|
||||
height: 1rem !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
|
||||
.form-control:focus {
|
||||
border-color: var(--primary-color) !important;
|
||||
box-shadow: 0 0 0 0.2rem rgba(27, 131, 84, 0.15) !important;
|
||||
}
|
||||
|
||||
.form-control:focus + .input-group-text {
|
||||
border-color: var(--primary-color) !important;
|
||||
background-color: rgba(27, 131, 84, 0.05) !important;
|
||||
}
|
||||
|
||||
/* Button Group Enhancements */
|
||||
.d-flex.gap-2 .btn {
|
||||
white-space: nowrap !important;
|
||||
transition: all 0.2s ease !important;
|
||||
}
|
||||
|
||||
.d-flex.gap-2 .btn:hover {
|
||||
transform: translateY(-1px) !important;
|
||||
}
|
||||
|
||||
.d-flex.gap-2 .btn svg {
|
||||
margin-right: 0.375rem !important;
|
||||
}
|
||||
|
||||
/* Responsive Header Adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.card-header {
|
||||
padding: 1rem 1.25rem !important;
|
||||
}
|
||||
|
||||
.card-header h1.h3,
|
||||
.card-header h2.h3,
|
||||
.card-header h3.h3 {
|
||||
font-size: 1.125rem !important;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
min-width: 200px !important;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
.d-flex.gap-2 {
|
||||
flex-wrap: wrap !important;
|
||||
gap: 0.5rem !important;
|
||||
}
|
||||
|
||||
.d-flex.gap-2 .btn {
|
||||
flex: 1 !important;
|
||||
min-width: 120px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.card-header {
|
||||
padding: 0.875rem 1rem !important;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
min-width: 100% !important;
|
||||
}
|
||||
|
||||
.d-flex.gap-2 .btn {
|
||||
font-size: 0.875rem !important;
|
||||
padding: 0.375rem 0.75rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Search Input Placeholder */
|
||||
.form-control::placeholder {
|
||||
color: #6c757d !important;
|
||||
opacity: 0.7 !important;
|
||||
}
|
||||
|
||||
/* Enhanced Focus States */
|
||||
.form-control:focus::placeholder {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* Detail Page Enhancements */
|
||||
.detail-page-header {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, rgba(27, 131, 84, 0.1) 100%);
|
||||
border-bottom: 3px solid var(--primary-color) !important;
|
||||
}
|
||||
|
||||
.detail-page-header h1 {
|
||||
color: var(--primary-color) !important;
|
||||
}
|
||||
|
||||
/* Information Cards Enhancement */
|
||||
.info-card {
|
||||
background: #f8f9fa;
|
||||
border-left: 4px solid var(--primary-color);
|
||||
border-radius: 0.375rem;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.info-card:hover {
|
||||
background: #e9ecef;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.info-card .info-label {
|
||||
font-size: 0.875rem;
|
||||
color: #6c757d;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.info-card .info-value {
|
||||
font-size: 1rem;
|
||||
color: #212529;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Action Cards Enhancement */
|
||||
.action-card {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||
border-radius: 0.75rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.action-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Resume File Display */
|
||||
.resume-file {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.resume-file:hover {
|
||||
background: #e9ecef;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.resume-file .file-name {
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.resume-file .file-info {
|
||||
font-size: 0.875rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
/* Parsed Data Grid Enhancement */
|
||||
.parsed-data-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.parsed-data-item {
|
||||
background: white;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.parsed-data-item:hover {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 2px 8px rgba(27, 131, 84, 0.1);
|
||||
}
|
||||
|
||||
.parsed-data-item .data-key {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
color: var(--primary-color);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.parsed-data-item .data-value {
|
||||
font-size: 0.875rem;
|
||||
color: #495057;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Status Badge Enhancement */
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 2rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.status-badge .heroicon {
|
||||
width: 1rem !important;
|
||||
height: 1rem !important;
|
||||
}
|
||||
|
||||
/* Contact Information Enhancement */
|
||||
.contact-info-item {
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
background: white;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #e0e0e0;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.contact-info-item:hover {
|
||||
background: #f8f9fa;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 2px 8px rgba(27, 131, 84, 0.1);
|
||||
}
|
||||
|
||||
.contact-info-item .contact-label {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
color: #6c757d;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.contact-info-item .contact-value {
|
||||
font-size: 1rem;
|
||||
color: #212529;
|
||||
font-weight: 500;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
/* Responsive Detail Page Adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.detail-page-header {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, rgba(27, 131, 84, 0.05) 100%);
|
||||
border-bottom: 2px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.detail-page-header h1 {
|
||||
font-size: 1.5rem !important;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.parsed-data-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.contact-info-item {
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.action-card {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.detail-page-header h1 {
|
||||
font-size: 1.25rem !important;
|
||||
}
|
||||
|
||||
.info-card .info-label {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.info-card .info-value {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading Animation for Detail Pages */
|
||||
.detail-loading {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: white;
|
||||
animation: spin 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Print Styles for Detail Pages */
|
||||
@media print {
|
||||
.detail-page-header {
|
||||
background: white !important;
|
||||
border: 2px solid #dee2e6 !important;
|
||||
}
|
||||
|
||||
.detail-page-header h1 {
|
||||
color: #212529 !important;
|
||||
}
|
||||
|
||||
.contact-info-item,
|
||||
.info-card,
|
||||
.parsed-data-item {
|
||||
border: 1px solid #dee2e6 !important;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.btn,
|
||||
.action-card {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Form and Update Page Enhancements */
|
||||
.form-page-header {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, rgba(27, 131, 84, 0.1) 100%);
|
||||
border-bottom: 3px solid var(--primary-color) !important;
|
||||
}
|
||||
|
||||
.form-page-header h1 {
|
||||
color: var(--primary-color) !important;
|
||||
}
|
||||
|
||||
.form-page-header p {
|
||||
color: rgba(27, 131, 84, 0.8) !important;
|
||||
}
|
||||
|
||||
/* Form Section Enhancement */
|
||||
.form-section {
|
||||
background: #f8f9fa;
|
||||
border-left: 4px solid var(--primary-color);
|
||||
border-radius: 0.375rem;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.form-section:hover {
|
||||
background: #e9ecef;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.form-section h5 {
|
||||
color: var(--primary-color);
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.form-section .section-icon {
|
||||
width: 1.25rem !important;
|
||||
height: 1.25rem !important;
|
||||
}
|
||||
|
||||
/* Form Field Enhancement */
|
||||
.form-field-wrapper {
|
||||
position: relative;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-field-wrapper label {
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
margin-bottom: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.form-field-wrapper .required-indicator {
|
||||
color: #dc3545;
|
||||
font-size: 0.875rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.form-field-wrapper .field-icon {
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
top: 2.5rem;
|
||||
color: #6c757d;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.form-control,
|
||||
.form-select {
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 1rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.form-control:focus,
|
||||
.form-select:focus {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 0.2rem rgba(27, 131, 84, 0.15);
|
||||
color: #212529;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.form-control.is-invalid,
|
||||
.form-select.is-invalid {
|
||||
border-color: #dc3545;
|
||||
padding-right: 2.5rem;
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
.form-control.is-valid,
|
||||
.form-select.is-valid {
|
||||
border-color: #28a745;
|
||||
padding-right: 2.5rem;
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
.form-text {
|
||||
font-size: 0.875rem;
|
||||
color: #6c757d;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.invalid-feedback,
|
||||
.valid-feedback {
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.invalid-feedback {
|
||||
color: #dc3545;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.valid-feedback {
|
||||
color: #28a745;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Form Enhancement for Special Fields */
|
||||
.form-check-input {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.form-check-input:checked {
|
||||
background-color: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.form-check-label {
|
||||
font-weight: 500;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
/* Action Buttons Enhancement */
|
||||
.form-action-buttons {
|
||||
background: white;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
margin-top: 2rem;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.form-action-buttons .btn {
|
||||
min-width: 120px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.form-action-buttons .btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* Responsive Form Adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.form-section {
|
||||
padding: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-section h5 {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.form-control,
|
||||
.form-select {
|
||||
font-size: 0.875rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
}
|
||||
|
||||
.form-field-wrapper label {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.form-text,
|
||||
.invalid-feedback,
|
||||
.valid-feedback {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.form-action-buttons {
|
||||
padding: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.form-action-buttons .btn {
|
||||
min-width: 100px;
|
||||
font-size: 0.875rem;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.form-section {
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-section h5 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.form-control,
|
||||
.form-select {
|
||||
font-size: 0.813rem;
|
||||
padding: 0.5rem 0.625rem;
|
||||
}
|
||||
|
||||
.form-field-wrapper {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-action-buttons {
|
||||
flex-direction: column !important;
|
||||
gap: 0.5rem !important;
|
||||
}
|
||||
|
||||
.form-action-buttons .btn {
|
||||
min-width: auto;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading State for Forms */
|
||||
.form-loading {
|
||||
position: relative;
|
||||
pointer-events: none;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.form-loading::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.form-loading .spinner {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* Print Styles for Forms */
|
||||
@media print {
|
||||
.form-page-header,
|
||||
.form-section,
|
||||
.form-action-buttons {
|
||||
border: 1px solid #dee2e6 !important;
|
||||
background: white !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.form-control,
|
||||
.form-select {
|
||||
border: 1px solid #000 !important;
|
||||
background: white !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* File Upload Enhancement */
|
||||
.form-control[type="file"] {
|
||||
padding: 0.5rem;
|
||||
border: 2px dashed #dee2e6;
|
||||
background: #f8f9fa;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.form-control[type="file"]:hover {
|
||||
border-color: var(--primary-color);
|
||||
background: #f0f8f4;
|
||||
}
|
||||
|
||||
.form-control[type="file"]:focus {
|
||||
border-color: var(--primary-color);
|
||||
background: white;
|
||||
box-shadow: 0 0 0 0.2rem rgba(27, 131, 84, 0.15);
|
||||
}
|
||||
|
||||
/* Checkbox and Radio Enhancement */
|
||||
.form-check-input:checked ~ .form-check-label::before {
|
||||
background-color: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.form-check-input:focus ~ .form-check-label::before {
|
||||
box-shadow: 0 0 0 0.2rem rgba(27, 131, 84, 0.25);
|
||||
}
|
||||
|
||||
/* Help Text Enhancement */
|
||||
.help-text {
|
||||
font-size: 0.813rem;
|
||||
color: #6c757d;
|
||||
margin-top: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.help-text .help-icon {
|
||||
width: 1rem !important;
|
||||
height: 1rem !important;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
/* Error State Enhancement */
|
||||
.field-error {
|
||||
border-color: #dc3545 !important;
|
||||
background-color: #fff5f5 !important;
|
||||
}
|
||||
|
||||
.field-error:focus {
|
||||
box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.25) !important;
|
||||
}
|
||||
|
||||
/* Success State Enhancement */
|
||||
.field-success {
|
||||
border-color: #28a745 !important;
|
||||
background-color: #f8fff9 !important;
|
||||
}
|
||||
|
||||
.field-success:focus {
|
||||
box-shadow: 0 0 0 0.2rem rgba(40, 167, 69, 0.25) !important;
|
||||
}
|
||||
126
static/js/modal_handlers.js
Normal file
126
static/js/modal_handlers.js
Normal file
@ -0,0 +1,126 @@
|
||||
// Modal Handlers for Bootstrap
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Handle delete button clicks
|
||||
const deleteButtons = document.querySelectorAll('[data-bs-toggle="deleteModal"]');
|
||||
|
||||
deleteButtons.forEach(button => {
|
||||
button.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const targetModal = this.getAttribute('data-bs-target');
|
||||
const modal = new bootstrap.Modal(document.getElementById('deleteModal'));
|
||||
|
||||
// Set up the delete form and message
|
||||
const deleteUrl = this.getAttribute('data-delete-url');
|
||||
const itemName = this.getAttribute('data-item-name') || 'this item';
|
||||
|
||||
const deleteForm = document.getElementById('deleteForm');
|
||||
deleteForm.setAttribute('action', deleteUrl);
|
||||
|
||||
const modalMessage = document.getElementById('deleteModalMessage');
|
||||
modalMessage.textContent = `Are you sure you want to delete "${itemName}"? This action cannot be undone.`;
|
||||
|
||||
modal.show();
|
||||
});
|
||||
});
|
||||
|
||||
// Handle update button clicks for inline editing
|
||||
const updateButtons = document.querySelectorAll('[data-bs-toggle="updateModal"]');
|
||||
|
||||
updateButtons.forEach(button => {
|
||||
button.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const targetModal = this.getAttribute('data-bs-target');
|
||||
const modal = new bootstrap.Modal(document.getElementById('updateModal'));
|
||||
|
||||
// Load the form content via AJAX
|
||||
const updateUrl = this.getAttribute('data-update-url');
|
||||
|
||||
fetch(updateUrl)
|
||||
.then(response => response.text())
|
||||
.then(html => {
|
||||
const modalBody = document.querySelector('#updateModal .modal-body');
|
||||
modalBody.innerHTML = html;
|
||||
|
||||
// Re-initialize any form validation or JavaScript within the modal
|
||||
initializeModalForms();
|
||||
|
||||
modal.show();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading form:', error);
|
||||
alert('Error loading the form. Please try again.');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize forms within modals
|
||||
function initializeModalForms() {
|
||||
// Handle form submissions within modals
|
||||
const modalForms = document.querySelectorAll('#updateModal form');
|
||||
|
||||
modalForms.forEach(form => {
|
||||
form.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(this);
|
||||
const submitUrl = this.getAttribute('action');
|
||||
|
||||
fetch(submitUrl, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-CSRFToken': formData.get('csrfmiddlewaretoken')
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
// Close the modal and refresh the page or update the content
|
||||
bootstrap.Modal.getInstance(document.getElementById('updateModal')).hide();
|
||||
|
||||
// Reload the page or update the specific element
|
||||
if (data.reload) {
|
||||
window.location.reload();
|
||||
} else if (data.update_element) {
|
||||
// Update specific element if needed
|
||||
console.log('Element updated:', data.update_element);
|
||||
}
|
||||
} else {
|
||||
// Show errors in the form
|
||||
displayFormErrors(form, data.errors);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error submitting form:', error);
|
||||
alert('Error submitting the form. Please try again.');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Display form errors
|
||||
function displayFormErrors(form, errors) {
|
||||
// Remove existing error messages
|
||||
const existingErrors = form.querySelectorAll('.alert-danger');
|
||||
existingErrors.forEach(error => error.remove());
|
||||
|
||||
// Add error messages
|
||||
if (errors && Object.keys(errors).length > 0) {
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'alert alert-danger';
|
||||
errorDiv.innerHTML = '<strong>Please correct the following errors:</strong><ul>';
|
||||
|
||||
for (const [field, messages] of Object.entries(errors)) {
|
||||
messages.forEach(message => {
|
||||
errorDiv.innerHTML += `<li>${field}: ${message}</li>`;
|
||||
});
|
||||
}
|
||||
|
||||
errorDiv.innerHTML += '</ul>';
|
||||
form.insertBefore(errorDiv, form.firstChild);
|
||||
}
|
||||
}
|
||||
BIN
static/media/resumes/Abdullah_Bakhsh_-_2025_EJixhWY.pdf
Normal file
BIN
static/media/resumes/Abdullah_Bakhsh_-_2025_EJixhWY.pdf
Normal file
Binary file not shown.
BIN
static/media/resumes/Abdullah_Bakhsh_-_2025_jeZedk0.pdf
Normal file
BIN
static/media/resumes/Abdullah_Bakhsh_-_2025_jeZedk0.pdf
Normal file
Binary file not shown.
BIN
static/media/resumes/Abdullah_Bakhsh_-_2025_r6sreTY.pdf
Normal file
BIN
static/media/resumes/Abdullah_Bakhsh_-_2025_r6sreTY.pdf
Normal file
Binary file not shown.
BIN
static/media/resumes/Certificate.pdf
Normal file
BIN
static/media/resumes/Certificate.pdf
Normal file
Binary file not shown.
BIN
static/media/resumes/Certificate_MwMpoYu.pdf
Normal file
BIN
static/media/resumes/Certificate_MwMpoYu.pdf
Normal file
Binary file not shown.
3
static/media/resumes/cars_sample_5.csv
Normal file
3
static/media/resumes/cars_sample_5.csv
Normal file
@ -0,0 +1,3 @@
|
||||
vin,
|
||||
5FNRL38739B001353,
|
||||
5FNRL38739B001354
|
||||
|
500 Internal Server Error
Gitea Version: 1.23.5 |