update1
This commit is contained in:
parent
1e04b5736d
commit
1aa8b6800a
Binary file not shown.
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
|
||||
@ -188,4 +210,10 @@ UNFOLD = {
|
||||
ZOOM_ACCOUNT_ID = 'HoGikHXsQB2GNDC5Rvyw9A'
|
||||
ZOOM_CLIENT_ID = 'brC39920R8C8azfudUaQgA'
|
||||
ZOOM_CLIENT_SECRET = 'rvfhjlbID4ychXPOvZ2lYsoAC0B0Ny2L'
|
||||
SECRET_TOKEN = '6KdTGyF0SSCSL_V4Xa34aw'
|
||||
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 = [
|
||||
@ -33,4 +33,4 @@ urlpatterns = [
|
||||
path('', include('recruitment.urls')),
|
||||
]
|
||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
||||
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
||||
|
||||
BIN
db.sqlite3
BIN
db.sqlite3
Binary file not shown.
Binary file not shown.
Binary file not shown.
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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.
Binary file not shown.
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,15 +1,368 @@
|
||||
from django import forms
|
||||
from .models import ZoomMeeting, Candidate
|
||||
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 = Candidate
|
||||
fields = ['name', 'email', 'resume']
|
||||
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 = {
|
||||
'start_time': forms.DateTimeInput(attrs={'type': 'datetime-local'}),
|
||||
}
|
||||
'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
|
||||
# }
|
||||
@ -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')),
|
||||
],
|
||||
),
|
||||
]
|
||||
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.
@ -1,76 +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(models.Model):
|
||||
class ZoomMeeting(Base):
|
||||
# Basic meeting details
|
||||
topic = models.CharField(max_length=255)
|
||||
meeting_id = models.CharField(max_length=20, unique=True) # Unique identifier for the meeting
|
||||
start_time = models.DateTimeField()
|
||||
duration = models.PositiveIntegerField() # Duration in minutes
|
||||
timezone = models.CharField(max_length=50)
|
||||
join_url = models.URLField() # URL for participants to join
|
||||
password = models.CharField(max_length=50, blank=True, null=True)
|
||||
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'))
|
||||
|
||||
# Host information
|
||||
host_email = models.EmailField()
|
||||
|
||||
# Status
|
||||
STATUS_CHOICES = [
|
||||
('waiting', 'Waiting'),
|
||||
('started', 'Started'),
|
||||
('ended', 'Ended'),
|
||||
]
|
||||
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='waiting')
|
||||
|
||||
# Settings
|
||||
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)
|
||||
zoom_gateway_response = models.JSONField(blank=True, null=True, verbose_name=_('Zoom Gateway Response'))
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.topic
|
||||
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
|
||||
fields = '__all__'
|
||||
model = Candidate
|
||||
fields = '__all__'
|
||||
|
||||
@ -3,17 +3,60 @@ 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/<int:pk>/', views.ZoomMeetingDetailsView.as_view(), name='meeting_details'),
|
||||
path('update-meeting/<int:pk>/', views.ZoomMeetingUpdateView.as_view(), name='update_meeting'),
|
||||
path('delete-meeting/<int:pk>/', views.ZoomMeetingDeleteView, name='delete_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")
|
||||
|
||||
|
||||
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,23 +1,32 @@
|
||||
import json
|
||||
import requests
|
||||
from django.views import View
|
||||
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
|
||||
from .forms import ZoomMeetingForm,JobPostingForm
|
||||
from rest_framework import viewsets
|
||||
from django.contrib import messages
|
||||
from .models import ZoomMeeting, Job, Candidate
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.http import HttpResponseRedirect, JsonResponse
|
||||
from .serializers import JobSerializer, CandidateSerializer
|
||||
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, list_zoom_meetings, get_zoom_meeting_details, update_zoom_meeting
|
||||
from .utils import create_zoom_meeting, delete_zoom_meeting, update_zoom_meeting
|
||||
|
||||
class JobViewSet(viewsets.ModelViewSet):
|
||||
queryset = Job.objects.all()
|
||||
serializer_class = JobSerializer
|
||||
import logging
|
||||
logger=logging.getLogger(__name__)
|
||||
|
||||
|
||||
class JobPostingViewSet(viewsets.ModelViewSet):
|
||||
queryset = JobPosting.objects.all()
|
||||
serializer_class = JobPostingSerializer
|
||||
|
||||
class CandidateViewSet(viewsets.ModelViewSet):
|
||||
queryset = Candidate.objects.all()
|
||||
@ -61,6 +70,26 @@ 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
|
||||
@ -108,3 +137,441 @@ def ZoomMeetingDeleteView(request, pk):
|
||||
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]
|
||||
@ -51,4 +364,4 @@ def dashboard_view(request):
|
||||
'job_app_counts': job_app_counts,
|
||||
'average_applications': average_applications,
|
||||
}
|
||||
return render(request, 'recruitment/dashboard.html', context)
|
||||
return render(request, 'recruitment/dashboard.html', context)
|
||||
|
||||
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 |