This commit is contained in:
ismail 2025-10-05 12:19:45 +03:00
parent 1e04b5736d
commit 1aa8b6800a
110 changed files with 13058 additions and 1080 deletions

View File

@ -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

View File

@ -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)

Binary file not shown.

1495
i.html Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@ -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',)

View File

@ -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

View File

@ -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

View 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
# }

View File

@ -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'),
),
]

View 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),
),
]

View File

@ -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),
),
]

View File

@ -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'),
),
]

View 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',
),
]

View 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'),
),
]

View File

@ -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')),
],
),
]

View File

@ -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)

View File

@ -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__'

View File

@ -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'),
]

View File

@ -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
View 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.")

View File

@ -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
})

View File

@ -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
View 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
View 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);
}
}

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,3 @@
vin,
5FNRL38739B001353,
5FNRL38739B001354
Internal Server Error - Gitea: Git with a cup of tea
500 Internal Server Error

Gitea Version: 1.23.5