update1
@ -38,6 +38,7 @@ INSTALLED_APPS = [
|
||||
"unfold.contrib.guardian", # optional, if django-guardian package is used
|
||||
"unfold.contrib.simple_history",
|
||||
'django.contrib.admin',
|
||||
'django.contrib.humanize',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
@ -53,8 +54,10 @@ INSTALLED_APPS = [
|
||||
'allauth.socialaccount.providers.linkedin_oauth2',
|
||||
'channels',
|
||||
'django_filters',
|
||||
|
||||
|
||||
'crispy_forms',
|
||||
'crispy_bootstrap5',
|
||||
'django_extensions',
|
||||
'template_partials',
|
||||
]
|
||||
|
||||
SITE_ID = 1
|
||||
@ -135,11 +138,30 @@ AUTH_PASSWORD_VALIDATORS = [
|
||||
},
|
||||
]
|
||||
|
||||
# Crispy Forms Configuration
|
||||
CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5"
|
||||
CRISPY_TEMPLATE_PACK = "bootstrap5"
|
||||
|
||||
# Bootstrap 5 Configuration
|
||||
CRISPY_BS5 = {
|
||||
'include_placeholder_text': True,
|
||||
'use_css_helpers': True,
|
||||
}
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/5.2/topics/i18n/
|
||||
|
||||
LANGUAGES = [
|
||||
('en', 'English'),
|
||||
('ar', 'Arabic'),
|
||||
]
|
||||
|
||||
LANGUAGE_CODE = 'en-us'
|
||||
|
||||
LOCALE_PATHS = [
|
||||
BASE_DIR / 'locale',
|
||||
]
|
||||
|
||||
TIME_ZONE = 'UTC'
|
||||
|
||||
USE_I18N = True
|
||||
@ -188,4 +210,10 @@ UNFOLD = {
|
||||
ZOOM_ACCOUNT_ID = 'HoGikHXsQB2GNDC5Rvyw9A'
|
||||
ZOOM_CLIENT_ID = 'brC39920R8C8azfudUaQgA'
|
||||
ZOOM_CLIENT_SECRET = 'rvfhjlbID4ychXPOvZ2lYsoAC0B0Ny2L'
|
||||
SECRET_TOKEN = '6KdTGyF0SSCSL_V4Xa34aw'
|
||||
SECRET_TOKEN = '6KdTGyF0SSCSL_V4Xa34aw'
|
||||
|
||||
# Maximum file upload size (in bytes)
|
||||
DATA_UPLOAD_MAX_MEMORY_SIZE = 10485760 # 10MB
|
||||
FILE_UPLOAD_MAX_MEMORY_SIZE = 10485760 # 10MB
|
||||
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
@ -23,7 +23,7 @@ from rest_framework.routers import DefaultRouter
|
||||
from recruitment import views
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'jobs', views.JobViewSet)
|
||||
router.register(r'jobs', views.JobPostingViewSet)
|
||||
router.register(r'candidates', views.CandidateViewSet)
|
||||
|
||||
urlpatterns = [
|
||||
@ -33,4 +33,4 @@ urlpatterns = [
|
||||
path('', include('recruitment.urls')),
|
||||
]
|
||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
||||
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
||||
|
||||
BIN
db.sqlite3
BIN
recruitment/__pycache__/linkedin_service.cpython-313.pyc
Normal file
BIN
recruitment/__pycache__/validators.cpython-313.pyc
Normal 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',)
|
||||
|
||||
@ -1,7 +1,2 @@
|
||||
import pandas as pd
|
||||
from . import models
|
||||
|
||||
def get_dashboard_data():
|
||||
df = pd.DataFrame(list(models.Candidate.objects.all().values('applied', 'created_at')))
|
||||
summary = df['applied'].value_counts().to_dict()
|
||||
return summary
|
||||
# This file is intentionally left empty
|
||||
# The dashboard functionality has been moved to views_frontend.py
|
||||
|
||||
@ -1,15 +1,368 @@
|
||||
from django import forms
|
||||
from .models import ZoomMeeting, Candidate
|
||||
from .validators import validate_hash_tags
|
||||
from crispy_forms.helper import FormHelper
|
||||
from django.core.validators import URLValidator
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from crispy_forms.layout import Layout, Submit, HTML, Div, Field
|
||||
from .models import ZoomMeeting, Candidate,Job,TrainingMaterial,JobPosting
|
||||
|
||||
class CandidateForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Candidate
|
||||
fields = ['name', 'email', 'resume']
|
||||
fields = ['job', 'first_name', 'last_name', 'phone', 'email', 'resume', 'stage']
|
||||
labels = {
|
||||
'first_name': _('First Name'),
|
||||
'last_name': _('Last Name'),
|
||||
'phone': _('Phone'),
|
||||
'email': _('Email'),
|
||||
'resume': _('Resume'),
|
||||
'stage': _('Application Stage'),
|
||||
}
|
||||
widgets = {
|
||||
'first_name': forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Enter first name')}),
|
||||
'last_name': forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Enter last name')}),
|
||||
'phone': forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Enter phone number')}),
|
||||
'email': forms.EmailInput(attrs={'class': 'form-control', 'placeholder': _('Enter email')}),
|
||||
'stage': forms.Select(attrs={'class': 'form-select'}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_method = 'post'
|
||||
self.helper.form_class = 'form-horizontal'
|
||||
self.helper.label_class = 'col-md-3'
|
||||
self.helper.field_class = 'col-md-9'
|
||||
|
||||
# Make job field read-only if it's being pre-populated
|
||||
job_value = self.initial.get('job')
|
||||
if job_value:
|
||||
self.fields['job'].widget.attrs['readonly'] = True
|
||||
|
||||
self.helper.layout = Layout(
|
||||
Field('job', css_class='form-control'),
|
||||
Field('first_name', css_class='form-control'),
|
||||
Field('last_name', css_class='form-control'),
|
||||
Field('phone', css_class='form-control'),
|
||||
Field('email', css_class='form-control'),
|
||||
Field('stage', css_class='form-control'),
|
||||
Field('resume', css_class='form-control'),
|
||||
Submit('submit', _('Submit'), css_class='btn btn-primary')
|
||||
)
|
||||
|
||||
class CandidateStageForm(forms.ModelForm):
|
||||
"""Form specifically for updating candidate stage with validation"""
|
||||
|
||||
class Meta:
|
||||
model = Candidate
|
||||
fields = ['stage']
|
||||
labels = {
|
||||
'stage': _('New Application Stage'),
|
||||
}
|
||||
widgets = {
|
||||
'stage': forms.Select(attrs={'class': 'form-select'}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
# Get the current candidate instance for validation
|
||||
self.candidate = kwargs.pop('candidate', None)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Dynamically filter stage choices based on current stage
|
||||
if self.candidate and self.candidate.pk:
|
||||
current_stage = self.candidate.stage
|
||||
available_stages = self.candidate.get_available_stages()
|
||||
|
||||
# Filter choices to only include available stages
|
||||
choices = [(stage, self.candidate.Stage(stage).label)
|
||||
for stage in available_stages]
|
||||
self.fields['stage'].choices = choices
|
||||
|
||||
# Set initial value to current stage
|
||||
self.fields['stage'].initial = current_stage
|
||||
else:
|
||||
# For new candidates, only show 'Applied' stage
|
||||
self.fields['stage'].choices = [('Applied', _('Applied'))]
|
||||
self.fields['stage'].initial = 'Applied'
|
||||
|
||||
def clean_stage(self):
|
||||
"""Validate stage transition"""
|
||||
new_stage = self.cleaned_data.get('stage')
|
||||
if not new_stage:
|
||||
raise forms.ValidationError(_('Please select a stage.'))
|
||||
|
||||
# Use model validation for stage transitions
|
||||
if self.candidate and self.candidate.pk:
|
||||
current_stage = self.candidate.stage
|
||||
if new_stage != current_stage:
|
||||
if not self.candidate.can_transition_to(new_stage):
|
||||
allowed_stages = self.candidate.get_available_stages()
|
||||
raise forms.ValidationError(
|
||||
_('Cannot transition from "%(current)s" to "%(new)s". '
|
||||
'Allowed transitions: %(allowed)s') % {
|
||||
'current': current_stage,
|
||||
'new': new_stage,
|
||||
'allowed': ', '.join(allowed_stages) or 'None (final stage)'
|
||||
}
|
||||
)
|
||||
|
||||
return new_stage
|
||||
|
||||
class ZoomMeetingForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = ZoomMeeting
|
||||
fields = ['topic', 'start_time', 'duration']
|
||||
labels = {
|
||||
'topic': _('Topic'),
|
||||
'start_time': _('Start Time'),
|
||||
'duration': _('Duration'),
|
||||
}
|
||||
widgets = {
|
||||
'start_time': forms.DateTimeInput(attrs={'type': 'datetime-local'}),
|
||||
}
|
||||
'topic': forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Enter meeting topic'),}),
|
||||
'start_time': forms.DateTimeInput(attrs={'class': 'form-control','type': 'datetime-local'}),
|
||||
'duration': forms.NumberInput(attrs={'class': 'form-control','min': 1, 'placeholder': _('60')}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_method = 'post'
|
||||
self.helper.form_class = 'form-horizontal'
|
||||
self.helper.label_class = 'col-md-3'
|
||||
self.helper.field_class = 'col-md-9'
|
||||
self.helper.layout = Layout(
|
||||
Field('topic', css_class='form-control'),
|
||||
Field('start_time', css_class='form-control'),
|
||||
Field('duration', css_class='form-control'),
|
||||
Submit('submit', _('Create Meeting'), css_class='btn btn-primary')
|
||||
)
|
||||
|
||||
# Old JobForm removed - replaced by JobPostingForm
|
||||
|
||||
class TrainingMaterialForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = TrainingMaterial
|
||||
fields = ['title', 'content', 'video_link', 'file']
|
||||
labels = {
|
||||
'title': _('Title'),
|
||||
'content': _('Content'),
|
||||
'video_link': _('Video Link'),
|
||||
'file': _('File'),
|
||||
}
|
||||
widgets = {
|
||||
'title': forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Enter title')}),
|
||||
'content': forms.Textarea(attrs={'rows': 6, 'class': 'form-control', 'placeholder': _('Enter material content')}),
|
||||
'video_link': forms.URLInput(attrs={'class': 'form-control', 'placeholder': _('https://www.youtube.com/watch?v=...')}),
|
||||
'file': forms.FileInput(attrs={'class': 'form-control'}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_method = 'post'
|
||||
self.helper.form_class = 'form-horizontal'
|
||||
self.helper.label_class = 'col-md-3'
|
||||
self.helper.field_class = 'col-md-9'
|
||||
self.helper.layout = Layout(
|
||||
Field('title', css_class='form-control'),
|
||||
Field('content', css_class='form-control'),
|
||||
Div(
|
||||
Field('video_link', css_class='form-control'),
|
||||
Field('file', css_class='form-control'),
|
||||
css_class='row'
|
||||
),
|
||||
Submit('submit', _('Save Material'), css_class='btn btn-primary mt-3')
|
||||
)
|
||||
|
||||
|
||||
class JobPostingForm(forms.ModelForm):
|
||||
"""Form for creating and editing job postings"""
|
||||
|
||||
class Meta:
|
||||
model = JobPosting
|
||||
fields = [
|
||||
'title', 'department', 'job_type', 'workplace_type',
|
||||
'location_city', 'location_state', 'location_country',
|
||||
'description', 'qualifications', 'salary_range', 'benefits',
|
||||
'application_url', 'application_deadline', 'application_instructions',
|
||||
'position_number', 'reporting_to', 'start_date', 'status',
|
||||
'created_by','open_positions','hash_tags'
|
||||
]
|
||||
widgets = {
|
||||
# Basic Information
|
||||
'title': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Assistant Professor of Computer Science',
|
||||
'required': True
|
||||
}),
|
||||
'department': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Computer Science, Human Resources, etc.'
|
||||
}),
|
||||
'job_type': forms.Select(attrs={
|
||||
'class': 'form-select',
|
||||
'required': True
|
||||
}),
|
||||
'workplace_type': forms.Select(attrs={
|
||||
'class': 'form-select',
|
||||
'required': True
|
||||
}),
|
||||
|
||||
# Location
|
||||
'location_city': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Boston'
|
||||
}),
|
||||
'location_state': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'MA'
|
||||
}),
|
||||
'location_country': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'value': 'United States'
|
||||
}),
|
||||
|
||||
# Job Details
|
||||
'description': forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 6,
|
||||
'placeholder': 'Provide a comprehensive description of the role, responsibilities, and expectations...',
|
||||
'required': True
|
||||
}),
|
||||
'qualifications': forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 4,
|
||||
'placeholder': 'List required qualifications, skills, education, and experience...'
|
||||
}),
|
||||
'salary_range': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': '$60,000 - $80,000'
|
||||
}),
|
||||
'benefits': forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 2,
|
||||
'placeholder': 'Health insurance, retirement plans, tuition reimbursement, etc.'
|
||||
}),
|
||||
|
||||
# Application Information
|
||||
'application_url': forms.URLInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'https://university.edu/careers/job123',
|
||||
'required': True
|
||||
}),
|
||||
'application_deadline': forms.DateInput(attrs={
|
||||
'class': 'form-control',
|
||||
'type': 'date'
|
||||
}),
|
||||
'application_instructions': forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 3,
|
||||
'placeholder': 'Special instructions for applicants (e.g., required documents, reference requirements, etc.)'
|
||||
}),
|
||||
'open_positions': forms.NumberInput(attrs={
|
||||
'class': 'form-control',
|
||||
'min': 1,
|
||||
'placeholder': 'Number of open positions'
|
||||
}),
|
||||
'hash_tags': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': '#hiring,#jobopening',
|
||||
'validators':validate_hash_tags,
|
||||
|
||||
}),
|
||||
|
||||
# Internal Information
|
||||
'position_number': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'UNIV-2025-001'
|
||||
}),
|
||||
'reporting_to': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Department Chair, Director, etc.'
|
||||
}),
|
||||
'start_date': forms.DateInput(attrs={
|
||||
'class': 'form-control',
|
||||
'type': 'date'
|
||||
}),
|
||||
|
||||
'created_by': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'University Administrator'
|
||||
}),
|
||||
}
|
||||
|
||||
def __init__(self,*args,**kwargs):
|
||||
|
||||
# Extract your custom argument BEFORE calling super()
|
||||
self.is_anonymous_user = kwargs.pop('is_anonymous_user', False)
|
||||
# Now call the parent __init__ with remaining args
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if not self.instance.pk:# Creating new job posting
|
||||
if not self.is_anonymous_user:
|
||||
self.fields['created_by'].initial = 'University Administrator'
|
||||
self.fields['status'].initial = 'Draft'
|
||||
self.fields['location_city'].initial='Riyadh'
|
||||
self.fields['location_state'].initial='Riyadh Province'
|
||||
self.fields['location_country'].initial='Saudi Arabia'
|
||||
|
||||
|
||||
def clean_hash_tags(self):
|
||||
hash_tags=self.cleaned_data.get('hash_tags')
|
||||
if hash_tags:
|
||||
tags=[tag.strip() for tag in hash_tags.split(',') if tag.strip()]
|
||||
for tag in tags:
|
||||
if not tag.startswith('#'):
|
||||
raise forms.ValidationError("Each hashtag must start with '#' symbol and must be comma(,) sepearted.")
|
||||
return ','.join(tags)
|
||||
return hash_tags # Allow blank
|
||||
|
||||
def clean_title(self):
|
||||
title=self.cleaned_data.get('title')
|
||||
if not title or len(title.strip())<3:
|
||||
raise forms.ValidationError("Job title must be at least 3 characters long.")
|
||||
if len(title)>200:
|
||||
raise forms.ValidationError("Job title cannot exceed 200 characters.")
|
||||
return title.strip()
|
||||
|
||||
def clean_description(self):
|
||||
description=self.cleaned_data.get('description')
|
||||
if not description or len(description.strip())<20:
|
||||
raise forms.ValidationError("Job description must be at least 20 characters long.")
|
||||
return description.strip() # to remove leading/trailing whitespace
|
||||
|
||||
def clean_application_url(self):
|
||||
url=self.cleaned_data.get('application_url')
|
||||
if url:
|
||||
validator=URLValidator()
|
||||
try:
|
||||
validator(url)
|
||||
except forms.ValidationError:
|
||||
raise forms.ValidationError('Please enter a valid URL (e.g., https://example.com)')
|
||||
return url
|
||||
|
||||
def clean(self):
|
||||
"""Cross-field validation"""
|
||||
cleaned_data = super().clean()
|
||||
|
||||
# Validate dates
|
||||
start_date = cleaned_data.get('start_date')
|
||||
application_deadline = cleaned_data.get('application_deadline')
|
||||
|
||||
# Perform cross-field validation only if both fields have values
|
||||
if start_date and application_deadline:
|
||||
if application_deadline > start_date:
|
||||
self.add_error('application_deadline',
|
||||
'The application deadline must be set BEFORE the job start date.')
|
||||
|
||||
# # Validate that if status is ACTIVE, we have required fields
|
||||
# status = cleaned_data.get('status')
|
||||
# if status == 'ACTIVE':
|
||||
# if not cleaned_data.get('application_url'):
|
||||
# self.add_error('application_url',
|
||||
# 'Application URL is required for active jobs.')
|
||||
# if not cleaned_data.get('description'):
|
||||
# self.add_error('description',
|
||||
# 'Job description is required for active jobs.')
|
||||
|
||||
return cleaned_data
|
||||
|
||||
552
recruitment/linkedin_service.py
Normal file
@ -0,0 +1,552 @@
|
||||
# jobs/linkedin_service.py
|
||||
import uuid
|
||||
from urllib.parse import quote
|
||||
import requests
|
||||
import logging
|
||||
from django.conf import settings
|
||||
from urllib.parse import urlencode, quote
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class LinkedInService:
|
||||
def __init__(self):
|
||||
self.client_id = settings.LINKEDIN_CLIENT_ID
|
||||
self.client_secret = settings.LINKEDIN_CLIENT_SECRET
|
||||
self.redirect_uri = settings.LINKEDIN_REDIRECT_URI
|
||||
self.access_token = None
|
||||
|
||||
def get_auth_url(self):
|
||||
"""Generate LinkedIn OAuth URL"""
|
||||
params = {
|
||||
'response_type': 'code',
|
||||
'client_id': self.client_id,
|
||||
'redirect_uri': self.redirect_uri,
|
||||
'scope': 'w_member_social openid profile',
|
||||
'state': 'university_ats_linkedin'
|
||||
}
|
||||
return f"https://www.linkedin.com/oauth/v2/authorization?{urlencode(params)}"
|
||||
|
||||
def get_access_token(self, code):
|
||||
"""Exchange authorization code for access token"""
|
||||
# This function exchanges LinkedIn’s temporary authorization code for a usable access token.
|
||||
url = "https://www.linkedin.com/oauth/v2/accessToken"
|
||||
data = {
|
||||
'grant_type': 'authorization_code',
|
||||
'code': code,
|
||||
'redirect_uri': self.redirect_uri,
|
||||
'client_id': self.client_id,
|
||||
'client_secret': self.client_secret
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(url, data=data, timeout=60)
|
||||
response.raise_for_status()
|
||||
token_data = response.json()
|
||||
"""
|
||||
Example response:{
|
||||
"access_token": "AQXq8HJkLmNpQrStUvWxYz...",
|
||||
"expires_in": 5184000
|
||||
}
|
||||
"""
|
||||
self.access_token = token_data.get('access_token')
|
||||
return self.access_token
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting access token: {e}")
|
||||
raise
|
||||
|
||||
def get_user_profile(self):
|
||||
"""Get user profile information"""
|
||||
if not self.access_token:
|
||||
raise Exception("No access token available")
|
||||
|
||||
url = "https://api.linkedin.com/v2/userinfo"
|
||||
headers = {'Authorization': f'Bearer {self.access_token}'}
|
||||
|
||||
try:
|
||||
response = requests.get(url, headers=headers, timeout=60)
|
||||
response.raise_for_status() # Ensure we raise an error for bad responses(4xx, 5xx) and does nothing for 2xx(success)
|
||||
return response.json() # returns a dict from json response (deserialize)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting user profile: {e}")
|
||||
raise
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def register_image_upload(self, person_urn):
|
||||
"""Step 1: Register image upload with LinkedIn"""
|
||||
url = "https://api.linkedin.com/v2/assets?action=registerUpload"
|
||||
headers = {
|
||||
'Authorization': f'Bearer {self.access_token}',
|
||||
'Content-Type': 'application/json',
|
||||
'X-Restli-Protocol-Version': '2.0.0'
|
||||
}
|
||||
|
||||
payload = {
|
||||
"registerUploadRequest": {
|
||||
"recipes": ["urn:li:digitalmediaRecipe:feedshare-image"],
|
||||
"owner": f"urn:li:person:{person_urn}",
|
||||
"serviceRelationships": [{
|
||||
"relationshipType": "OWNER",
|
||||
"identifier": "urn:li:userGeneratedContent"
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
response = requests.post(url, headers=headers, json=payload, timeout=30)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
return {
|
||||
'upload_url': data['value']['uploadMechanism']['com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest']['uploadUrl'],
|
||||
'asset': data['value']['asset']
|
||||
}
|
||||
|
||||
def upload_image_to_linkedin(self, upload_url, image_file):
|
||||
"""Step 2: Upload actual image file to LinkedIn"""
|
||||
# Open and read the Django ImageField
|
||||
image_file.open()
|
||||
image_content = image_file.read()
|
||||
image_file.close()
|
||||
|
||||
headers = {
|
||||
'Authorization': f'Bearer {self.access_token}',
|
||||
}
|
||||
|
||||
response = requests.post(upload_url, headers=headers, data=image_content, timeout=60)
|
||||
response.raise_for_status()
|
||||
return True
|
||||
def create_job_post(self, job_posting):
|
||||
"""Create a job announcement post on LinkedIn (with image support)"""
|
||||
if not self.access_token:
|
||||
raise Exception("Not authenticated with LinkedIn")
|
||||
|
||||
try:
|
||||
# Get user profile for person URN
|
||||
profile = self.get_user_profile()
|
||||
person_urn = profile.get('sub')
|
||||
|
||||
if not person_urn:
|
||||
raise Exception("Could not retrieve LinkedIn user ID")
|
||||
|
||||
# Check if job has an image
|
||||
try:
|
||||
image_upload = job_posting.files.first()
|
||||
has_image = image_upload and image_upload.linkedinpost_image
|
||||
except Exception:
|
||||
has_image = False
|
||||
|
||||
if has_image:
|
||||
# === POST WITH IMAGE ===
|
||||
try:
|
||||
# Step 1: Register image upload
|
||||
upload_info = self.register_image_upload(person_urn)
|
||||
|
||||
# Step 2: Upload image
|
||||
self.upload_image_to_linkedin(
|
||||
upload_info['upload_url'],
|
||||
image_upload.linkedinpost_image
|
||||
)
|
||||
|
||||
# Step 3: Create post with image
|
||||
return self.create_job_post_with_image(
|
||||
job_posting,
|
||||
image_upload.linkedinpost_image,
|
||||
person_urn,
|
||||
upload_info['asset']
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Image upload failed: {e}")
|
||||
# Fall back to text-only post if image upload fails
|
||||
has_image = False
|
||||
|
||||
# === FALLBACK TO URL/ARTICLE POST ===
|
||||
# Add unique timestamp to prevent duplicates
|
||||
from django.utils import timezone
|
||||
import random
|
||||
unique_suffix = f"\n\nPosted: {timezone.now().strftime('%b %d, %Y at %I:%M %p')} (ID: {random.randint(1000, 9999)})"
|
||||
|
||||
message_parts = [f"🚀 **We're Hiring: {job_posting.title}**"]
|
||||
if job_posting.department:
|
||||
message_parts.append(f"**Department:** {job_posting.department}")
|
||||
if job_posting.description:
|
||||
message_parts.append(f"\n{job_posting.description}")
|
||||
|
||||
details = []
|
||||
if job_posting.job_type:
|
||||
details.append(f"💼 {job_posting.get_job_type_display()}")
|
||||
if job_posting.get_location_display() != 'Not specified':
|
||||
details.append(f"📍 {job_posting.get_location_display()}")
|
||||
if job_posting.workplace_type:
|
||||
details.append(f"🏠 {job_posting.get_workplace_type_display()}")
|
||||
if job_posting.salary_range:
|
||||
details.append(f"💰 {job_posting.salary_range}")
|
||||
|
||||
if details:
|
||||
message_parts.append("\n" + " | ".join(details))
|
||||
|
||||
if job_posting.application_url:
|
||||
message_parts.append(f"\n🔗 **Apply now:** {job_posting.application_url}")
|
||||
|
||||
hashtags = self.hashtags_list(job_posting.hash_tags)
|
||||
if job_posting.department:
|
||||
dept_hashtag = f"#{job_posting.department.replace(' ', '')}"
|
||||
hashtags.insert(0, dept_hashtag)
|
||||
|
||||
message_parts.append("\n\n" + " ".join(hashtags))
|
||||
message_parts.append(unique_suffix)
|
||||
message = "\n".join(message_parts)
|
||||
|
||||
# 🔥 FIX URL - REMOVE TRAILING SPACES 🔥
|
||||
url = "https://api.linkedin.com/v2/ugcPosts"
|
||||
headers = {
|
||||
'Authorization': f'Bearer {self.access_token}',
|
||||
'Content-Type': 'application/json',
|
||||
'X-Restli-Protocol-Version': '2.0.0'
|
||||
}
|
||||
|
||||
payload = {
|
||||
"author": f"urn:li:person:{person_urn}",
|
||||
"lifecycleState": "PUBLISHED",
|
||||
"specificContent": {
|
||||
"com.linkedin.ugc.ShareContent": {
|
||||
"shareCommentary": {"text": message},
|
||||
"shareMediaCategory": "ARTICLE",
|
||||
"media": [{
|
||||
"status": "READY",
|
||||
"description": {"text": f"Apply for {job_posting.title} at our university!"},
|
||||
"originalUrl": job_posting.application_url,
|
||||
"title": {"text": job_posting.title}
|
||||
}]
|
||||
}
|
||||
},
|
||||
"visibility": {
|
||||
"com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"
|
||||
}
|
||||
}
|
||||
|
||||
response = requests.post(url, headers=headers, json=payload, timeout=60)
|
||||
response.raise_for_status()
|
||||
|
||||
post_id = response.headers.get('x-restli-id', '')
|
||||
post_url = f"https://www.linkedin.com/feed/update/{quote(post_id)}/" if post_id else ""
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'post_id': post_id,
|
||||
'post_url': post_url,
|
||||
'status_code': response.status_code
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating LinkedIn post: {e}")
|
||||
return {
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
'status_code': getattr(e.response, 'status_code', 500) if hasattr(e, 'response') else 500
|
||||
}
|
||||
|
||||
# def create_job_post_with_image(self, job_posting, image_file, person_urn, asset_urn):
|
||||
# """Step 3: Create post with uploaded image"""
|
||||
# url = "https://api.linkedin.com/v2/ugcPosts"
|
||||
# headers = {
|
||||
# 'Authorization': f'Bearer {self.access_token}',
|
||||
# 'Content-Type': 'application/json',
|
||||
# 'X-Restli-Protocol-Version': '2.0.0'
|
||||
# }
|
||||
|
||||
# # Build the same message as before
|
||||
# message_parts = [f"🚀 **We're Hiring: {job_posting.title}**"]
|
||||
# if job_posting.department:
|
||||
# message_parts.append(f"**Department:** {job_posting.department}")
|
||||
# if job_posting.description:
|
||||
# message_parts.append(f"\n{job_posting.description}")
|
||||
|
||||
# details = []
|
||||
# if job_posting.job_type:
|
||||
# details.append(f"💼 {job_posting.get_job_type_display()}")
|
||||
# if job_posting.get_location_display() != 'Not specified':
|
||||
# details.append(f"📍 {job_posting.get_location_display()}")
|
||||
# if job_posting.workplace_type:
|
||||
# details.append(f"🏠 {job_posting.get_workplace_type_display()}")
|
||||
# if job_posting.salary_range:
|
||||
# details.append(f"💰 {job_posting.salary_range}")
|
||||
|
||||
# if details:
|
||||
# message_parts.append("\n" + " | ".join(details))
|
||||
|
||||
# if job_posting.application_url:
|
||||
# message_parts.append(f"\n🔗 **Apply now:** {job_posting.application_url}")
|
||||
|
||||
# hashtags = self.hashtags_list(job_posting.hash_tags)
|
||||
# if job_posting.department:
|
||||
# dept_hashtag = f"#{job_posting.department.replace(' ', '')}"
|
||||
# hashtags.insert(0, dept_hashtag)
|
||||
|
||||
# message_parts.append("\n\n" + " ".join(hashtags))
|
||||
# message = "\n".join(message_parts)
|
||||
|
||||
# # Create image post payload
|
||||
# payload = {
|
||||
# "author": f"urn:li:person:{person_urn}",
|
||||
# "lifecycleState": "PUBLISHED",
|
||||
# "specificContent": {
|
||||
# "com.linkedin.ugc.ShareContent": {
|
||||
# "shareCommentary": {"text": message},
|
||||
# "shareMediaCategory": "IMAGE",
|
||||
# "media": [{
|
||||
# "status": "READY",
|
||||
# "media": asset_urn
|
||||
# }]
|
||||
# }
|
||||
# },
|
||||
# "visibility": {
|
||||
# "com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"
|
||||
# }
|
||||
# }
|
||||
|
||||
# response = requests.post(url, headers=headers, json=payload, timeout=30)
|
||||
# response.raise_for_status()
|
||||
|
||||
# post_id = response.headers.get('x-restli-id', '')
|
||||
# post_url = f"https://www.linkedin.com/feed/update/{quote(post_id)}/" if post_id else ""
|
||||
|
||||
# return {
|
||||
# 'success': True,
|
||||
# 'post_id': post_id,
|
||||
# 'post_url': post_url,
|
||||
# 'status_code': response.status_code
|
||||
# }
|
||||
|
||||
def hashtags_list(self,hash_tags_str):
|
||||
"""Convert comma-separated hashtags string to list"""
|
||||
if not hash_tags_str:
|
||||
return [""]
|
||||
tags = [tag.strip() for tag in hash_tags_str.split(',') if tag.strip()]
|
||||
if not tags:
|
||||
return ["#HigherEd", "#Hiring", "#FacultyJobs", "#UniversityJobs"]
|
||||
|
||||
return tags
|
||||
|
||||
|
||||
# def create_job_post(self, job_posting):
|
||||
# """Create a job announcement post on LinkedIn (with image support)"""
|
||||
# if not self.access_token:
|
||||
# raise Exception("Not authenticated with LinkedIn")
|
||||
|
||||
# try:
|
||||
# # Get user profile for person URN
|
||||
# profile = self.get_user_profile()
|
||||
# person_urn = profile.get('sub')
|
||||
|
||||
# if not person_urn:
|
||||
# raise Exception("Could not retrieve LinkedIn user ID")
|
||||
|
||||
# # Check if job has an image
|
||||
# try:
|
||||
# image_upload = job_posting.files.first()
|
||||
# has_image = image_upload and image_upload.linkedinpost_image
|
||||
# except Exception:
|
||||
# has_image = False
|
||||
|
||||
# if has_image:
|
||||
# # === POST WITH IMAGE ===
|
||||
# upload_info = self.register_image_upload(person_urn)
|
||||
# self.upload_image_to_linkedin(
|
||||
# upload_info['upload_url'],
|
||||
# image_upload.linkedinpost_image
|
||||
# )
|
||||
# return self.create_job_post_with_image(
|
||||
# job_posting,
|
||||
# image_upload.linkedinpost_image,
|
||||
# person_urn,
|
||||
# upload_info['asset']
|
||||
# )
|
||||
|
||||
# else:
|
||||
# # === FALLBACK TO URL/ARTICLE POST ===
|
||||
# # 🔥 ADD UNIQUE TIMESTAMP TO PREVENT DUPLICATES 🔥
|
||||
# from django.utils import timezone
|
||||
# import random
|
||||
# unique_suffix = f"\n\nPosted: {timezone.now().strftime('%b %d, %Y at %I:%M %p')} (ID: {random.randint(1000, 9999)})"
|
||||
|
||||
# message_parts = [f"🚀 **We're Hiring: {job_posting.title}**"]
|
||||
# if job_posting.department:
|
||||
# message_parts.append(f"**Department:** {job_posting.department}")
|
||||
# if job_posting.description:
|
||||
# message_parts.append(f"\n{job_posting.description}")
|
||||
|
||||
# details = []
|
||||
# if job_posting.job_type:
|
||||
# details.append(f"💼 {job_posting.get_job_type_display()}")
|
||||
# if job_posting.get_location_display() != 'Not specified':
|
||||
# details.append(f"📍 {job_posting.get_location_display()}")
|
||||
# if job_posting.workplace_type:
|
||||
# details.append(f"🏠 {job_posting.get_workplace_type_display()}")
|
||||
# if job_posting.salary_range:
|
||||
# details.append(f"💰 {job_posting.salary_range}")
|
||||
|
||||
# if details:
|
||||
# message_parts.append("\n" + " | ".join(details))
|
||||
|
||||
# if job_posting.application_url:
|
||||
# message_parts.append(f"\n🔗 **Apply now:** {job_posting.application_url}")
|
||||
|
||||
# hashtags = self.hashtags_list(job_posting.hash_tags)
|
||||
# if job_posting.department:
|
||||
# dept_hashtag = f"#{job_posting.department.replace(' ', '')}"
|
||||
# hashtags.insert(0, dept_hashtag)
|
||||
|
||||
# message_parts.append("\n\n" + " ".join(hashtags))
|
||||
# message_parts.append(unique_suffix) # 🔥 Add unique suffix
|
||||
# message = "\n".join(message_parts)
|
||||
|
||||
# # 🔥 FIX URL - REMOVE TRAILING SPACES 🔥
|
||||
# url = "https://api.linkedin.com/v2/ugcPosts"
|
||||
# headers = {
|
||||
# 'Authorization': f'Bearer {self.access_token}',
|
||||
# 'Content-Type': 'application/json',
|
||||
# 'X-Restli-Protocol-Version': '2.0.0'
|
||||
# }
|
||||
|
||||
# payload = {
|
||||
# "author": f"urn:li:person:{person_urn}",
|
||||
# "lifecycleState": "PUBLISHED",
|
||||
# "specificContent": {
|
||||
# "com.linkedin.ugc.ShareContent": {
|
||||
# "shareCommentary": {"text": message},
|
||||
# "shareMediaCategory": "ARTICLE",
|
||||
# "media": [{
|
||||
# "status": "READY",
|
||||
# "description": {"text": f"Apply for {job_posting.title} at our university!"},
|
||||
# "originalUrl": job_posting.application_url,
|
||||
# "title": {"text": job_posting.title}
|
||||
# }]
|
||||
# }
|
||||
# },
|
||||
# "visibility": {
|
||||
# "com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"
|
||||
# }
|
||||
# }
|
||||
|
||||
# response = requests.post(url, headers=headers, json=payload, timeout=60)
|
||||
# response.raise_for_status()
|
||||
|
||||
# post_id = response.headers.get('x-restli-id', '')
|
||||
# # 🔥 FIX POST URL - REMOVE TRAILING SPACES 🔥
|
||||
# post_url = f"https://www.linkedin.com/feed/update/{quote(post_id)}/" if post_id else ""
|
||||
|
||||
# return {
|
||||
# 'success': True,
|
||||
# 'post_id': post_id,
|
||||
# 'post_url': post_url,
|
||||
# 'status_code': response.status_code
|
||||
# }
|
||||
|
||||
# except Exception as e:
|
||||
# logger.error(f"Error creating LinkedIn post: {e}")
|
||||
# return {
|
||||
# 'success': False,
|
||||
# 'error': str(e),
|
||||
# 'status_code': getattr(e.response, 'status_code', 500) if hasattr(e, 'response') else 500
|
||||
# }
|
||||
# def create_job_post(self, job_posting):
|
||||
# """Create a job announcement post on LinkedIn"""
|
||||
# if not self.access_token:
|
||||
# raise Exception("Not authenticated with LinkedIn")
|
||||
|
||||
# try:
|
||||
# # Get user profile for person URN
|
||||
# profile = self.get_user_profile()
|
||||
# person_urn = profile.get('sub')
|
||||
|
||||
# if not person_urn: # uniform resource name used to uniquely identify linked-id for internal systems and apis
|
||||
# raise Exception("Could not retrieve LinkedIn user ID")
|
||||
|
||||
# # Build professional job post message
|
||||
# message_parts = [f"🚀 **We're Hiring: {job_posting.title}**"]
|
||||
|
||||
# if job_posting.department:
|
||||
# message_parts.append(f"**Department:** {job_posting.department}")
|
||||
|
||||
# if job_posting.description:
|
||||
# message_parts.append(f"\n{job_posting.description}")
|
||||
|
||||
# # Add job details
|
||||
# details = []
|
||||
# if job_posting.job_type:
|
||||
# details.append(f"💼 {job_posting.get_job_type_display()}")
|
||||
# if job_posting.get_location_display() != 'Not specified':
|
||||
# details.append(f"📍 {job_posting.get_location_display()}")
|
||||
# if job_posting.workplace_type:
|
||||
# details.append(f"🏠 {job_posting.get_workplace_type_display()}")
|
||||
# if job_posting.salary_range:
|
||||
# details.append(f"💰 {job_posting.salary_range}")
|
||||
|
||||
# if details:
|
||||
# message_parts.append("\n" + " | ".join(details))
|
||||
|
||||
# # Add application link
|
||||
# if job_posting.application_url:
|
||||
# message_parts.append(f"\n🔗 **Apply now:** {job_posting.application_url}")
|
||||
|
||||
# # Add hashtags
|
||||
# hashtags = ["#HigherEd", "#Hiring", "#FacultyJobs", "#UniversityJobs"]
|
||||
# if job_posting.department:
|
||||
# dept_hashtag = f"#{job_posting.department.replace(' ', '')}"
|
||||
# hashtags.insert(0, dept_hashtag)
|
||||
|
||||
# message_parts.append("\n\n" + " ".join(hashtags))
|
||||
# message = "\n".join(message_parts)
|
||||
|
||||
# # Create LinkedIn post
|
||||
# url = "https://api.linkedin.com/v2/ugcPosts"
|
||||
# headers = {
|
||||
# 'Authorization': f'Bearer {self.access_token}',
|
||||
# 'Content-Type': 'application/json',
|
||||
# 'X-Restli-Protocol-Version': '2.0.0'
|
||||
# }
|
||||
|
||||
# payload = {
|
||||
# "author": f"urn:li:person:{person_urn}",
|
||||
# "lifecycleState": "PUBLISHED",
|
||||
# "specificContent": {
|
||||
# "com.linkedin.ugc.ShareContent": {
|
||||
# "shareCommentary": {"text": message},
|
||||
# "shareMediaCategory": "ARTICLE",
|
||||
# "media": [{
|
||||
# "status": "READY",
|
||||
# "description": {"text": f"Apply for {job_posting.title} at our university!"},
|
||||
# "originalUrl": job_posting.application_url,
|
||||
# "title": {"text": job_posting.title}
|
||||
# }]
|
||||
# }
|
||||
# },
|
||||
# "visibility": {
|
||||
# "com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"
|
||||
# }
|
||||
# }
|
||||
|
||||
# response = requests.post(url, headers=headers, json=payload, timeout=60)
|
||||
# response.raise_for_status()
|
||||
|
||||
# # Extract post ID from response
|
||||
# post_id = response.headers.get('x-restli-id', '')
|
||||
# post_url = f"https://www.linkedin.com/feed/update/{quote(post_id)}/" if post_id else ""
|
||||
|
||||
# return {
|
||||
# 'success': True,
|
||||
# 'post_id': post_id,
|
||||
# 'post_url': post_url,
|
||||
# 'status_code': response.status_code
|
||||
# }
|
||||
|
||||
# except Exception as e:
|
||||
# logger.error(f"Error creating LinkedIn post: {e}")
|
||||
# return {
|
||||
# 'success': False,
|
||||
# 'error': str(e),
|
||||
# 'status_code': getattr(e.response, 'status_code', 500) if hasattr(e, 'response') else 500
|
||||
# }
|
||||
@ -0,0 +1,318 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-02 14:14
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import recruitment.validators
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0005_zoommeeting'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='JobPosting',
|
||||
fields=[
|
||||
('title', models.CharField(max_length=200)),
|
||||
('department', models.CharField(blank=True, max_length=100)),
|
||||
('job_type', models.CharField(choices=[('FULL_TIME', 'Full-time'), ('PART_TIME', 'Part-time'), ('CONTRACT', 'Contract'), ('INTERNSHIP', 'Internship'), ('FACULTY', 'Faculty'), ('TEMPORARY', 'Temporary')], default='FULL_TIME', max_length=20)),
|
||||
('workplace_type', models.CharField(choices=[('ON_SITE', 'On-site'), ('REMOTE', 'Remote'), ('HYBRID', 'Hybrid')], default='ON_SITE', max_length=20)),
|
||||
('location_city', models.CharField(blank=True, max_length=100)),
|
||||
('location_state', models.CharField(blank=True, max_length=100)),
|
||||
('location_country', models.CharField(default='United States', max_length=100)),
|
||||
('description', models.TextField(help_text='Full job description including responsibilities and requirements')),
|
||||
('qualifications', models.TextField(blank=True, help_text='Required qualifications and skills')),
|
||||
('salary_range', models.CharField(blank=True, help_text='e.g., $60,000 - $80,000', max_length=200)),
|
||||
('benefits', models.TextField(blank=True, help_text='Benefits offered')),
|
||||
('application_url', models.URLField(help_text='URL where candidates apply', validators=[django.core.validators.URLValidator()])),
|
||||
('application_deadline', models.DateField(blank=True, null=True)),
|
||||
('application_instructions', models.TextField(blank=True, help_text='Special instructions for applicants')),
|
||||
('internal_job_id', models.CharField(editable=False, max_length=50, primary_key=True, serialize=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('created_by', models.CharField(blank=True, help_text='Name of person who created this job', max_length=100)),
|
||||
('status', models.CharField(choices=[('DRAFT', 'Draft'), ('ACTIVE', 'Active'), ('CLOSED', 'Closed'), ('ARCHIVED', 'Archived')], default='DRAFT', max_length=20)),
|
||||
('hash_tags', models.CharField(blank=True, help_text='Comma-separated hashtags for linkedin post like #hiring,#jobopening', max_length=200, validators=[recruitment.validators.validate_hash_tags])),
|
||||
('linkedin_post_id', models.CharField(blank=True, help_text='LinkedIn post ID after posting', max_length=200)),
|
||||
('linkedin_post_url', models.URLField(blank=True, help_text='Direct URL to LinkedIn post')),
|
||||
('posted_to_linkedin', models.BooleanField(default=False)),
|
||||
('linkedin_post_status', models.CharField(blank=True, help_text='Status of LinkedIn posting', max_length=50)),
|
||||
('linkedin_posted_at', models.DateTimeField(blank=True, null=True)),
|
||||
('position_number', models.CharField(blank=True, help_text='University position number', max_length=50)),
|
||||
('reporting_to', models.CharField(blank=True, help_text='Who this position reports to', max_length=100)),
|
||||
('start_date', models.DateField(blank=True, help_text='Desired start date', null=True)),
|
||||
('open_positions', models.PositiveIntegerField(default=1, help_text='Number of open positions for this job')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Job Posting',
|
||||
'verbose_name_plural': 'Job Postings',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='candidate',
|
||||
options={'verbose_name': 'Candidate', 'verbose_name_plural': 'Candidates'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='job',
|
||||
options={'verbose_name': 'Job', 'verbose_name_plural': 'Jobs'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='trainingmaterial',
|
||||
options={'verbose_name': 'Training Material', 'verbose_name_plural': 'Training Materials'},
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='zoommeeting',
|
||||
name='host_email',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='zoommeeting',
|
||||
name='host_video',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='zoommeeting',
|
||||
name='password',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='zoommeeting',
|
||||
name='status',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='candidate',
|
||||
name='exam_date',
|
||||
field=models.DateField(blank=True, null=True, verbose_name='Exam Date'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='candidate',
|
||||
name='exam_status',
|
||||
field=models.CharField(blank=True, choices=[('Passed', 'Passed'), ('Failed', 'Failed')], max_length=100, null=True, verbose_name='Exam Status'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='candidate',
|
||||
name='first_name',
|
||||
field=models.CharField(default='user', max_length=255, verbose_name='First Name'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='candidate',
|
||||
name='interview_date',
|
||||
field=models.DateField(blank=True, null=True, verbose_name='Interview Date'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='candidate',
|
||||
name='interview_status',
|
||||
field=models.CharField(blank=True, choices=[('Accepted', 'Accepted'), ('Rejected', 'Rejected')], max_length=100, null=True, verbose_name='Interview Status'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='candidate',
|
||||
name='join_date',
|
||||
field=models.DateField(blank=True, null=True, verbose_name='Join Date'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='candidate',
|
||||
name='last_name',
|
||||
field=models.CharField(default='user', max_length=255, verbose_name='Last Name'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='candidate',
|
||||
name='offer_date',
|
||||
field=models.DateField(blank=True, null=True, verbose_name='Offer Date'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='candidate',
|
||||
name='offer_status',
|
||||
field=models.CharField(blank=True, choices=[('Accepted', 'Accepted'), ('Rejected', 'Rejected')], max_length=100, null=True, verbose_name='Offer Status'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='candidate',
|
||||
name='phone',
|
||||
field=models.CharField(default='0569874562', max_length=20, verbose_name='Phone'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='candidate',
|
||||
name='stage',
|
||||
field=models.CharField(default='Applied', max_length=100, verbose_name='Stage'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='candidate',
|
||||
name='applied',
|
||||
field=models.BooleanField(default=False, verbose_name='Applied'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='candidate',
|
||||
name='created_at',
|
||||
field=models.DateTimeField(auto_now_add=True, verbose_name='Created at'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='candidate',
|
||||
name='email',
|
||||
field=models.EmailField(max_length=254, verbose_name='Email'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='candidate',
|
||||
name='name',
|
||||
field=models.CharField(max_length=255, verbose_name='Name'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='candidate',
|
||||
name='parsed_summary',
|
||||
field=models.TextField(blank=True, verbose_name='Parsed Summary'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='candidate',
|
||||
name='resume',
|
||||
field=models.FileField(upload_to='resumes/', verbose_name='Resume'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='candidate',
|
||||
name='updated_at',
|
||||
field=models.DateTimeField(auto_now=True, verbose_name='Updated at'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='job',
|
||||
name='created_at',
|
||||
field=models.DateTimeField(auto_now_add=True, verbose_name='Created at'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='job',
|
||||
name='description_ar',
|
||||
field=models.TextField(verbose_name='Description Arabic'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='job',
|
||||
name='description_en',
|
||||
field=models.TextField(verbose_name='Description English'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='job',
|
||||
name='is_published',
|
||||
field=models.BooleanField(default=False, verbose_name='Published'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='job',
|
||||
name='posted_to_linkedin',
|
||||
field=models.BooleanField(default=False, verbose_name='Posted to LinkedIn'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='job',
|
||||
name='title',
|
||||
field=models.CharField(max_length=255, verbose_name='Title'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='job',
|
||||
name='updated_at',
|
||||
field=models.DateTimeField(auto_now=True, verbose_name='Updated at'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='trainingmaterial',
|
||||
name='content',
|
||||
field=models.TextField(blank=True, verbose_name='Content'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='trainingmaterial',
|
||||
name='created_at',
|
||||
field=models.DateTimeField(auto_now_add=True, verbose_name='Created at'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='trainingmaterial',
|
||||
name='created_by',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Created by'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='trainingmaterial',
|
||||
name='file',
|
||||
field=models.FileField(blank=True, upload_to='training_materials/', verbose_name='File'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='trainingmaterial',
|
||||
name='title',
|
||||
field=models.CharField(max_length=255, verbose_name='Title'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='trainingmaterial',
|
||||
name='updated_at',
|
||||
field=models.DateTimeField(auto_now=True, verbose_name='Updated at'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='trainingmaterial',
|
||||
name='video_link',
|
||||
field=models.URLField(blank=True, verbose_name='Video Link'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='zoommeeting',
|
||||
name='created_at',
|
||||
field=models.DateTimeField(auto_now_add=True, verbose_name='Created at'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='zoommeeting',
|
||||
name='duration',
|
||||
field=models.PositiveIntegerField(verbose_name='Duration'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='zoommeeting',
|
||||
name='join_before_host',
|
||||
field=models.BooleanField(default=False, verbose_name='Join Before Host'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='zoommeeting',
|
||||
name='join_url',
|
||||
field=models.URLField(verbose_name='Join URL'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='zoommeeting',
|
||||
name='meeting_id',
|
||||
field=models.CharField(max_length=20, unique=True, verbose_name='Meeting ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='zoommeeting',
|
||||
name='mute_upon_entry',
|
||||
field=models.BooleanField(default=False, verbose_name='Mute Upon Entry'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='zoommeeting',
|
||||
name='participant_video',
|
||||
field=models.BooleanField(default=True, verbose_name='Participant Video'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='zoommeeting',
|
||||
name='start_time',
|
||||
field=models.DateTimeField(verbose_name='Start Time'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='zoommeeting',
|
||||
name='timezone',
|
||||
field=models.CharField(max_length=50, verbose_name='Timezone'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='zoommeeting',
|
||||
name='topic',
|
||||
field=models.CharField(max_length=255, verbose_name='Topic'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='zoommeeting',
|
||||
name='updated_at',
|
||||
field=models.DateTimeField(auto_now=True, verbose_name='Updated at'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='zoommeeting',
|
||||
name='waiting_room',
|
||||
field=models.BooleanField(default=False, verbose_name='Waiting Room'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='zoommeeting',
|
||||
name='zoom_gateway_response',
|
||||
field=models.JSONField(blank=True, null=True, verbose_name='Zoom Gateway Response'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='candidate',
|
||||
name='job',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='candidates', to='recruitment.jobposting', verbose_name='Job'),
|
||||
),
|
||||
]
|
||||
18
recruitment/migrations/0007_alter_jobposting_status.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-02 14:32
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0006_jobposting_alter_candidate_options_alter_job_options_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='jobposting',
|
||||
name='status',
|
||||
field=models.CharField(blank=True, choices=[('DRAFT', 'Draft'), ('ACTIVE', 'Active'), ('CLOSED', 'Closed'), ('ARCHIVED', 'Archived')], default='DRAFT', max_length=20, null=True),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-02 14:33
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0007_alter_jobposting_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='jobposting',
|
||||
name='published_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='jobposting',
|
||||
name='status',
|
||||
field=models.CharField(blank=True, choices=[('DRAFT', 'Draft'), ('PUBLISHED', 'Published'), ('CLOSED', 'Closed'), ('ARCHIVED', 'Archived')], default='DRAFT', max_length=20, null=True),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,49 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-02 14:39
|
||||
|
||||
import django_extensions.db.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0008_jobposting_published_at_alter_jobposting_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='candidate',
|
||||
name='slug',
|
||||
field=django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='job',
|
||||
name='slug',
|
||||
field=django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='jobposting',
|
||||
name='slug',
|
||||
field=django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='trainingmaterial',
|
||||
name='slug',
|
||||
field=django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='zoommeeting',
|
||||
name='slug',
|
||||
field=django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='jobposting',
|
||||
name='created_at',
|
||||
field=models.DateTimeField(auto_now_add=True, verbose_name='Created at'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='jobposting',
|
||||
name='updated_at',
|
||||
field=models.DateTimeField(auto_now=True, verbose_name='Updated at'),
|
||||
),
|
||||
]
|
||||
17
recruitment/migrations/0010_remove_candidate_name.py
Normal file
@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-02 15:16
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0009_candidate_slug_job_slug_jobposting_slug_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='candidate',
|
||||
name='name',
|
||||
),
|
||||
]
|
||||
18
recruitment/migrations/0011_alter_candidate_stage.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-02 16:00
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0010_remove_candidate_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='candidate',
|
||||
name='stage',
|
||||
field=models.CharField(choices=[('Applied', 'Applied'), ('Exam', 'Exam'), ('Interview', 'Interview'), ('Offer', 'Offer')], default='Applied', max_length=100, verbose_name='Stage'),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,57 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-04 12:39
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0011_alter_candidate_stage'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Form',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=200)),
|
||||
('description', models.TextField(blank=True)),
|
||||
('structure', models.JSONField(default=dict)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='FormSubmission',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('submission_data', models.JSONField(default=dict)),
|
||||
('submitted_at', models.DateTimeField(auto_now_add=True)),
|
||||
('ip_address', models.GenericIPAddressField(blank=True, null=True)),
|
||||
('user_agent', models.TextField(blank=True)),
|
||||
('form', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='submissions', to='recruitment.form')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-submitted_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='UploadedFile',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('field_id', models.CharField(max_length=100)),
|
||||
('file', models.FileField(upload_to='form_uploads/%Y/%m/%d/')),
|
||||
('original_filename', models.CharField(max_length=255)),
|
||||
('uploaded_at', models.DateTimeField(auto_now_add=True)),
|
||||
('submission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to='recruitment.formsubmission')),
|
||||
],
|
||||
),
|
||||
]
|
||||
@ -1,76 +1,315 @@
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from .validators import validate_hash_tags
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.validators import URLValidator
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_extensions.db.fields import RandomCharField
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
class Base(models.Model):
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('Created at'))
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name=_('Updated at'))
|
||||
slug = RandomCharField(length=8, unique=True, editable=False, verbose_name=_('Slug'))
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
# Create your models here.
|
||||
class Job(models.Model):
|
||||
title = models.CharField(max_length=255)
|
||||
description_en = models.TextField()
|
||||
description_ar = models.TextField()
|
||||
is_published = models.BooleanField(default=False)
|
||||
posted_to_linkedin = models.BooleanField(default=False)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
class Job(Base):
|
||||
title = models.CharField(max_length=255, verbose_name=_('Title'))
|
||||
description_en = models.TextField(verbose_name=_('Description English'))
|
||||
description_ar = models.TextField(verbose_name=_('Description Arabic'))
|
||||
is_published = models.BooleanField(default=False, verbose_name=_('Published'))
|
||||
posted_to_linkedin = models.BooleanField(default=False, verbose_name=_('Posted to LinkedIn'))
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('Created at'))
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name=_('Updated at'))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Job')
|
||||
verbose_name_plural = _('Jobs')
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
class JobPosting(Base):
|
||||
# Basic Job Information
|
||||
JOB_TYPES = [
|
||||
('FULL_TIME', 'Full-time'),
|
||||
('PART_TIME', 'Part-time'),
|
||||
('CONTRACT', 'Contract'),
|
||||
('INTERNSHIP', 'Internship'),
|
||||
('FACULTY', 'Faculty'),
|
||||
('TEMPORARY', 'Temporary'),
|
||||
]
|
||||
|
||||
WORKPLACE_TYPES = [
|
||||
('ON_SITE', 'On-site'),
|
||||
('REMOTE', 'Remote'),
|
||||
('HYBRID', 'Hybrid'),
|
||||
]
|
||||
|
||||
# Core Fields
|
||||
title = models.CharField(max_length=200)
|
||||
department = models.CharField(max_length=100, blank=True)
|
||||
job_type = models.CharField(max_length=20, choices=JOB_TYPES, default='FULL_TIME')
|
||||
workplace_type = models.CharField(max_length=20, choices=WORKPLACE_TYPES, default='ON_SITE')
|
||||
|
||||
|
||||
# Location
|
||||
location_city = models.CharField(max_length=100, blank=True)
|
||||
location_state = models.CharField(max_length=100, blank=True)
|
||||
location_country = models.CharField(max_length=100, default='United States')
|
||||
|
||||
# Job Details
|
||||
description = models.TextField(help_text="Full job description including responsibilities and requirements")
|
||||
qualifications = models.TextField(blank=True, help_text="Required qualifications and skills")
|
||||
salary_range = models.CharField(max_length=200, blank=True, help_text="e.g., $60,000 - $80,000")
|
||||
benefits = models.TextField(blank=True, help_text="Benefits offered")
|
||||
|
||||
|
||||
# Application Information
|
||||
application_url = models.URLField(validators=[URLValidator()], help_text="URL where candidates apply")
|
||||
application_deadline = models.DateField(null=True, blank=True)
|
||||
application_instructions = models.TextField(blank=True, help_text="Special instructions for applicants")
|
||||
|
||||
# Internal Tracking
|
||||
internal_job_id = models.CharField(max_length=50, primary_key=True, editable=False)
|
||||
created_by = models.CharField(max_length=100, blank=True, help_text="Name of person who created this job")
|
||||
|
||||
# Status Fields
|
||||
STATUS_CHOICES = [
|
||||
('DRAFT', 'Draft'),
|
||||
('PUBLISHED', 'Published'),
|
||||
('CLOSED', 'Closed'),
|
||||
('ARCHIVED', 'Archived'),
|
||||
]
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='DRAFT',null=True, blank=True)
|
||||
|
||||
#hashtags for social media
|
||||
hash_tags = models.CharField(max_length=200, blank=True, help_text="Comma-separated hashtags for linkedin post like #hiring,#jobopening",validators=[validate_hash_tags])
|
||||
|
||||
# LinkedIn Integration Fields
|
||||
linkedin_post_id = models.CharField(max_length=200, blank=True, help_text="LinkedIn post ID after posting")
|
||||
linkedin_post_url = models.URLField(blank=True, help_text="Direct URL to LinkedIn post")
|
||||
posted_to_linkedin = models.BooleanField(default=False)
|
||||
linkedin_post_status = models.CharField(max_length=50, blank=True, help_text="Status of LinkedIn posting")
|
||||
linkedin_posted_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
published_at = models.DateTimeField(null=True, blank=True)
|
||||
# University Specific Fields
|
||||
position_number = models.CharField(max_length=50, blank=True, help_text="University position number")
|
||||
reporting_to = models.CharField(max_length=100, blank=True, help_text="Who this position reports to")
|
||||
start_date = models.DateField(null=True, blank=True, help_text="Desired start date")
|
||||
open_positions = models.PositiveIntegerField(default=1, help_text="Number of open positions for this job")
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
verbose_name = "Job Posting"
|
||||
verbose_name_plural = "Job Postings"
|
||||
|
||||
class Candidate(models.Model):
|
||||
job = models.ForeignKey(Job, on_delete=models.CASCADE, related_name='candidates')
|
||||
name = models.CharField(max_length=255)
|
||||
email = models.EmailField()
|
||||
resume = models.FileField(upload_to='resumes/')
|
||||
parsed_summary = models.TextField(blank=True)
|
||||
applied = models.BooleanField(default=False)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
def __str__(self):
|
||||
return f"{self.title} - {self.get_status_display()}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Generate unique internal job ID if not exists
|
||||
if not self.internal_job_id:
|
||||
prefix = "UNIV"
|
||||
year = timezone.now().year
|
||||
# Get next sequential number
|
||||
last_job = JobPosting.objects.filter(
|
||||
internal_job_id__startswith=f"{prefix}-{year}-"
|
||||
).order_by('internal_job_id').last()
|
||||
|
||||
if last_job:
|
||||
last_num = int(last_job.internal_job_id.split('-')[-1])
|
||||
next_num = last_num + 1
|
||||
else:
|
||||
next_num = 1
|
||||
|
||||
self.internal_job_id = f"{prefix}-{year}-{next_num:04d}"
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def get_location_display(self):
|
||||
"""Return formatted location string"""
|
||||
parts = []
|
||||
if self.location_city:
|
||||
parts.append(self.location_city)
|
||||
if self.location_state:
|
||||
parts.append(self.location_state)
|
||||
if self.location_country and self.location_country != 'United States':
|
||||
parts.append(self.location_country)
|
||||
return ', '.join(parts) if parts else 'Not specified'
|
||||
|
||||
def is_expired(self):
|
||||
"""Check if application deadline has passed"""
|
||||
if self.application_deadline:
|
||||
return self.application_deadline < timezone.now().date()
|
||||
return False
|
||||
|
||||
|
||||
class Candidate(Base):
|
||||
class Stage(models.TextChoices):
|
||||
APPLIED = 'Applied', _('Applied')
|
||||
EXAM = 'Exam', _('Exam')
|
||||
INTERVIEW = 'Interview', _('Interview')
|
||||
OFFER = 'Offer', _('Offer')
|
||||
class ExamStatus(models.TextChoices):
|
||||
PASSED = 'Passed', _('Passed')
|
||||
FAILED = 'Failed', _('Failed')
|
||||
class Status(models.TextChoices):
|
||||
ACCEPTED = 'Accepted', _('Accepted')
|
||||
REJECTED = 'Rejected', _('Rejected')
|
||||
|
||||
# Stage transition validation constants
|
||||
STAGE_SEQUENCE = {
|
||||
'Applied': ['Exam', 'Interview', 'Offer'],
|
||||
'Exam': ['Interview', 'Offer'],
|
||||
'Interview': ['Offer'],
|
||||
'Offer': [] # Final stage - no further transitions
|
||||
}
|
||||
|
||||
job = models.ForeignKey(JobPosting, on_delete=models.CASCADE, related_name='candidates', verbose_name=_('Job'))
|
||||
first_name = models.CharField(max_length=255, verbose_name=_('First Name'))
|
||||
last_name = models.CharField(max_length=255, verbose_name=_('Last Name'))
|
||||
email = models.EmailField(verbose_name=_('Email'))
|
||||
phone = models.CharField(max_length=20, verbose_name=_('Phone'))
|
||||
resume = models.FileField(upload_to='resumes/', verbose_name=_('Resume'))
|
||||
parsed_summary = models.TextField(blank=True, verbose_name=_('Parsed Summary'))
|
||||
applied = models.BooleanField(default=False, verbose_name=_('Applied'))
|
||||
stage = models.CharField(max_length=100, default='Applied', choices=Stage.choices, verbose_name=_('Stage'))
|
||||
|
||||
exam_date = models.DateField(null=True, blank=True, verbose_name=_('Exam Date'))
|
||||
exam_status = models.CharField(choices=ExamStatus.choices,max_length=100, null=True, blank=True, verbose_name=_('Exam Status'))
|
||||
interview_date = models.DateField(null=True, blank=True, verbose_name=_('Interview Date'))
|
||||
interview_status = models.CharField(choices=Status.choices,max_length=100, null=True, blank=True, verbose_name=_('Interview Status'))
|
||||
offer_date = models.DateField(null=True, blank=True, verbose_name=_('Offer Date'))
|
||||
offer_status = models.CharField(choices=Status.choices,max_length=100, null=True, blank=True, verbose_name=_('Offer Status'))
|
||||
join_date = models.DateField(null=True, blank=True, verbose_name=_('Join Date'))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Candidate')
|
||||
verbose_name_plural = _('Candidates')
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return f"{self.first_name} {self.last_name}"
|
||||
@property
|
||||
def full_name(self):
|
||||
return self.name
|
||||
|
||||
def clean(self):
|
||||
"""Validate stage transitions"""
|
||||
# Only validate if this is an existing record (not being created)
|
||||
if self.pk and self.stage != self.__class__.objects.get(pk=self.pk).stage:
|
||||
old_stage = self.__class__.objects.get(pk=self.pk).stage
|
||||
allowed_next_stages = self.STAGE_SEQUENCE.get(old_stage, [])
|
||||
|
||||
if self.stage not in allowed_next_stages:
|
||||
raise ValidationError({
|
||||
'stage': f'Cannot transition from "{old_stage}" to "{self.stage}". '
|
||||
f'Allowed transitions: {", ".join(allowed_next_stages) or "None (final stage)"}'
|
||||
})
|
||||
|
||||
# Validate that the stage is a valid choice
|
||||
if self.stage not in [choice[0] for choice in self.Stage.choices]:
|
||||
raise ValidationError({
|
||||
'stage': f'Invalid stage. Must be one of: {", ".join(choice[0] for choice in self.Stage.choices)}'
|
||||
})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Override save to ensure validation is called"""
|
||||
self.clean() # Call validation before saving
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def can_transition_to(self, new_stage):
|
||||
"""Check if a stage transition is allowed"""
|
||||
if not self.pk: # New record - can be in Applied stage
|
||||
return new_stage == 'Applied'
|
||||
|
||||
old_stage = self.__class__.objects.get(pk=self.pk).stage
|
||||
allowed_next_stages = self.STAGE_SEQUENCE.get(old_stage, [])
|
||||
return new_stage in allowed_next_stages
|
||||
|
||||
def get_available_stages(self):
|
||||
"""Get list of stages this candidate can transition to"""
|
||||
if not self.pk: # New record
|
||||
return ['Applied']
|
||||
|
||||
old_stage = self.__class__.objects.get(pk=self.pk).stage
|
||||
return self.STAGE_SEQUENCE.get(old_stage, [])
|
||||
|
||||
def __str__(self):
|
||||
return self.full_name
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class TrainingMaterial(Base):
|
||||
title = models.CharField(max_length=255, verbose_name=_('Title'))
|
||||
content = models.TextField(blank=True, verbose_name=_('Content'))
|
||||
video_link = models.URLField(blank=True, verbose_name=_('Video Link'))
|
||||
file = models.FileField(upload_to='training_materials/', blank=True, verbose_name=_('File'))
|
||||
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, verbose_name=_('Created by'))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Training Material')
|
||||
verbose_name_plural = _('Training Materials')
|
||||
|
||||
class TrainingMaterial(models.Model):
|
||||
title = models.CharField(max_length=255)
|
||||
content = models.TextField(blank=True)
|
||||
video_link = models.URLField(blank=True)
|
||||
file = models.FileField(upload_to='training_materials/', blank=True)
|
||||
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
|
||||
class ZoomMeeting(models.Model):
|
||||
class ZoomMeeting(Base):
|
||||
# Basic meeting details
|
||||
topic = models.CharField(max_length=255)
|
||||
meeting_id = models.CharField(max_length=20, unique=True) # Unique identifier for the meeting
|
||||
start_time = models.DateTimeField()
|
||||
duration = models.PositiveIntegerField() # Duration in minutes
|
||||
timezone = models.CharField(max_length=50)
|
||||
join_url = models.URLField() # URL for participants to join
|
||||
password = models.CharField(max_length=50, blank=True, null=True)
|
||||
topic = models.CharField(max_length=255, verbose_name=_('Topic'))
|
||||
meeting_id = models.CharField(max_length=20, unique=True, verbose_name=_('Meeting ID')) # Unique identifier for the meeting
|
||||
start_time = models.DateTimeField(verbose_name=_('Start Time'))
|
||||
duration = models.PositiveIntegerField(verbose_name=_('Duration')) # Duration in minutes
|
||||
timezone = models.CharField(max_length=50, verbose_name=_('Timezone'))
|
||||
join_url = models.URLField(verbose_name=_('Join URL')) # URL for participants to join
|
||||
participant_video = models.BooleanField(default=True, verbose_name=_('Participant Video'))
|
||||
join_before_host = models.BooleanField(default=False, verbose_name=_('Join Before Host'))
|
||||
mute_upon_entry = models.BooleanField(default=False, verbose_name=_('Mute Upon Entry'))
|
||||
waiting_room = models.BooleanField(default=False, verbose_name=_('Waiting Room'))
|
||||
|
||||
# Host information
|
||||
host_email = models.EmailField()
|
||||
|
||||
# Status
|
||||
STATUS_CHOICES = [
|
||||
('waiting', 'Waiting'),
|
||||
('started', 'Started'),
|
||||
('ended', 'Ended'),
|
||||
]
|
||||
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='waiting')
|
||||
|
||||
# Settings
|
||||
host_video = models.BooleanField(default=True)
|
||||
participant_video = models.BooleanField(default=True)
|
||||
join_before_host = models.BooleanField(default=False)
|
||||
mute_upon_entry = models.BooleanField(default=False)
|
||||
waiting_room = models.BooleanField(default=False)
|
||||
|
||||
zoom_gateway_response = models.JSONField(blank=True, null=True)
|
||||
zoom_gateway_response = models.JSONField(blank=True, null=True, verbose_name=_('Zoom Gateway Response'))
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.topic
|
||||
return self.topic
|
||||
|
||||
|
||||
class Form(models.Model):
|
||||
title = models.CharField(max_length=200)
|
||||
description = models.TextField(blank=True)
|
||||
structure = models.JSONField(default=dict) # Stores the form schema
|
||||
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
class FormSubmission(models.Model):
|
||||
form = models.ForeignKey(Form, on_delete=models.CASCADE, related_name='submissions')
|
||||
submission_data = models.JSONField(default=dict) # Stores form responses
|
||||
submitted_at = models.DateTimeField(auto_now_add=True)
|
||||
ip_address = models.GenericIPAddressField(null=True, blank=True)
|
||||
user_agent = models.TextField(blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-submitted_at']
|
||||
|
||||
class UploadedFile(models.Model):
|
||||
submission = models.ForeignKey(FormSubmission, on_delete=models.CASCADE, related_name='files')
|
||||
field_id = models.CharField(max_length=100)
|
||||
file = models.FileField(upload_to='form_uploads/%Y/%m/%d/')
|
||||
original_filename = models.CharField(max_length=255)
|
||||
uploaded_at = models.DateTimeField(auto_now_add=True)
|
||||
@ -1,12 +1,14 @@
|
||||
from rest_framework import serializers
|
||||
from . import models
|
||||
from .models import JobPosting, Candidate
|
||||
|
||||
class JobSerializer(serializers.ModelSerializer):
|
||||
class JobPostingSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = models.Job
|
||||
model = JobPosting
|
||||
fields = '__all__'
|
||||
|
||||
class CandidateSerializer(serializers.ModelSerializer):
|
||||
job_title = serializers.CharField(source='job.title', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.Candidate
|
||||
fields = '__all__'
|
||||
model = Candidate
|
||||
fields = '__all__'
|
||||
|
||||
@ -3,17 +3,60 @@ from . import views_frontend
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path('careers/', views_frontend.job_list, name='job_list'),
|
||||
path('careers/<int:job_id>/', views_frontend.job_detail, name='job_detail'),
|
||||
path('training/', views_frontend.training_list, name='training_list'),
|
||||
path('candidate/<int:candidate_id>/view/', views_frontend.candidate_detail, name='candidate_detail'),
|
||||
path('dashboard/', views_frontend.dashboard_view, name='dashboard'),
|
||||
|
||||
# Job URLs (using JobPosting model)
|
||||
path('jobs/', views_frontend.JobListView.as_view(), name='job_list'),
|
||||
path('jobs/create/', views.create_job, name='job_create'),
|
||||
path('jobs/<slug:slug>/update/', views.edit_job, name='job_update'),
|
||||
# path('jobs/<slug:slug>/delete/', views., name='job_delete'),
|
||||
path('jobs/<slug:slug>/', views.job_detail, name='job_detail'),
|
||||
|
||||
# LinkedIn Integration URLs
|
||||
path('jobs/<slug:slug>/post-to-linkedin/', views.post_to_linkedin, name='post_to_linkedin'),
|
||||
path('jobs/linkedin/login/', views.linkedin_login, name='linkedin_login'),
|
||||
path('jobs/linkedin/callback/', views.linkedin_callback, name='linkedin_callback'),
|
||||
|
||||
# Candidate URLs
|
||||
path('candidates/', views_frontend.CandidateListView.as_view(), name='candidate_list'),
|
||||
path('candidates/create/', views_frontend.CandidateCreateView.as_view(), name='candidate_create'),
|
||||
path('candidates/create/<slug:slug>/', views_frontend.CandidateCreateView.as_view(), name='candidate_create_for_job'),
|
||||
path('jobs/<slug:slug>/candidates/', views_frontend.JobCandidatesListView.as_view(), name='job_candidates_list'),
|
||||
path('candidates/<slug:slug>/update/', views_frontend.CandidateUpdateView.as_view(), name='candidate_update'),
|
||||
path('candidates/<slug:slug>/delete/', views_frontend.CandidateDeleteView.as_view(), name='candidate_delete'),
|
||||
path('candidate/<slug:slug>/view/', views_frontend.candidate_detail, name='candidate_detail'),
|
||||
path('candidate/<slug:slug>/update-stage/', views_frontend.candidate_update_stage, name='candidate_update_stage'),
|
||||
|
||||
# Training URLs
|
||||
path('training/', views_frontend.TrainingListView.as_view(), name='training_list'),
|
||||
path('training/create/', views_frontend.TrainingCreateView.as_view(), name='training_create'),
|
||||
path('training/<slug:slug>/', views_frontend.TrainingDetailView.as_view(), name='training_detail'),
|
||||
path('training/<slug:slug>/update/', views_frontend.TrainingUpdateView.as_view(), name='training_update'),
|
||||
path('training/<slug:slug>/delete/', views_frontend.TrainingDeleteView.as_view(), name='training_delete'),
|
||||
|
||||
# Meeting URLs
|
||||
path('', views.ZoomMeetingListView.as_view(), name='list_meetings'),
|
||||
path('create-meeting/', views.ZoomMeetingCreateView.as_view(), name='create_meeting'),
|
||||
path('meeting-details/<int:pk>/', views.ZoomMeetingDetailsView.as_view(), name='meeting_details'),
|
||||
path('update-meeting/<int:pk>/', views.ZoomMeetingUpdateView.as_view(), name='update_meeting'),
|
||||
path('delete-meeting/<int:pk>/', views.ZoomMeetingDeleteView, name='delete_meeting'),
|
||||
path('meeting-details/<slug:slug>/', views.ZoomMeetingDetailsView.as_view(), name='meeting_details'),
|
||||
path('update-meeting/<slug:slug>/', views.ZoomMeetingUpdateView.as_view(), name='update_meeting'),
|
||||
path('delete-meeting/<slug:slug>/', views.ZoomMeetingDeleteView, name='delete_meeting'),
|
||||
|
||||
# JobPosting functional views URLs (keeping for compatibility)
|
||||
path('api/create/', views.create_job, name='create_job_api'),
|
||||
path('api/<slug:slug>/edit/', views.edit_job, name='edit_job_api'),
|
||||
|
||||
#
|
||||
path('form_builder/', views.form_builder, name='form_builder'),
|
||||
|
||||
# Form Preview URLs
|
||||
path('forms/', views.form_list, name='form_list'),
|
||||
path('forms/<int:form_id>/', views.form_preview, name='form_preview'),
|
||||
path('forms/<int:form_id>/submit/', views.form_submit, name='form_submit'),
|
||||
path('forms/<int:form_id>/embed/', views.form_embed, name='form_embed'),
|
||||
path('forms/<int:form_id>/submissions/', views.form_submissions, name='form_submissions'),
|
||||
path('forms/<int:form_id>/edit/', views.edit_form, name='edit_form'),
|
||||
path('api/forms/save/', views.save_form_builder, name='save_form_builder'),
|
||||
path('api/forms/<int:form_id>/load/', views.load_form, name='load_form'),
|
||||
path('api/forms/<int:form_id>/update/', views.update_form_builder, name='update_form_builder'),
|
||||
|
||||
]
|
||||
|
||||
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import os
|
||||
import fitz # PyMuPDF
|
||||
import spacy
|
||||
import os
|
||||
import requests
|
||||
from recruitment import models
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
nlp = spacy.load("en_core_web_sm")
|
||||
|
||||
|
||||
15
recruitment/validators.py
Normal file
@ -0,0 +1,15 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
def validate_image_size(image):
|
||||
max_size_mb = 2
|
||||
if image.size > max_size_mb * 1024 * 1024:
|
||||
raise ValidationError(f"Image size should not exceed {max_size_mb}MB.")
|
||||
|
||||
def validate_hash_tags(value):
|
||||
if value:
|
||||
tags = [tag.strip() for tag in value.split(',')]
|
||||
for tag in tags:
|
||||
if ' ' in tag:
|
||||
raise ValidationError("Hash tags should not contain spaces.")
|
||||
if not tag.startswith('#'):
|
||||
raise ValidationError("Each hash tag should start with '#' symbol.")
|
||||
@ -1,23 +1,32 @@
|
||||
import json
|
||||
import requests
|
||||
from django.views import View
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from django.http import JsonResponse
|
||||
from recruitment.models import FormSubmission,Form,UploadedFile
|
||||
from datetime import datetime
|
||||
from django.views import View
|
||||
from django.db.models import Q
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from .forms import ZoomMeetingForm
|
||||
from .forms import ZoomMeetingForm,JobPostingForm
|
||||
from rest_framework import viewsets
|
||||
from django.contrib import messages
|
||||
from .models import ZoomMeeting, Job, Candidate
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.http import HttpResponseRedirect, JsonResponse
|
||||
from .serializers import JobSerializer, CandidateSerializer
|
||||
from django.core.paginator import Paginator
|
||||
from .linkedin_service import LinkedInService
|
||||
from .models import ZoomMeeting, Job, Candidate, JobPosting
|
||||
from .serializers import JobPostingSerializer, CandidateSerializer
|
||||
from django.shortcuts import get_object_or_404, render, redirect
|
||||
from django.views.generic import CreateView,UpdateView,DetailView,ListView
|
||||
from .utils import create_zoom_meeting, delete_zoom_meeting, list_zoom_meetings, get_zoom_meeting_details, update_zoom_meeting
|
||||
from .utils import create_zoom_meeting, delete_zoom_meeting, update_zoom_meeting
|
||||
|
||||
class JobViewSet(viewsets.ModelViewSet):
|
||||
queryset = Job.objects.all()
|
||||
serializer_class = JobSerializer
|
||||
import logging
|
||||
logger=logging.getLogger(__name__)
|
||||
|
||||
|
||||
class JobPostingViewSet(viewsets.ModelViewSet):
|
||||
queryset = JobPosting.objects.all()
|
||||
serializer_class = JobPostingSerializer
|
||||
|
||||
class CandidateViewSet(viewsets.ModelViewSet):
|
||||
queryset = Candidate.objects.all()
|
||||
@ -61,6 +70,26 @@ class ZoomMeetingListView(ListView):
|
||||
model = ZoomMeeting
|
||||
template_name = 'meetings/list_meetings.html'
|
||||
context_object_name = 'meetings'
|
||||
paginate_by = 10
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset().order_by('-start_time')
|
||||
|
||||
# Handle search
|
||||
search_query = self.request.GET.get('search', '')
|
||||
if search_query:
|
||||
queryset = queryset.filter(
|
||||
Q(topic__icontains=search_query) |
|
||||
Q(meeting_id__icontains=search_query) |
|
||||
Q(host_email__icontains=search_query)
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['search_query'] = self.request.GET.get('search', '')
|
||||
return context
|
||||
|
||||
class ZoomMeetingDetailsView(DetailView):
|
||||
model = ZoomMeeting
|
||||
@ -108,3 +137,441 @@ def ZoomMeetingDeleteView(request, pk):
|
||||
except Exception as e:
|
||||
messages.error(request, str(e))
|
||||
return redirect('/')
|
||||
|
||||
|
||||
#Job Posting
|
||||
def job_list(request):
|
||||
"""Display the list of job postings order by creation date descending"""
|
||||
jobs=JobPosting.objects.all().order_by('-created_at')
|
||||
|
||||
# Filter by status if provided
|
||||
status=request.GET.get('status')
|
||||
if status:
|
||||
jobs=jobs.filter(status=status)
|
||||
|
||||
#pagination
|
||||
paginator=Paginator(jobs,10) # Show 10 jobs per page
|
||||
page_number=request.GET.get('page')
|
||||
page_obj=paginator.get_page(page_number)
|
||||
return render(request, 'jobs/job_list.html', {
|
||||
'page_obj': page_obj,
|
||||
'status_filter': status
|
||||
})
|
||||
|
||||
|
||||
def create_job(request):
|
||||
"""Create a new job posting"""
|
||||
if request.method=='POST':
|
||||
|
||||
form=JobPostingForm(request.POST,is_anonymous_user=not request.user.is_authenticated)
|
||||
#to check user is authenticated or not
|
||||
if form.is_valid():
|
||||
try:
|
||||
job=form.save(commit=False)
|
||||
if request.user.is_authenticated:
|
||||
job.created_by=request.user.get_full_name() or request.user.username
|
||||
else:
|
||||
job.created_by=request.POST.get('created_by','').strip()
|
||||
if not job.created_by:
|
||||
job.created_by="University Administrator"
|
||||
job.save()
|
||||
messages.success(request,f'Job "{job.title}" created successfully!')
|
||||
return redirect('job_list')
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating job: {e}")
|
||||
messages.error(request,f"Error creating job: {e}")
|
||||
else:
|
||||
messages.error(request, f'Please correct the errors below.{form.errors}')
|
||||
else:
|
||||
form=JobPostingForm(is_anonymous_user=not request.user.is_authenticated)
|
||||
return render(request,'jobs/create_job.html',{'form':form})
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def edit_job(request,slug):
|
||||
"""Edit an existing job posting"""
|
||||
if request.method=='POST':
|
||||
job=get_object_or_404(JobPosting,slug=slug)
|
||||
form=JobPostingForm(request.POST,instance=job,is_anonymous_user=not request.user.is_authenticated)
|
||||
if form.is_valid():
|
||||
try:
|
||||
job=form.save(commit=False)
|
||||
if request.user.is_authenticated:
|
||||
job.created_by=request.user.get_full_name() or request.user.username
|
||||
else:
|
||||
job.created_by=request.POST.get('created_by','').strip()
|
||||
if not job.created_by:
|
||||
job.created_by="University Administrator"
|
||||
job.save()
|
||||
messages.success(request,f'Job "{job.title}" updated successfully!')
|
||||
return redirect('job_list')
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating job: {e}")
|
||||
messages.error(request,f"Error updating job: {e}")
|
||||
else:
|
||||
messages.error(request, 'Please correct the errors below.')
|
||||
else:
|
||||
job=get_object_or_404(JobPosting,slug=slug)
|
||||
form=JobPostingForm(instance=job,is_anonymous_user=not request.user.is_authenticated)
|
||||
return render(request,'jobs/edit_job.html',{'form':form,'job':job})
|
||||
|
||||
def job_detail(request, slug):
|
||||
"""View details of a specific job"""
|
||||
job = get_object_or_404(JobPosting, slug=slug)
|
||||
|
||||
# Get all candidates for this job, ordered by most recent
|
||||
candidates = job.candidates.all().order_by('-created_at')
|
||||
|
||||
# Count candidates by stage for summary statistics
|
||||
total_candidates = candidates.count()
|
||||
applied_count = candidates.filter(stage='Applied').count()
|
||||
interview_count = candidates.filter(stage='Interview').count()
|
||||
offer_count = candidates.filter(stage='Offer').count()
|
||||
|
||||
context = {
|
||||
'job': job,
|
||||
'candidates': candidates,
|
||||
'total_candidates': total_candidates,
|
||||
'applied_count': applied_count,
|
||||
'interview_count': interview_count,
|
||||
'offer_count': offer_count,
|
||||
}
|
||||
return render(request, 'jobs/job_detail.html', context)
|
||||
\
|
||||
|
||||
def post_to_linkedin(request,slug):
|
||||
"""Post a job to LinkedIn"""
|
||||
job=get_object_or_404(JobPosting,slug=slug)
|
||||
if job.status!='ACTIVE':
|
||||
messages.info(request,'Only active jobs can be posted to LinkedIn.')
|
||||
return redirect('job_list')
|
||||
|
||||
if request.method=='POST':
|
||||
try:
|
||||
# Check if user is authenticated with LinkedIn
|
||||
if 'linkedin_access_token' not in request.session:
|
||||
messages.error(request,'Please authenticate with LinkedIn first.')
|
||||
return redirect('linkedin_login')
|
||||
|
||||
# Clear previous LinkedIn data for re-posting
|
||||
job.posted_to_linkedin=False
|
||||
job.linkedin_post_id=''
|
||||
job.linkedin_post_url=''
|
||||
job.linkedin_post_status=''
|
||||
job.linkedin_posted_at=None
|
||||
job.save()
|
||||
|
||||
# Initialize LinkedIn service
|
||||
service=LinkedInService()
|
||||
service.access_token=request.session['linkedin_access_token']
|
||||
|
||||
# Post to LinkedIn
|
||||
result=service.create_job_post(job)
|
||||
if result['success']:
|
||||
# Update job with LinkedIn info
|
||||
job.posted_to_linkedin=True
|
||||
job.linkedin_post_id=result['post_id']
|
||||
job.linkedin_post_url=result['post_url']
|
||||
job.linkedin_post_status='SUCCESS'
|
||||
job.linkedin_posted_at=timezone.now()
|
||||
job.save()
|
||||
|
||||
messages.success(request,'Job posted to LinkedIn successfully!')
|
||||
else:
|
||||
error_msg=result.get('error','Unknown error')
|
||||
job.linkedin_post_status=f'ERROR: {error_msg}'
|
||||
job.save()
|
||||
messages.error(request,f'Error posting to LinkedIn: {error_msg}')
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in post_to_linkedin: {e}")
|
||||
job.linkedin_post_status = f'ERROR: {str(e)}'
|
||||
job.save()
|
||||
messages.error(request, f'Error posting to LinkedIn: {e}')
|
||||
|
||||
return redirect('job_detail', slug=job.slug)
|
||||
|
||||
def linkedin_login(request):
|
||||
"""Redirect to LinkedIn OAuth"""
|
||||
service=LinkedInService()
|
||||
auth_url=service.get_auth_url()
|
||||
"""
|
||||
It creates a special URL that:
|
||||
Sends the user to LinkedIn to log in
|
||||
Asks the user to grant your app permission to post on their behalf
|
||||
Tells LinkedIn where to send the user back after they approve (your redirect_uri)
|
||||
http://yoursite.com/linkedin/callback/?code=TEMPORARY_CODE_HERE
|
||||
"""
|
||||
return redirect(auth_url)
|
||||
|
||||
|
||||
def linkedin_callback(request):
|
||||
"""Handle LinkedIn OAuth callback"""
|
||||
code=request.GET.get('code')
|
||||
if not code:
|
||||
messages.error(request,'No authorization code received from LinkedIn.')
|
||||
return redirect('job_list')
|
||||
|
||||
try:
|
||||
service=LinkedInService()
|
||||
#get_access_token(code)->It makes a POST request to LinkedIn’s token endpoint with parameters
|
||||
access_token=service.get_access_token(code)
|
||||
request.session['linkedin_access_token']=access_token
|
||||
request.session['linkedin_authenticated']=True
|
||||
messages.success(request,'Successfully authenticated with LinkedIn!')
|
||||
except Exception as e:
|
||||
logger.error(f"LinkedIn authentication error: {e}")
|
||||
messages.error(request,f'LinkedIn authentication failed: {e}')
|
||||
|
||||
return redirect('job_list')
|
||||
|
||||
|
||||
#applicant views
|
||||
def applicant_job_detail(request,slug):
|
||||
"""View job details for applicants"""
|
||||
job=get_object_or_404(JobPosting,slug=slug,status='ACTIVE')
|
||||
return render(request,'jobs/applicant_job_detail.html',{'job':job})
|
||||
|
||||
def form_builder(request):
|
||||
return render(request,'form_builder.html')
|
||||
|
||||
|
||||
# Form Preview Views
|
||||
from django.http import JsonResponse
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.core.paginator import Paginator
|
||||
from django.contrib.auth.decorators import login_required
|
||||
import json
|
||||
|
||||
def form_list(request):
|
||||
"""Display list of all available forms"""
|
||||
forms = Form.objects.filter(is_active=True).order_by('-created_at')
|
||||
|
||||
# Pagination
|
||||
paginator = Paginator(forms, 12)
|
||||
page_number = request.GET.get('page')
|
||||
page_obj = paginator.get_page(page_number)
|
||||
|
||||
return render(request, 'forms/form_list.html', {
|
||||
'page_obj': page_obj
|
||||
})
|
||||
|
||||
def form_preview(request, form_id):
|
||||
"""Display form preview for end users"""
|
||||
form = get_object_or_404(Form, id=form_id, is_active=True)
|
||||
|
||||
# Get submission count for analytics
|
||||
submission_count = form.submissions.count()
|
||||
|
||||
return render(request, 'forms/form_preview.html', {
|
||||
'form': form,
|
||||
'submission_count': submission_count,
|
||||
'is_embed': request.GET.get('embed', 'false') == 'true'
|
||||
})
|
||||
|
||||
@csrf_exempt
|
||||
def form_submit(request, form_id):
|
||||
"""Handle form submission via AJAX"""
|
||||
if request.method != 'POST':
|
||||
return JsonResponse({'success': False, 'error': 'Only POST method allowed'}, status=405)
|
||||
|
||||
form = get_object_or_404(Form, id=form_id, is_active=True)
|
||||
|
||||
try:
|
||||
# Parse form data
|
||||
submission_data = {}
|
||||
files = {}
|
||||
|
||||
# Process regular form fields
|
||||
for key, value in request.POST.items():
|
||||
if key != 'csrfmiddlewaretoken':
|
||||
submission_data[key] = value
|
||||
|
||||
# Process file uploads
|
||||
for key, file in request.FILES.items():
|
||||
if file:
|
||||
files[key] = file
|
||||
|
||||
# Create form submission
|
||||
submission = FormSubmission.objects.create(
|
||||
form=form,
|
||||
submission_data=submission_data,
|
||||
ip_address=request.META.get('REMOTE_ADDR'),
|
||||
user_agent=request.META.get('HTTP_USER_AGENT', '')
|
||||
)
|
||||
|
||||
# Handle file uploads
|
||||
for field_id, file in files.items():
|
||||
UploadedFile.objects.create(
|
||||
submission=submission,
|
||||
field_id=field_id,
|
||||
file=file,
|
||||
original_filename=file.name
|
||||
)
|
||||
|
||||
# TODO: Send email notification if configured
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'message': 'Form submitted successfully!',
|
||||
'submission_id': submission.id
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error submitting form {form_id}: {e}")
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': 'An error occurred while submitting the form. Please try again.'
|
||||
}, status=500)
|
||||
|
||||
def form_embed(request, form_id):
|
||||
"""Display embeddable version of form"""
|
||||
form = get_object_or_404(Form, id=form_id, is_active=True)
|
||||
|
||||
return render(request, 'forms/form_embed.html', {
|
||||
'form': form,
|
||||
'is_embed': True
|
||||
})
|
||||
|
||||
@login_required
|
||||
def save_form_builder(request):
|
||||
"""Save form from builder to database"""
|
||||
if request.method != 'POST':
|
||||
return JsonResponse({'success': False, 'error': 'Only POST method allowed'}, status=405)
|
||||
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
form_data = data.get('form', {})
|
||||
|
||||
# Check if this is an update or create
|
||||
form_id = data.get('form_id')
|
||||
|
||||
if form_id:
|
||||
# Update existing form
|
||||
form = Form.objects.get(id=form_id, created_by=request.user)
|
||||
form.title = form_data.get('title', 'Untitled Form')
|
||||
form.description = form_data.get('description', '')
|
||||
form.structure = form_data
|
||||
form.save()
|
||||
else:
|
||||
# Create new form
|
||||
form = Form.objects.create(
|
||||
title=form_data.get('title', 'Untitled Form'),
|
||||
description=form_data.get('description', ''),
|
||||
structure=form_data,
|
||||
created_by=request.user
|
||||
)
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'form_id': form.id,
|
||||
'message': 'Form saved successfully!'
|
||||
})
|
||||
|
||||
except json.JSONDecodeError:
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': 'Invalid JSON data'
|
||||
}, status=400)
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving form: {e}")
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': 'An error occurred while saving the form'
|
||||
}, status=500)
|
||||
|
||||
@login_required
|
||||
def load_form(request, form_id):
|
||||
"""Load form data for editing in builder"""
|
||||
try:
|
||||
form = get_object_or_404(Form, id=form_id, created_by=request.user)
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'form': {
|
||||
'id': form.id,
|
||||
'title': form.title,
|
||||
'description': form.description,
|
||||
'structure': form.structure
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading form {form_id}: {e}")
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': 'An error occurred while loading the form'
|
||||
}, status=500)
|
||||
|
||||
@csrf_exempt
|
||||
def update_form_builder(request, form_id):
|
||||
"""Update existing form from builder"""
|
||||
if request.method != 'POST':
|
||||
return JsonResponse({'success': False, 'error': 'Only POST method allowed'}, status=405)
|
||||
|
||||
try:
|
||||
form = get_object_or_404(Form, id=form_id)
|
||||
|
||||
# Check if user has permission to edit this form
|
||||
if form.created_by != request.user:
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': 'You do not have permission to edit this form'
|
||||
}, status=403)
|
||||
|
||||
data = json.loads(request.body)
|
||||
form_data = data.get('form', {})
|
||||
|
||||
# Update form
|
||||
form.title = form_data.get('title', 'Untitled Form')
|
||||
form.description = form_data.get('description', '')
|
||||
form.structure = form_data
|
||||
form.save()
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'form_id': form.id,
|
||||
'message': 'Form updated successfully!'
|
||||
})
|
||||
|
||||
except json.JSONDecodeError:
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': 'Invalid JSON data'
|
||||
}, status=400)
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating form {form_id}: {e}")
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': 'An error occurred while updating the form'
|
||||
}, status=500)
|
||||
|
||||
def edit_form(request, form_id):
|
||||
"""Display form edit page"""
|
||||
form = get_object_or_404(Form, id=form_id)
|
||||
|
||||
# Check if user has permission to edit this form
|
||||
if form.created_by != request.user:
|
||||
messages.error(request, 'You do not have permission to edit this form.')
|
||||
return redirect('form_list')
|
||||
|
||||
return render(request, 'forms/edit_form.html', {
|
||||
'form': form
|
||||
})
|
||||
|
||||
def form_submissions(request, form_id):
|
||||
"""View submissions for a specific form"""
|
||||
form = get_object_or_404(Form, id=form_id, created_by=request.user)
|
||||
submissions = form.submissions.all().order_by('-submitted_at')
|
||||
|
||||
# Pagination
|
||||
paginator = Paginator(submissions, 20)
|
||||
page_number = request.GET.get('page')
|
||||
page_obj = paginator.get_page(page_number)
|
||||
|
||||
return render(request, 'forms/form_submissions.html', {
|
||||
'form': form,
|
||||
'page_obj': page_obj
|
||||
})
|
||||
|
||||
@ -1,20 +1,192 @@
|
||||
from django.shortcuts import render, get_object_or_404
|
||||
from django.http import JsonResponse
|
||||
from . import models
|
||||
from django.utils.translation import get_language
|
||||
from . import forms
|
||||
from django.contrib.auth.decorators import login_required, user_passes_test
|
||||
from django.contrib.auth.decorators import login_required
|
||||
import ast
|
||||
from .dashboard import get_dashboard_data
|
||||
from django.template.loader import render_to_string
|
||||
# from .dashboard import get_dashboard_data
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.views.generic import ListView, CreateView, UpdateView, DeleteView, DetailView
|
||||
# JobForm removed - using JobPostingForm instead
|
||||
from django.urls import reverse_lazy
|
||||
from django.db.models import Q
|
||||
|
||||
def job_list(request):
|
||||
jobs = models.Job.objects.filter(is_published=True).order_by('-created_at')
|
||||
lang = get_language()
|
||||
return render(request, 'recruitment/job_list.html', {'jobs': jobs, 'lang': lang})
|
||||
from datastar_py.django import (
|
||||
DatastarResponse,
|
||||
ServerSentEventGenerator as SSE,
|
||||
read_signals,
|
||||
)
|
||||
|
||||
def job_detail(request, job_id):
|
||||
job = get_object_or_404(models.Job, id=job_id, is_published=True)
|
||||
class JobListView(LoginRequiredMixin, ListView):
|
||||
model = models.JobPosting
|
||||
template_name = 'jobs/job_list.html'
|
||||
context_object_name = 'jobs'
|
||||
paginate_by = 10
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset().order_by('-created_at')
|
||||
|
||||
# Handle search
|
||||
search_query = self.request.GET.get('search', '')
|
||||
if search_query:
|
||||
queryset = queryset.filter(
|
||||
Q(title__icontains=search_query) |
|
||||
Q(description__icontains=search_query) |
|
||||
Q(department__icontains=search_query)
|
||||
)
|
||||
|
||||
# Filter for non-staff users
|
||||
if not self.request.user.is_staff:
|
||||
queryset = queryset.filter(status='Published')
|
||||
|
||||
return queryset
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['search_query'] = self.request.GET.get('search', '')
|
||||
context['lang'] = get_language()
|
||||
return context
|
||||
|
||||
|
||||
class JobCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView):
|
||||
model = models.JobPosting
|
||||
form_class = forms.JobPostingForm
|
||||
template_name = 'jobs/create_job.html'
|
||||
success_url = reverse_lazy('job_list')
|
||||
success_message = 'Job created successfully.'
|
||||
|
||||
|
||||
class JobUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView):
|
||||
model = models.JobPosting
|
||||
form_class = forms.JobPostingForm
|
||||
template_name = 'jobs/edit_job.html'
|
||||
success_url = reverse_lazy('job_list')
|
||||
success_message = 'Job updated successfully.'
|
||||
slug_url_kwarg = 'slug'
|
||||
|
||||
|
||||
class JobDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView):
|
||||
model = models.JobPosting
|
||||
template_name = 'jobs/partials/delete_modal.html'
|
||||
success_url = reverse_lazy('job_list')
|
||||
success_message = 'Job deleted successfully.'
|
||||
slug_url_kwarg = 'slug'
|
||||
|
||||
class JobCandidatesListView(LoginRequiredMixin, ListView):
|
||||
model = models.Candidate
|
||||
template_name = 'jobs/job_candidates_list.html'
|
||||
context_object_name = 'candidates'
|
||||
paginate_by = 10
|
||||
|
||||
def get_queryset(self):
|
||||
# Get the job by slug
|
||||
self.job = get_object_or_404(models.JobPosting, slug=self.kwargs['slug'])
|
||||
|
||||
# Filter candidates for this specific job
|
||||
queryset = models.Candidate.objects.filter(job=self.job)
|
||||
|
||||
# Handle search
|
||||
search_query = self.request.GET.get('search', '')
|
||||
if search_query:
|
||||
queryset = queryset.filter(
|
||||
Q(first_name__icontains=search_query) |
|
||||
Q(last_name__icontains=search_query) |
|
||||
Q(email__icontains=search_query) |
|
||||
Q(phone__icontains=search_query) |
|
||||
Q(stage__icontains=search_query)
|
||||
)
|
||||
|
||||
# Filter for non-staff users
|
||||
if not self.request.user.is_staff:
|
||||
return models.Candidate.objects.none() # Restrict for non-staff
|
||||
|
||||
return queryset.order_by('-created_at')
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['search_query'] = self.request.GET.get('search', '')
|
||||
context['job'] = getattr(self, 'job', None)
|
||||
return context
|
||||
|
||||
|
||||
class CandidateListView(LoginRequiredMixin, ListView):
|
||||
model = models.Candidate
|
||||
template_name = 'recruitment/candidate_list.html'
|
||||
context_object_name = 'candidates'
|
||||
paginate_by = 10
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
|
||||
# Handle search
|
||||
search_query = self.request.GET.get('search', '')
|
||||
if search_query:
|
||||
queryset = queryset.filter(
|
||||
Q(first_name__icontains=search_query) |
|
||||
Q(last_name__icontains=search_query) |
|
||||
Q(email__icontains=search_query) |
|
||||
Q(phone__icontains=search_query) |
|
||||
Q(stage__icontains=search_query) |
|
||||
Q(job__title__icontains=search_query)
|
||||
)
|
||||
|
||||
# Filter for non-staff users
|
||||
if not self.request.user.is_staff:
|
||||
return models.Candidate.objects.none() # Restrict for non-staff
|
||||
|
||||
return queryset.order_by('-created_at')
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['search_query'] = self.request.GET.get('search', '')
|
||||
return context
|
||||
|
||||
|
||||
class CandidateCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView):
|
||||
model = models.Candidate
|
||||
form_class = forms.CandidateForm
|
||||
template_name = 'recruitment/candidate_create.html'
|
||||
success_url = reverse_lazy('candidate_list')
|
||||
success_message = 'Candidate created successfully.'
|
||||
|
||||
def get_initial(self):
|
||||
initial = super().get_initial()
|
||||
if 'slug' in self.kwargs:
|
||||
job = get_object_or_404(models.JobPosting, slug=self.kwargs['slug'])
|
||||
initial['job'] = job
|
||||
return initial
|
||||
|
||||
def form_valid(self, form):
|
||||
if 'slug' in self.kwargs:
|
||||
job = get_object_or_404(models.JobPosting, slug=self.kwargs['slug'])
|
||||
form.instance.job = job
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
class CandidateUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView):
|
||||
model = models.Candidate
|
||||
form_class = forms.CandidateForm
|
||||
template_name = 'recruitment/candidate_update.html'
|
||||
success_url = reverse_lazy('candidate_list')
|
||||
success_message = 'Candidate updated successfully.'
|
||||
slug_url_kwarg = 'slug'
|
||||
|
||||
|
||||
class CandidateDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView):
|
||||
model = models.Candidate
|
||||
template_name = 'recruitment/candidate_delete.html'
|
||||
success_url = reverse_lazy('candidate_list')
|
||||
success_message = 'Candidate deleted successfully.'
|
||||
slug_url_kwarg = 'slug'
|
||||
|
||||
|
||||
def job_detail(request, slug):
|
||||
job = get_object_or_404(models.JobPosting, slug=slug, status='Published')
|
||||
form = forms.CandidateForm()
|
||||
return render(request, 'recruitment/job_detail.html', {'job': job, 'form': form})
|
||||
return render(request, 'jobs/job_detail.html', {'job': job, 'form': form})
|
||||
|
||||
|
||||
@login_required
|
||||
@ -23,22 +195,163 @@ def training_list(request):
|
||||
return render(request, 'recruitment/training_list.html', {'materials': materials})
|
||||
|
||||
|
||||
def candidate_detail(request, candidate_id):
|
||||
candidate = get_object_or_404(models.Candidate, id=candidate_id)
|
||||
def candidate_detail(request, slug):
|
||||
candidate = get_object_or_404(models.Candidate, slug=slug)
|
||||
try:
|
||||
parsed = ast.literal_eval(candidate.parsed_summary)
|
||||
except:
|
||||
parsed = {}
|
||||
|
||||
# Create stage update form for staff users
|
||||
stage_form = None
|
||||
if request.user.is_staff:
|
||||
stage_form = forms.CandidateStageForm(candidate=candidate)
|
||||
|
||||
return render(request, 'recruitment/candidate_detail.html', {
|
||||
'candidate': candidate,
|
||||
'parsed': parsed,
|
||||
'stage_form': stage_form,
|
||||
})
|
||||
|
||||
def candidate_update_stage(request, slug):
|
||||
"""Handle HTMX stage update requests"""
|
||||
from time import sleep
|
||||
sleep(5)
|
||||
try:
|
||||
if not request.user.is_staff:
|
||||
return render(request, 'recruitment/partials/error.html', {'error': 'Permission denied'}, status=403)
|
||||
|
||||
candidate = get_object_or_404(models.Candidate, slug=slug)
|
||||
|
||||
if request.method != 'POST':
|
||||
return render(request, 'recruitment/partials/error.html', {'error': 'Only POST method is allowed'}, status=405)
|
||||
|
||||
# Handle form data
|
||||
form = forms.CandidateStageForm(request.POST, candidate=candidate)
|
||||
if form.is_valid():
|
||||
stage_value = form.cleaned_data['stage']
|
||||
|
||||
# Validate stage value
|
||||
valid_stages = [choice[0] for choice in models.Candidate.Stage.choices]
|
||||
if stage_value not in valid_stages:
|
||||
return render(request, 'recruitment/partials/error.html', {'error': f'Invalid stage value. Must be one of: {", ".join(valid_stages)}'}, status=400)
|
||||
|
||||
# Check transition rules
|
||||
if candidate.pk and stage_value != candidate.stage:
|
||||
old_stage = candidate.stage
|
||||
if not candidate.can_transition_to(stage_value):
|
||||
return render(request, 'recruitment/partials/error.html', {'error': f'Cannot transition from "{old_stage}" to "{stage_value}". Transition not allowed.'}, status=400)
|
||||
|
||||
# Update the stage
|
||||
old_stage = candidate.stage
|
||||
candidate.stage = stage_value
|
||||
candidate.save()
|
||||
|
||||
# Return success template
|
||||
context = {
|
||||
'form': form,
|
||||
'success': True,
|
||||
'message': f'Stage updated from "{old_stage}" to "{candidate.stage}"',
|
||||
'new_stage': candidate.stage,
|
||||
'new_stage_display': candidate.get_stage_display(),
|
||||
'candidate': candidate
|
||||
}
|
||||
def response():
|
||||
stage_form = forms.CandidateStageForm(candidate=candidate)
|
||||
context['stage_form'] = stage_form
|
||||
stage_form_partial = render_to_string('recruitment/partials/stage_update_modal.html#id-stage', context)
|
||||
success_html = render_to_string('recruitment/partials/stage_update_success.html', context)
|
||||
yield SSE.patch_elements(stage_form_partial,"#id_stage")
|
||||
yield SSE.patch_elements(success_html,"#availableStagesInfo")
|
||||
yield SSE.patch_signals({'stage':candidate.stage})
|
||||
|
||||
return DatastarResponse(response())
|
||||
# return render(request, 'recruitment/partials/stage_update_success.html', context)
|
||||
else:
|
||||
# Return form with errors
|
||||
context = {
|
||||
'form': form,
|
||||
'candidate': candidate,
|
||||
'stage_form': forms.CandidateStageForm(candidate=candidate)
|
||||
}
|
||||
return render(request, 'recruitment/partials/stage_update_form.html', context)
|
||||
|
||||
except Exception as e:
|
||||
# Log the error for debugging
|
||||
import traceback
|
||||
error_details = traceback.format_exc()
|
||||
print(f"Error in candidate_update_stage: {error_details}")
|
||||
|
||||
return render(request, 'partials/error.html', {'error': f'Internal server error: {str(e)}'}, status=500)
|
||||
|
||||
|
||||
class TrainingListView(LoginRequiredMixin, ListView):
|
||||
model = models.TrainingMaterial
|
||||
template_name = 'recruitment/training_list.html'
|
||||
context_object_name = 'materials'
|
||||
paginate_by = 10
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
|
||||
# Handle search
|
||||
search_query = self.request.GET.get('search', '')
|
||||
if search_query:
|
||||
queryset = queryset.filter(
|
||||
Q(title__icontains=search_query) |
|
||||
Q(description__icontains=search_query)
|
||||
)
|
||||
|
||||
# Filter for non-staff users
|
||||
if not self.request.user.is_staff:
|
||||
return models.TrainingMaterial.objects.none() # Restrict for non-staff
|
||||
|
||||
return queryset.filter(created_by=self.request.user).order_by('-created_at')
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['search_query'] = self.request.GET.get('search', '')
|
||||
return context
|
||||
|
||||
|
||||
class TrainingCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView):
|
||||
model = models.TrainingMaterial
|
||||
form_class = forms.TrainingMaterialForm
|
||||
template_name = 'recruitment/training_create.html'
|
||||
success_url = reverse_lazy('training_list')
|
||||
success_message = 'Training material created successfully.'
|
||||
|
||||
def form_valid(self, form):
|
||||
form.instance.created_by = self.request.user
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
class TrainingUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView):
|
||||
model = models.TrainingMaterial
|
||||
form_class = forms.TrainingMaterialForm
|
||||
template_name = 'recruitment/training_update.html'
|
||||
success_url = reverse_lazy('training_list')
|
||||
success_message = 'Training material updated successfully.'
|
||||
slug_url_kwarg = 'slug'
|
||||
|
||||
|
||||
class TrainingDetailView(LoginRequiredMixin, DetailView):
|
||||
model = models.TrainingMaterial
|
||||
template_name = 'recruitment/training_detail.html'
|
||||
context_object_name = 'material'
|
||||
slug_url_kwarg = 'slug'
|
||||
|
||||
class TrainingDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView):
|
||||
model = models.TrainingMaterial
|
||||
template_name = 'recruitment/training_delete.html'
|
||||
success_url = reverse_lazy('training_list')
|
||||
success_message = 'Training material deleted successfully.'
|
||||
|
||||
|
||||
def dashboard_view(request):
|
||||
total_jobs = models.Job.objects.count()
|
||||
total_jobs = models.JobPosting.objects.count()
|
||||
total_candidates = models.Candidate.objects.count()
|
||||
jobs = models.Job.objects.all()
|
||||
jobs = models.JobPosting.objects.all()
|
||||
|
||||
job_titles = [job.title for job in jobs]
|
||||
job_app_counts = [job.candidates.count() for job in jobs]
|
||||
@ -51,4 +364,4 @@ def dashboard_view(request):
|
||||
'job_app_counts': job_app_counts,
|
||||
'average_applications': average_applications,
|
||||
}
|
||||
return render(request, 'recruitment/dashboard.html', context)
|
||||
return render(request, 'recruitment/dashboard.html', context)
|
||||
|
||||
947
static/css/style.css
Normal file
@ -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
@ -0,0 +1,126 @@
|
||||
// Modal Handlers for Bootstrap
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Handle delete button clicks
|
||||
const deleteButtons = document.querySelectorAll('[data-bs-toggle="deleteModal"]');
|
||||
|
||||
deleteButtons.forEach(button => {
|
||||
button.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const targetModal = this.getAttribute('data-bs-target');
|
||||
const modal = new bootstrap.Modal(document.getElementById('deleteModal'));
|
||||
|
||||
// Set up the delete form and message
|
||||
const deleteUrl = this.getAttribute('data-delete-url');
|
||||
const itemName = this.getAttribute('data-item-name') || 'this item';
|
||||
|
||||
const deleteForm = document.getElementById('deleteForm');
|
||||
deleteForm.setAttribute('action', deleteUrl);
|
||||
|
||||
const modalMessage = document.getElementById('deleteModalMessage');
|
||||
modalMessage.textContent = `Are you sure you want to delete "${itemName}"? This action cannot be undone.`;
|
||||
|
||||
modal.show();
|
||||
});
|
||||
});
|
||||
|
||||
// Handle update button clicks for inline editing
|
||||
const updateButtons = document.querySelectorAll('[data-bs-toggle="updateModal"]');
|
||||
|
||||
updateButtons.forEach(button => {
|
||||
button.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const targetModal = this.getAttribute('data-bs-target');
|
||||
const modal = new bootstrap.Modal(document.getElementById('updateModal'));
|
||||
|
||||
// Load the form content via AJAX
|
||||
const updateUrl = this.getAttribute('data-update-url');
|
||||
|
||||
fetch(updateUrl)
|
||||
.then(response => response.text())
|
||||
.then(html => {
|
||||
const modalBody = document.querySelector('#updateModal .modal-body');
|
||||
modalBody.innerHTML = html;
|
||||
|
||||
// Re-initialize any form validation or JavaScript within the modal
|
||||
initializeModalForms();
|
||||
|
||||
modal.show();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading form:', error);
|
||||
alert('Error loading the form. Please try again.');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize forms within modals
|
||||
function initializeModalForms() {
|
||||
// Handle form submissions within modals
|
||||
const modalForms = document.querySelectorAll('#updateModal form');
|
||||
|
||||
modalForms.forEach(form => {
|
||||
form.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(this);
|
||||
const submitUrl = this.getAttribute('action');
|
||||
|
||||
fetch(submitUrl, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-CSRFToken': formData.get('csrfmiddlewaretoken')
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
// Close the modal and refresh the page or update the content
|
||||
bootstrap.Modal.getInstance(document.getElementById('updateModal')).hide();
|
||||
|
||||
// Reload the page or update the specific element
|
||||
if (data.reload) {
|
||||
window.location.reload();
|
||||
} else if (data.update_element) {
|
||||
// Update specific element if needed
|
||||
console.log('Element updated:', data.update_element);
|
||||
}
|
||||
} else {
|
||||
// Show errors in the form
|
||||
displayFormErrors(form, data.errors);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error submitting form:', error);
|
||||
alert('Error submitting the form. Please try again.');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Display form errors
|
||||
function displayFormErrors(form, errors) {
|
||||
// Remove existing error messages
|
||||
const existingErrors = form.querySelectorAll('.alert-danger');
|
||||
existingErrors.forEach(error => error.remove());
|
||||
|
||||
// Add error messages
|
||||
if (errors && Object.keys(errors).length > 0) {
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'alert alert-danger';
|
||||
errorDiv.innerHTML = '<strong>Please correct the following errors:</strong><ul>';
|
||||
|
||||
for (const [field, messages] of Object.entries(errors)) {
|
||||
messages.forEach(message => {
|
||||
errorDiv.innerHTML += `<li>${field}: ${message}</li>`;
|
||||
});
|
||||
}
|
||||
|
||||
errorDiv.innerHTML += '</ul>';
|
||||
form.insertBefore(errorDiv, form.firstChild);
|
||||
}
|
||||
}
|
||||
BIN
static/media/resumes/Abdullah_Bakhsh_-_2025_EJixhWY.pdf
Normal file
BIN
static/media/resumes/Abdullah_Bakhsh_-_2025_jeZedk0.pdf
Normal file
BIN
static/media/resumes/Abdullah_Bakhsh_-_2025_r6sreTY.pdf
Normal file
BIN
static/media/resumes/Certificate.pdf
Normal file
BIN
static/media/resumes/Certificate_MwMpoYu.pdf
Normal file
3
static/media/resumes/cars_sample_5.csv
Normal file
@ -0,0 +1,3 @@
|
||||
vin,
|
||||
5FNRL38739B001353,
|
||||
5FNRL38739B001354
|
||||
|
3
static/media/resumes/cars_sample_6.csv
Normal file
@ -0,0 +1,3 @@
|
||||
vin,
|
||||
1HGCE1899RA009923,
|
||||
1HGCE1899RA009924,
|
||||
|
3
static/media/resumes/cars_sample_6_8A18tRh.csv
Normal file
@ -0,0 +1,3 @@
|
||||
vin,
|
||||
1HGCE1899RA009923,
|
||||
1HGCE1899RA009924,
|
||||
|
BIN
static/media/resumes/مواصفات_الخدمة_المطلوبة_1.pdf
Normal file
BIN
static/media/resumes/مواصفات_الخدمة_المطلوبة_1_1mTtqBQ.pdf
Normal file
BIN
static/media/resumes/مواصفات_الخدمة_المطلوبة_2.pdf
Normal file
BIN
static/media/resumes/مواصفات_الخدمة_المطلوبة_2_3V3ucbE.pdf
Normal file
BIN
static/media/resumes/مواصفات_الخدمة_المطلوبة_2_4aLIYuz.pdf
Normal file
BIN
static/media/resumes/مواصفات_الخدمة_المطلوبة_2_WrtgegA.pdf
Normal file
141
templates/base.html
Normal file
@ -0,0 +1,141 @@
|
||||
{% load static i18n %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="{% if request.LANGUAGE_CODE %}{{ request.LANGUAGE_CODE }}{% else %}en{% endif %}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}NorahUniversity ATS{% endblock %}</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.5/bundles/datastar.js"></script>
|
||||
{% comment %} <script src="https://unpkg.com/htmx.org@2.0.3/dist/htmx.min.js"></script> {% endcomment %}
|
||||
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||
|
||||
<!-- FilePond CSS -->
|
||||
<link href="https://unpkg.com/filepond/dist/filepond.css" rel="stylesheet">
|
||||
<link href="https://unpkg.com/filepond-plugin-image-preview/dist/filepond-plugin-image-preview.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="{% static 'css/style.css' %}">
|
||||
{% block extra_css %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<header class="header">
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-center py-3">
|
||||
<div class="logo h4 mb-0">NorahUniversity ATS</div>
|
||||
<div class="user-info d-flex align-items-center gap-3">
|
||||
{% if user.is_authenticated %}
|
||||
<span class="text-muted">{{ user.username }}</span>
|
||||
<a href="{% url 'account_logout' %}" class="btn btn-outline-secondary btn-sm">
|
||||
<span class="d-flex align-items-center gap-1">
|
||||
{% include "icons/logout.html" %}
|
||||
Logout
|
||||
</span>
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{% url 'account_login' %}" class="btn btn-primary btn-sm">{% trans "Login" %}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="navbar navbar-expand-lg navbar-light border-bottom">
|
||||
<div class="container">
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav me-auto">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.resolver_match.url_name == 'dashboard' %}active{% endif %}" href="{% url 'dashboard' %}">
|
||||
<span class="d-flex align-items-center gap-2">
|
||||
{% include "icons/dashboard.html" %}
|
||||
Dashboard
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.resolver_match.url_name == 'job_list' %}active{% endif %}" href="{% url 'job_list' %}">
|
||||
<span class="d-flex align-items-center gap-2">
|
||||
{% include "icons/jobs.html" %}
|
||||
Jobs
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.resolver_match.url_name == 'candidate_list' %}active{% endif %}" href="{% url 'candidate_list' %}">
|
||||
<span class="d-flex align-items-center gap-2">
|
||||
{% include "icons/users.html" %}
|
||||
Candidates
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.resolver_match.url_name == 'training_list' %}active{% endif %}" href="{% url 'training_list' %}">
|
||||
<span class="d-flex align-items-center gap-2">
|
||||
<svg class="heroicon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
Training
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.resolver_match.url_name == 'list_meetings' %}active{% endif %}" href="{% url 'list_meetings' %}">
|
||||
<span class="d-flex align-items-center gap-2">
|
||||
{% include "icons/meeting.html" %}
|
||||
Meetings
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="container my-4">
|
||||
{% if messages %}
|
||||
<div class="messages">
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
</main>
|
||||
|
||||
<!-- Delete Modal -->
|
||||
{% include 'includes/delete_modal.html' %}
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<script>
|
||||
const csrfToken = "{{ csrf_token }}";
|
||||
const staticUrl = "{% static '' %}";
|
||||
</script>
|
||||
|
||||
<!-- JavaScript Libraries -->
|
||||
<script src="https://unpkg.com/petite-vue" defer init></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
|
||||
<script src="https://unpkg.com/filepond/dist/filepond.js"></script>
|
||||
<script src="https://unpkg.com/filepond-plugin-image-preview/dist/filepond-plugin-image-preview.js"></script>
|
||||
<script src="https://unpkg.com/filepond-plugin-file-validate-type/dist/filepond-plugin-file-validate-type.js"></script>
|
||||
<script src="https://unpkg.com/filepond-plugin-file-validate-size/dist/filepond-plugin-file-validate-size.js"></script>
|
||||
|
||||
|
||||
<script src="{% static 'js/modal_handlers.js' %}"></script>
|
||||
|
||||
<script>
|
||||
// Initialize tooltips
|
||||
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
|
||||
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
|
||||
return new bootstrap.Tooltip(tooltipTriggerEl)
|
||||
});
|
||||
</script>
|
||||
|
||||
{% block extra_js %}{% endblock %}
|
||||
1604
templates/form_builder.html
Normal file
1406
templates/forms/edit_form.html
Normal file
502
templates/forms/form_embed.html
Normal file
@ -0,0 +1,502 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ form.title }} - Embed</title>
|
||||
|
||||
<!-- Bootstrap CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<!-- Font Awesome -->
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
body {
|
||||
background: #f8f9fa;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.embed-container {
|
||||
background: #f8f9fa;
|
||||
padding: 0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.embed-form-wrapper {
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.embed-form {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.embed-form .card {
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.embed-form .card-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: none;
|
||||
padding: 30px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.embed-form .card-body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.embed-info {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 30px;
|
||||
margin-bottom: 30px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.code-block {
|
||||
background: #2d3748;
|
||||
color: #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
overflow-x: auto;
|
||||
position: relative;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background: #4a5568;
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.copy-btn:hover {
|
||||
background: #2d3748;
|
||||
}
|
||||
|
||||
.copy-btn.copied {
|
||||
background: #48bb78;
|
||||
}
|
||||
|
||||
.preview-iframe {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
width: 100%;
|
||||
height: 600px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link {
|
||||
border: none;
|
||||
background: #f7fafc;
|
||||
color: #4a5568;
|
||||
padding: 12px 24px;
|
||||
margin-right: 8px;
|
||||
border-radius: 8px 8px 0 0;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link.active {
|
||||
background: white;
|
||||
color: #2d3748;
|
||||
border-bottom: 2px solid #667eea;
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link:hover {
|
||||
background: #edf2f7;
|
||||
}
|
||||
|
||||
.feature-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.feature-list li {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.feature-list li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.feature-list i {
|
||||
color: #48bb78;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.dimensions-info {
|
||||
background: #f7fafc;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.responsive-options {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.responsive-option {
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
margin-bottom: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.responsive-option:hover {
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.responsive-option.active {
|
||||
border-color: #667eea;
|
||||
background: #f0f4ff;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="embed-container">
|
||||
<div class="embed-form-wrapper">
|
||||
<div class="embed-form">
|
||||
<!-- Header -->
|
||||
<div class="embed-info">
|
||||
<div class="d-flex justify-content-between align-items-start mb-4">
|
||||
<div>
|
||||
<h1 class="h2 mb-2">
|
||||
<i class="fas fa-code text-primary"></i> Embed Form
|
||||
</h1>
|
||||
<p class="text-muted mb-0">Get the embed code for "{{ form.title }}"</p>
|
||||
</div>
|
||||
<a href="{% url 'form_preview' form.id %}" target="_blank" class="btn btn-outline-primary">
|
||||
<i class="fas fa-external-link-alt"></i> Preview Form
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="row text-center mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="p-3">
|
||||
<h4 class="text-primary mb-1">{{ form.submissions.count }}</h4>
|
||||
<small class="text-muted">Submissions</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="p-3">
|
||||
<h4 class="text-success mb-1">{{ form.structure.wizards|length|default:0 }}</h4>
|
||||
<small class="text-muted">Steps</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="p-3">
|
||||
<h4 class="text-info mb-1">
|
||||
{% if form.structure.wizards %}
|
||||
{{ form.structure.wizards.0.fields|length|default:0 }}
|
||||
{% else %}
|
||||
0
|
||||
{% endif %}
|
||||
</h4>
|
||||
<small class="text-muted">Fields</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="p-3">
|
||||
<h4 class="text-warning mb-1">{{ form.created_at|date:"M d" }}</h4>
|
||||
<small class="text-muted">Created</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<ul class="nav nav-tabs" id="embedTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="iframe-tab" data-bs-toggle="tab" data-bs-target="#iframe" type="button" role="tab">
|
||||
<i class="fas fa-globe"></i> iFrame
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="popup-tab" data-bs-toggle="tab" data-bs-target="#popup" type="button" role="tab">
|
||||
<i class="fas fa-external-link-alt"></i> Popup
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="inline-tab" data-bs-toggle="tab" data-bs-target="#inline" type="button" role="tab">
|
||||
<i class="fas fa-code"></i> Inline
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content" id="embedTabsContent">
|
||||
<!-- iFrame Tab -->
|
||||
<div class="tab-pane fade show active" id="iframe" role="tabpanel">
|
||||
<h5 class="mb-3">iFrame Embed Code</h5>
|
||||
<p class="text-muted">Embed this form directly into your website using an iframe.</p>
|
||||
|
||||
<div class="code-block">
|
||||
<button class="copy-btn" onclick="copyToClipboard(this, 'iframe-code')">
|
||||
<i class="fas fa-copy"></i> Copy
|
||||
</button>
|
||||
<code id="iframe-code"><iframe src="{{ request.build_absolute_uri }}{% url 'form_preview' form.id %}?embed=true"
|
||||
width="100%"
|
||||
height="600"
|
||||
frameborder="0"
|
||||
style="border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);"></iframe></code>
|
||||
</div>
|
||||
|
||||
<div class="dimensions-info">
|
||||
<h6 class="mb-3">Responsive Options</h6>
|
||||
<div class="responsive-options">
|
||||
<div class="responsive-option active" onclick="selectResponsive(this, 'iframe', 'fixed')">
|
||||
<strong>Fixed Height:</strong> 600px
|
||||
<div class="text-muted small">Best for most websites</div>
|
||||
</div>
|
||||
<div class="responsive-option" onclick="selectResponsive(this, 'iframe', 'responsive')">
|
||||
<strong>Responsive:</strong> Auto height
|
||||
<div class="text-muted small">Adjusts to content height</div>
|
||||
</div>
|
||||
<div class="responsive-option" onclick="selectResponsive(this, 'iframe', 'full')">
|
||||
<strong>Full Screen:</strong> 100vh
|
||||
<div class="text-muted small">Takes full viewport height</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Popup Tab -->
|
||||
<div class="tab-pane fade" id="popup" role="tabpanel">
|
||||
<h5 class="mb-3">Popup Embed Code</h5>
|
||||
<p class="text-muted">Add a button or link that opens the form in a modal popup.</p>
|
||||
|
||||
<div class="code-block">
|
||||
<button class="copy-btn" onclick="copyToClipboard(this, 'popup-code')">
|
||||
<i class="fas fa-copy"></i> Copy
|
||||
</button>
|
||||
<code id="popup-code"><button onclick="openFormPopup()" class="btn btn-primary">
|
||||
Open Form
|
||||
</button>
|
||||
|
||||
<script>
|
||||
function openFormPopup() {
|
||||
const modal = document.createElement('div');
|
||||
modal.style.cssText = 'position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 9999; display: flex; align-items: center; justify-content: center;';
|
||||
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.src = '{{ request.build_absolute_uri }}{% url 'form_preview' form.id %}?embed=true';
|
||||
iframe.style.cssText = 'width: 90%; max-width: 800px; height: 80vh; border: none; border-radius: 12px; box-shadow: 0 20px 60px rgba(0,0,0,0.3);';
|
||||
|
||||
modal.appendChild(iframe);
|
||||
document.body.appendChild(modal);
|
||||
|
||||
modal.onclick = function(e) {
|
||||
if (e.target === modal) {
|
||||
document.body.removeChild(modal);
|
||||
}
|
||||
};
|
||||
}
|
||||
</script></code>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<h6>Customization Options</h6>
|
||||
<ul class="feature-list">
|
||||
<li><i class="fas fa-check"></i> Custom button text and styling</li>
|
||||
<li><i class="fas fa-check"></i> Trigger on page load or scroll</li>
|
||||
<li><i class="fas fa-check"></i> Custom modal dimensions</li>
|
||||
<li><i class="fas fa-check"></i> Close on outside click</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Inline Tab -->
|
||||
<div class="tab-pane fade" id="inline" role="tabpanel">
|
||||
<h5 class="mb-3">Inline Embed Code</h5>
|
||||
<p class="text-muted">Embed the form HTML directly into your page for maximum customization.</p>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<i class="fas fa-info-circle"></i> <strong>Note:</strong> This option requires more technical knowledge but offers the best integration.
|
||||
</div>
|
||||
|
||||
<div class="code-block">
|
||||
<button class="copy-btn" onclick="copyToClipboard(this, 'inline-code')">
|
||||
<i class="fas fa-copy"></i> Copy
|
||||
</button>
|
||||
<code id="inline-code"><!-- Form CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||
|
||||
<!-- Form Container -->
|
||||
<div id="form-{{ form.id }}">
|
||||
<div class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status"></div>
|
||||
<p class="mt-3">Loading form...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form Scripts -->
|
||||
<script src="https://unpkg.com/preact@10.19.3/dist/preact.umd.js"></script>
|
||||
<script src="https://unpkg.com/htm@3.1.1/dist/htm.umd.js"></script>
|
||||
<script>
|
||||
// Load form data and render
|
||||
fetch('/recruitment/api/forms/{{ form.id }}/load/')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
// Render form using the form structure
|
||||
console.log('Form data:', data);
|
||||
// Implement form rendering logic here
|
||||
})
|
||||
.catch(error => console.error('Error loading form:', error));
|
||||
</script></code>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<h6>Benefits of Inline Embed</h6>
|
||||
<ul class="feature-list">
|
||||
<li><i class="fas fa-check"></i> Full control over styling</li>
|
||||
<li><i class="fas fa-check"></i> Better SEO integration</li>
|
||||
<li><i class="fas fa-check"></i> Faster initial load</li>
|
||||
<li><i class="fas fa-check"></i> Custom form handling</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview Section -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-eye"></i> Live Preview
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<iframe src="{% url 'form_preview' form.id %}?embed=true"
|
||||
class="preview-iframe"
|
||||
frameborder="0">
|
||||
</iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bootstrap JS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<script>
|
||||
function copyToClipboard(button, elementId) {
|
||||
const element = document.getElementById(elementId);
|
||||
const text = element.textContent;
|
||||
|
||||
navigator.clipboard.writeText(text).then(function() {
|
||||
const originalText = button.innerHTML;
|
||||
button.innerHTML = '<i class="fas fa-check"></i> Copied!';
|
||||
button.classList.add('copied');
|
||||
|
||||
setTimeout(function() {
|
||||
button.innerHTML = originalText;
|
||||
button.classList.remove('copied');
|
||||
}, 2000);
|
||||
}).catch(function(err) {
|
||||
console.error('Failed to copy text: ', err);
|
||||
|
||||
// Fallback for older browsers
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = text;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
button.innerHTML = '<i class="fas fa-check"></i> Copied!';
|
||||
button.classList.add('copied');
|
||||
|
||||
setTimeout(function() {
|
||||
button.innerHTML = '<i class="fas fa-copy"></i> Copy';
|
||||
button.classList.remove('copied');
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error('Fallback copy failed: ', err);
|
||||
}
|
||||
|
||||
document.body.removeChild(textArea);
|
||||
});
|
||||
}
|
||||
|
||||
function selectResponsive(element, type, option) {
|
||||
// Remove active class from all options in this tab
|
||||
const container = element.closest('.tab-pane');
|
||||
container.querySelectorAll('.responsive-option').forEach(opt => {
|
||||
opt.classList.remove('active');
|
||||
});
|
||||
|
||||
// Add active class to selected option
|
||||
element.classList.add('active');
|
||||
|
||||
// Update the embed code based on selection
|
||||
updateEmbedCode(type, option);
|
||||
}
|
||||
|
||||
function updateEmbedCode(type, option) {
|
||||
const baseUrl = '{{ request.build_absolute_uri }}{% url "form_preview" form.id %}?embed=true';
|
||||
let height = '600';
|
||||
let style = 'border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);';
|
||||
|
||||
if (type === 'iframe') {
|
||||
switch(option) {
|
||||
case 'responsive':
|
||||
height = 'auto';
|
||||
style += ' min-height: 600px;';
|
||||
break;
|
||||
case 'full':
|
||||
height = '100vh';
|
||||
style += ' min-height: 100vh;';
|
||||
break;
|
||||
default:
|
||||
// fixed height - already set
|
||||
break;
|
||||
}
|
||||
|
||||
const code = `<iframe src="${baseUrl}"
|
||||
width="100%"
|
||||
height="${height}"
|
||||
frameborder="0"
|
||||
style="${style}"></iframe>`;
|
||||
|
||||
document.getElementById('iframe-code').textContent = code;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize tooltips
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
|
||||
const tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
|
||||
return new bootstrap.Tooltip(tooltipTriggerEl);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
142
templates/forms/form_list.html
Normal file
@ -0,0 +1,142 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Forms - University ATS{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1><i class="fas fa-wpforms"></i> Forms</h1>
|
||||
<a href="{% url 'form_builder' %}" class="btn btn-primary">
|
||||
<i class="fas fa-plus"></i> Create New Form
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Search and Filter -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<form method="get" class="row g-3">
|
||||
<div class="col-md-8">
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" name="search" placeholder="Search forms..." value="{{ request.GET.search }}">
|
||||
<button class="btn btn-outline-secondary" type="submit">
|
||||
<i class="fas fa-search"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<select class="form-select" name="sort">
|
||||
<option value="-created_at">Latest First</option>
|
||||
<option value="created_at">Oldest First</option>
|
||||
<option value="title">Title (A-Z)</option>
|
||||
<option value="-title">Title (Z-A)</option>
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Forms List -->
|
||||
{% if page_obj %}
|
||||
<div class="row">
|
||||
{% for form in page_obj %}
|
||||
<div class="col-lg-4 col-md-6 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<h5 class="card-title mb-1">{{ form.title }}</h5>
|
||||
<span class="badge bg-success">Active</span>
|
||||
</div>
|
||||
|
||||
<p class="card-text text-muted small">
|
||||
{{ form.description|truncatewords:15 }}
|
||||
</p>
|
||||
|
||||
<div class="mb-2">
|
||||
<small class="text-muted">
|
||||
<i class="fas fa-user"></i> {{ form.created_by.username }}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<small class="text-muted">
|
||||
<i class="fas fa-calendar"></i> {{ form.created_at|date:"M d, Y" }}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<small class="text-muted">
|
||||
<i class="fas fa-chart-bar"></i> {{ form.submissions.count }} submissions
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer bg-transparent">
|
||||
<div class="btn-group w-100" role="group">
|
||||
{% if form.created_by == user %}
|
||||
<a href="{% url 'edit_form' form.id %}" class="btn btn-sm btn-outline-warning">
|
||||
<i class="fas fa-edit"></i> Edit
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{% url 'form_preview' form.id %}" class="btn btn-sm btn-outline-primary" target="_blank">
|
||||
<i class="fas fa-eye"></i> Preview
|
||||
</a>
|
||||
<a href="{% url 'form_embed' form.id %}" class="btn btn-sm btn-outline-secondary" target="_blank">
|
||||
<i class="fas fa-code"></i> Embed
|
||||
</a>
|
||||
<a href="{% url 'form_submissions' form.id %}" class="btn btn-sm btn-outline-info">
|
||||
<i class="fas fa-list"></i> Submissions
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if page_obj.has_other_pages %}
|
||||
<nav aria-label="Forms pagination">
|
||||
<ul class="pagination justify-content-center">
|
||||
{% if page_obj.has_previous %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page=1{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}{% if request.GET.sort %}&sort={{ request.GET.sort }}{% endif %}">First</a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}{% if request.GET.sort %}&sort={{ request.GET.sort }}{% endif %}">Previous</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
<li class="page-item active">
|
||||
<span class="page-link">{{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span>
|
||||
</li>
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}{% if request.GET.sort %}&sort={{ request.GET.sort }}{% endif %}">Next</a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}{% if request.GET.sort %}&sort={{ request.GET.sort }}{% endif %}">Last</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="fas fa-wpforms fa-3x text-muted mb-3"></i>
|
||||
<h3>No forms found</h3>
|
||||
<p class="text-muted">Create your first form to get started.</p>
|
||||
<a href="{% url 'form_builder' %}" class="btn btn-primary">Create Form</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Add any interactive JavaScript here if needed
|
||||
</script>
|
||||
{% endblock %}
|
||||
715
templates/forms/form_preview.html
Normal file
@ -0,0 +1,715 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ form.title }} - Form Preview{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if not is_embed %}
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1><i class="fas fa-wpforms"></i> Form Preview</h1>
|
||||
<div>
|
||||
<a href="{% url 'form_list' %}" class="btn btn-outline-secondary me-2">
|
||||
<i class="fas fa-arrow-left"></i> Back to Forms
|
||||
</a>
|
||||
<a href="{% url 'form_embed' form.id %}" class="btn btn-outline-primary" target="_blank">
|
||||
<i class="fas fa-code"></i> Get Embed Code
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Form Preview Container -->
|
||||
<div class="{% if is_embed %}embed-container{% else %}container-fluid{% endif %}">
|
||||
<div class="{% if is_embed %}embed-form-wrapper{% else %}row justify-content-center{% endif %}">
|
||||
<div class="{% if is_embed %}embed-form{% else %}col-lg-8 col-md-10{% endif %}">
|
||||
<!-- Form Header -->
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h3 class="mb-1">{{ form.title }}</h3>
|
||||
{% if form.description %}
|
||||
<p class="mb-0 opacity-90">{{ form.description }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<small class="text-muted">
|
||||
<i class="fas fa-chart-bar"></i> {{ submission_count }} submissions
|
||||
</small>
|
||||
<small class="text-muted">
|
||||
<i class="fas fa-calendar"></i> Created {{ form.created_at|date:"M d, Y" }}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live Form Preview -->
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
<div id="form-preview-container">
|
||||
<!-- Form will be rendered here by Preact -->
|
||||
<div class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Loading form...</span>
|
||||
</div>
|
||||
<p class="mt-3 text-muted">Loading form preview...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form Analytics (only shown if not embedded) -->
|
||||
{% if not is_embed %}
|
||||
<div class="card shadow-sm mt-4">
|
||||
<div class="card-header">
|
||||
<h5><i class="fas fa-chart-line"></i> Form Analytics</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<div class="text-center">
|
||||
<h4 class="text-primary">{{ submission_count }}</h4>
|
||||
<small class="text-muted">Total Submissions</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="text-center">
|
||||
<h4 class="text-success">{{ form.created_at|timesince }}</h4>
|
||||
<small class="text-muted">Time Created</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="text-center">
|
||||
<h4 class="text-info">{{ form.structure.wizards|length|default:0 }}</h4>
|
||||
<small class="text-muted">Form Steps</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="text-center">
|
||||
<h4 class="text-warning">
|
||||
{% if form.structure.wizards %}
|
||||
{{ form.structure.wizards.0.fields|length|default:0 }}
|
||||
{% else %}
|
||||
0
|
||||
{% endif %}
|
||||
</h4>
|
||||
<small class="text-muted">First Step Fields</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success Modal -->
|
||||
<div class="modal fade" id="successModal" tabindex="-1" aria-labelledby="successModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-success text-white">
|
||||
<h5 class="modal-title" id="successModalLabel">
|
||||
<i class="fas fa-check-circle"></i> Form Submitted Successfully!
|
||||
</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="mb-0">Thank you for submitting the form. Your response has been recorded.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
<button type="button" class="btn btn-primary" onclick="resetForm()">Submit Another Response</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Modal -->
|
||||
<div class="modal fade" id="errorModal" tabindex="-1" aria-labelledby="errorModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-danger text-white">
|
||||
<h5 class="modal-title" id="errorModalLabel">
|
||||
<i class="fas fa-exclamation-triangle"></i> Submission Error
|
||||
</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="mb-0" id="errorMessage">An error occurred while submitting the form. Please try again.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
{% if is_embed %}
|
||||
<style>
|
||||
.embed-container {
|
||||
background: #f8f9fa;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.embed-form-wrapper {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.embed-form {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.embed-form .card {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.embed-form .card-header {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #f8f9fa;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="https://unpkg.com/preact@10.19.3/dist/preact.umd.js"></script>
|
||||
<script src="https://unpkg.com/htm@3.1.1/dist/htm.umd.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
|
||||
|
||||
<!-- FilePond for file uploads -->
|
||||
<link href="https://unpkg.com/filepond/dist/filepond.css" rel="stylesheet">
|
||||
<script src="https://unpkg.com/filepond/dist/filepond.js"></script>
|
||||
<script src="https://unpkg.com/filepond-plugin-file-validate-type/dist/filepond-plugin-file-validate-type.js"></script>
|
||||
<script src="https://unpkg.com/filepond-plugin-file-validate-size/dist/filepond-plugin-file-validate-size.js"></script>
|
||||
|
||||
<script>
|
||||
// Form data from Django
|
||||
const formId = {{ form.id }};
|
||||
const formStructure = {{ form.structure|safe }};
|
||||
const isEmbed = {{ is_embed|yesno:"true,false" }};
|
||||
|
||||
const { h, Component, render, useState, useEffect, useRef } = preact;
|
||||
const html = htm.bind(h);
|
||||
|
||||
// Field Components for Preview
|
||||
class PreviewTextField extends Component {
|
||||
render() {
|
||||
const { field, value, onChange, error } = this.props;
|
||||
return html`
|
||||
<div class="mb-3">
|
||||
<label class="form-label">
|
||||
${field.label}
|
||||
${field.required ? html`<span class="text-danger">*</span>` : ''}
|
||||
</label>
|
||||
<input type="text"
|
||||
class="form-control ${error ? 'is-invalid' : ''}"
|
||||
placeholder=${field.placeholder || ''}
|
||||
value=${value || ''}
|
||||
onInput=${(e) => onChange(field.id, e.target.value)}
|
||||
required=${field.required} />
|
||||
${error ? html`<div class="invalid-feedback">${error}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
class PreviewEmailField extends Component {
|
||||
render() {
|
||||
const { field, value, onChange, error } = this.props;
|
||||
return html`
|
||||
<div class="mb-3">
|
||||
<label class="form-label">
|
||||
${field.label}
|
||||
${field.required ? html`<span class="text-danger">*</span>` : ''}
|
||||
</label>
|
||||
<input type="email"
|
||||
class="form-control ${error ? 'is-invalid' : ''}"
|
||||
placeholder=${field.placeholder || ''}
|
||||
value=${value || ''}
|
||||
onInput=${(e) => onChange(field.id, e.target.value)}
|
||||
required=${field.required} />
|
||||
${error ? html`<div class="invalid-feedback">${error}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
class PreviewPhoneField extends Component {
|
||||
render() {
|
||||
const { field, value, onChange, error } = this.props;
|
||||
return html`
|
||||
<div class="mb-3">
|
||||
<label class="form-label">
|
||||
${field.label}
|
||||
${field.required ? html`<span class="text-danger">*</span>` : ''}
|
||||
</label>
|
||||
<input type="tel"
|
||||
class="form-control ${error ? 'is-invalid' : ''}"
|
||||
placeholder=${field.placeholder || ''}
|
||||
value=${value || ''}
|
||||
onInput=${(e) => onChange(field.id, e.target.value)}
|
||||
required=${field.required} />
|
||||
${error ? html`<div class="invalid-feedback">${error}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
class PreviewDateField extends Component {
|
||||
render() {
|
||||
const { field, value, onChange, error } = this.props;
|
||||
return html`
|
||||
<div class="mb-3">
|
||||
<label class="form-label">
|
||||
${field.label}
|
||||
${field.required ? html`<span class="text-danger">*</span>` : ''}
|
||||
</label>
|
||||
<input type="date"
|
||||
class="form-control ${error ? 'is-invalid' : ''}"
|
||||
value=${value || ''}
|
||||
onInput=${(e) => onChange(field.id, e.target.value)}
|
||||
required=${field.required} />
|
||||
${error ? html`<div class="invalid-feedback">${error}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
class PreviewFileField extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.filePondRef = null;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.initFilePond();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.filePondRef) {
|
||||
this.filePondRef.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
initFilePond() {
|
||||
const { field, onChange } = this.props;
|
||||
const inputElement = document.getElementById(`file-upload-${field.id}`);
|
||||
|
||||
if (!inputElement || typeof FilePond === 'undefined') return;
|
||||
|
||||
this.filePondRef = FilePond.create(inputElement, {
|
||||
allowMultiple: field.multiple || false,
|
||||
maxFiles: field.maxFiles || 1,
|
||||
maxFileSize: field.maxFileSize ? `${field.maxFileSize}MB` : '5MB',
|
||||
acceptedFileTypes: field.fileTypes || ['*'],
|
||||
labelIdle: 'Drag & Drop your files or <span class="filepond--label-action">Browse</span>',
|
||||
credits: false,
|
||||
onupdatefiles: (fileItems) => {
|
||||
const files = fileItems.map(item => item.file);
|
||||
onChange(field.id, files);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { field, error } = this.props;
|
||||
return html`
|
||||
<div class="mb-3">
|
||||
<label class="form-label">
|
||||
${field.label}
|
||||
${field.required ? html`<span class="text-danger">*</span>` : ''}
|
||||
</label>
|
||||
<input id="file-upload-${field.id}" type="file" />
|
||||
${error ? html`<div class="text-danger small mt-1">${error}</div>` : ''}
|
||||
${field.fileTypes ? html`<div class="form-text">Accepted: ${field.fileTypes.join(', ')}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
class PreviewDropdownField extends Component {
|
||||
render() {
|
||||
const { field, value, onChange, error } = this.props;
|
||||
return html`
|
||||
<div class="mb-3">
|
||||
<label class="form-label">
|
||||
${field.label}
|
||||
${field.required ? html`<span class="text-danger">*</span>` : ''}
|
||||
</label>
|
||||
<select class="form-select ${error ? 'is-invalid' : ''}"
|
||||
value=${value || ''}
|
||||
onChange=${(e) => onChange(field.id, e.target.value)}
|
||||
required=${field.required}>
|
||||
<option value="">Select an option...</option>
|
||||
${field.options.map(option => html`
|
||||
<option value=${option.value}>${option.value}</option>
|
||||
`)}
|
||||
</select>
|
||||
${error ? html`<div class="invalid-feedback">${error}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
class PreviewRadioField extends Component {
|
||||
render() {
|
||||
const { field, value, onChange, error } = this.props;
|
||||
return html`
|
||||
<div class="mb-3">
|
||||
<label class="form-label">
|
||||
${field.label}
|
||||
${field.required ? html`<span class="text-danger">*</span>` : ''}
|
||||
</label>
|
||||
${field.options.map(option => html`
|
||||
<div class="form-check">
|
||||
<input class="form-check-input"
|
||||
type="radio"
|
||||
name=${field.id}
|
||||
id=${option.id}
|
||||
value=${option.value}
|
||||
checked=${value === option.value}
|
||||
onChange=${(e) => onChange(field.id, e.target.value)}
|
||||
required=${field.required} />
|
||||
<label class="form-check-label" for=${option.id}>
|
||||
${option.value}
|
||||
</label>
|
||||
</div>
|
||||
`)}
|
||||
${error ? html`<div class="text-danger small mt-1">${error}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
class PreviewCheckboxField extends Component {
|
||||
render() {
|
||||
const { field, value = [], onChange, error } = this.props;
|
||||
return html`
|
||||
<div class="mb-3">
|
||||
<label class="form-label">
|
||||
${field.label}
|
||||
${field.required ? html`<span class="text-danger">*</span>` : ''}
|
||||
</label>
|
||||
${field.options.map(option => html`
|
||||
<div class="form-check">
|
||||
<input class="form-check-input"
|
||||
type="checkbox"
|
||||
id=${option.id}
|
||||
value=${option.value}
|
||||
checked=${value.includes(option.value)}
|
||||
onChange=${(e) => {
|
||||
const newValue = e.target.checked
|
||||
? [...value, option.value]
|
||||
: value.filter(v => v !== option.value);
|
||||
onChange(field.id, newValue);
|
||||
}} />
|
||||
<label class="form-check-label" for=${option.id}>
|
||||
${option.value}
|
||||
</label>
|
||||
</div>
|
||||
`)}
|
||||
${error ? html`<div class="text-danger small mt-1">${error}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
class PreviewRatingField extends Component {
|
||||
render() {
|
||||
const { field, value, onChange, error } = this.props;
|
||||
return html`
|
||||
<div class="mb-3">
|
||||
<label class="form-label">
|
||||
${field.label}
|
||||
${field.required ? html`<span class="text-danger">*</span>` : ''}
|
||||
</label>
|
||||
<div class="rating-container">
|
||||
${[1, 2, 3, 4, 5].map(n => html`
|
||||
<span class="rating-star ${value >= n ? 'active' : ''}"
|
||||
style="font-size: 24px; color: ${value >= n ? '#ffc107' : '#ddd'}; cursor: pointer; margin-right: 5px;"
|
||||
onClick=${() => onChange(field.id, n)}>
|
||||
★
|
||||
</span>
|
||||
`)}
|
||||
</div>
|
||||
${error ? html`<div class="text-danger small mt-1">${error}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Field Factory
|
||||
function createPreviewField(field, props) {
|
||||
const fieldProps = { ...props, field };
|
||||
|
||||
switch (field.type) {
|
||||
case 'text': return html`<${PreviewTextField} ...${fieldProps} />`;
|
||||
case 'email': return html`<${PreviewEmailField} ...${fieldProps} />`;
|
||||
case 'phone': return html`<${PreviewPhoneField} ...${fieldProps} />`;
|
||||
case 'date': return html`<${PreviewDateField} ...${fieldProps} />`;
|
||||
case 'file': return html`<${PreviewFileField} ...${fieldProps} />`;
|
||||
case 'dropdown': return html`<${PreviewDropdownField} ...${fieldProps} />`;
|
||||
case 'radio': return html`<${PreviewRadioField} ...${fieldProps} />`;
|
||||
case 'checkbox': return html`<${PreviewCheckboxField} ...${fieldProps} />`;
|
||||
case 'rating': return html`<${PreviewRatingField} ...${fieldProps} />`;
|
||||
default: return html`<${PreviewTextField} ...${fieldProps} />`;
|
||||
}
|
||||
}
|
||||
|
||||
// Main Form Preview Component
|
||||
class FormPreview extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
currentWizardIndex: 0,
|
||||
formData: {},
|
||||
errors: {},
|
||||
isSubmitting: false,
|
||||
isSubmitted: false
|
||||
};
|
||||
}
|
||||
|
||||
getCurrentWizard() {
|
||||
return formStructure.wizards[this.state.currentWizardIndex] || null;
|
||||
}
|
||||
|
||||
getCurrentWizardFields() {
|
||||
const wizard = this.getCurrentWizard();
|
||||
return wizard ? wizard.fields : [];
|
||||
}
|
||||
|
||||
getTotalWizards() {
|
||||
return formStructure.wizards ? formStructure.wizards.length : 0;
|
||||
}
|
||||
|
||||
getProgress() {
|
||||
return ((this.state.currentWizardIndex + 1) / this.getTotalWizards()) * 100;
|
||||
}
|
||||
|
||||
validateCurrentWizard() {
|
||||
const fields = this.getCurrentWizardFields();
|
||||
const errors = {};
|
||||
|
||||
fields.forEach(field => {
|
||||
const value = this.state.formData[field.id];
|
||||
|
||||
if (field.required && (!value || (Array.isArray(value) && value.length === 0))) {
|
||||
errors[field.id] = `${field.label} is required`;
|
||||
}
|
||||
|
||||
// Email validation
|
||||
if (field.type === 'email' && value) {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(value)) {
|
||||
errors[field.id] = 'Please enter a valid email address';
|
||||
}
|
||||
}
|
||||
|
||||
// Phone validation
|
||||
if (field.type === 'phone' && value) {
|
||||
const phoneRegex = /^[\d\s\-\+\(\)]+$/;
|
||||
if (!phoneRegex.test(value)) {
|
||||
errors[field.id] = 'Please enter a valid phone number';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.setState({ errors });
|
||||
return Object.keys(errors).length === 0;
|
||||
}
|
||||
|
||||
handleFieldChange = (fieldId, value) => {
|
||||
this.setState({
|
||||
formData: { ...this.state.formData, [fieldId]: value },
|
||||
errors: { ...this.state.errors, [fieldId]: null }
|
||||
});
|
||||
}
|
||||
|
||||
handleNext = () => {
|
||||
if (this.validateCurrentWizard()) {
|
||||
if (this.state.currentWizardIndex < this.getTotalWizards() - 1) {
|
||||
this.setState({ currentWizardIndex: this.state.currentWizardIndex + 1 });
|
||||
} else {
|
||||
this.handleSubmit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handlePrevious = () => {
|
||||
if (this.state.currentWizardIndex > 0) {
|
||||
this.setState({ currentWizardIndex: this.state.currentWizardIndex - 1 });
|
||||
}
|
||||
}
|
||||
|
||||
handleSubmit = async () => {
|
||||
if (!this.validateCurrentWizard()) return;
|
||||
|
||||
this.setState({ isSubmitting: true });
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
|
||||
// Add form data
|
||||
Object.keys(this.state.formData).forEach(key => {
|
||||
const value = this.state.formData[key];
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((file, index) => {
|
||||
formData.append(`${key}_${index}`, file);
|
||||
});
|
||||
} else {
|
||||
formData.append(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
// Add CSRF token
|
||||
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]');
|
||||
if (csrfToken) {
|
||||
formData.append('csrfmiddlewaretoken', csrfToken.value);
|
||||
}
|
||||
|
||||
const response = await fetch(`/recruitment/forms/${formId}/submit/`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
this.setState({ isSubmitted: true });
|
||||
const modal = new bootstrap.Modal(document.getElementById('successModal'));
|
||||
modal.show();
|
||||
} else {
|
||||
throw new Error(result.error || 'Submission failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Submission error:', error);
|
||||
document.getElementById('errorMessage').textContent = error.message;
|
||||
const modal = new bootstrap.Modal(document.getElementById('errorModal'));
|
||||
modal.show();
|
||||
} finally {
|
||||
this.setState({ isSubmitting: false });
|
||||
}
|
||||
}
|
||||
|
||||
resetForm = () => {
|
||||
this.setState({
|
||||
currentWizardIndex: 0,
|
||||
formData: {},
|
||||
errors: {},
|
||||
isSubmitting: false,
|
||||
isSubmitted: false
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const currentWizard = this.getCurrentWizard();
|
||||
const currentFields = this.getCurrentWizardFields();
|
||||
|
||||
if (this.state.isSubmitted) {
|
||||
return html`
|
||||
<div class="text-center py-5">
|
||||
<i class="fas fa-check-circle text-success fa-4x mb-3"></i>
|
||||
<h3>Thank You!</h3>
|
||||
<p class="text-muted">Your form has been submitted successfully.</p>
|
||||
<button class="btn btn-primary" onClick=${this.resetForm}>
|
||||
Submit Another Response
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="form-preview">
|
||||
<!-- Progress Bar -->
|
||||
${formStructure.settings && formStructure.settings.showProgress && this.getTotalWizards() > 1 ? html`
|
||||
<div class="progress mb-4" style="height: 6px;">
|
||||
<div class="progress-bar"
|
||||
style="width: ${this.getProgress()}%; transition: width 0.3s ease;">
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- Wizard Title -->
|
||||
${currentWizard ? html`
|
||||
<h4 class="mb-4">${currentWizard.title || 'Step ' + (this.state.currentWizardIndex + 1)}</h4>
|
||||
` : ''}
|
||||
|
||||
<!-- Form Fields -->
|
||||
<form id="preview-form">
|
||||
${currentFields.map(field =>
|
||||
createPreviewField(field, {
|
||||
key: field.id,
|
||||
value: this.state.formData[field.id],
|
||||
onChange: this.handleFieldChange,
|
||||
error: this.state.errors[field.id]
|
||||
})
|
||||
)}
|
||||
</form>
|
||||
|
||||
<!-- Navigation Buttons -->
|
||||
<div class="d-flex justify-content-between mt-4">
|
||||
<button type="button"
|
||||
class="btn btn-outline-secondary"
|
||||
onClick=${this.handlePrevious}
|
||||
disabled=${this.state.currentWizardIndex === 0 || this.state.isSubmitting}>
|
||||
<i class="fas fa-arrow-left"></i> Previous
|
||||
</button>
|
||||
|
||||
<button type="button"
|
||||
class="btn btn-primary"
|
||||
onClick=${this.handleNext}
|
||||
disabled=${this.state.isSubmitting}>
|
||||
${this.state.isSubmitting ? html`
|
||||
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
|
||||
Submitting...
|
||||
` : ''}
|
||||
${this.state.currentWizardIndex === this.getTotalWizards() - 1 ?
|
||||
html`<i class="fas fa-check"></i> Submit` :
|
||||
html`Next <i class="fas fa-arrow-right"></i>`
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize FilePond plugins
|
||||
if (typeof FilePond !== 'undefined') {
|
||||
FilePond.registerPlugin(
|
||||
FilePondPluginFileValidateType,
|
||||
FilePondPluginFileValidateSize
|
||||
);
|
||||
}
|
||||
|
||||
// Render the form preview
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const container = document.getElementById('form-preview-container');
|
||||
if (container) {
|
||||
render(html`<${FormPreview} />`, container);
|
||||
}
|
||||
});
|
||||
|
||||
// Global function for modal reset
|
||||
window.resetForm = function() {
|
||||
const container = document.getElementById('form-preview-container');
|
||||
if (container) {
|
||||
render(html`<${FormPreview} />`, container);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
{% endblock %}
|
||||
428
templates/forms/form_submissions.html
Normal file
@ -0,0 +1,428 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ form.title }} - Submissions{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1><i class="fas fa-list"></i> Form Submissions</h1>
|
||||
<p class="text-muted mb-0">{{ form.title }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{% url 'form_preview' form.id %}" class="btn btn-outline-primary me-2" target="_blank">
|
||||
<i class="fas fa-eye"></i> Preview Form
|
||||
</a>
|
||||
<a href="{% url 'form_list' %}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Back to Forms
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistics Cards -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="card bg-primary text-white">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<h4 class="mb-0">{{ page_obj.paginator.count }}</h4>
|
||||
<small>Total Submissions</small>
|
||||
</div>
|
||||
<div class="align-self-center">
|
||||
<i class="fas fa-users fa-2x opacity-75"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card bg-success text-white">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<h4 class="mb-0">{{ form.submissions.all|length }}</h4>
|
||||
<small>All Time</small>
|
||||
</div>
|
||||
<div class="align-self-center">
|
||||
<i class="fas fa-chart-line fa-2x opacity-75"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card bg-info text-white">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<h4 class="mb-0">
|
||||
{% if form.submissions.first %}
|
||||
{{ form.submissions.first.submitted_at|timesince }}
|
||||
{% else %}
|
||||
No submissions
|
||||
{% endif %}
|
||||
</h4>
|
||||
<small>Latest</small>
|
||||
</div>
|
||||
<div class="align-self-center">
|
||||
<i class="fas fa-clock fa-2x opacity-75"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card bg-warning text-white">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<h4 class="mb-0">{{ form.created_at|date:"M d" }}</h4>
|
||||
<small>Form Created</small>
|
||||
</div>
|
||||
<div class="align-self-center">
|
||||
<i class="fas fa-calendar fa-2x opacity-75"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Export Options -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="fas fa-download"></i> Export Options</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<button class="btn btn-outline-primary me-2" onclick="exportSubmissions('csv')">
|
||||
<i class="fas fa-file-csv"></i> Export as CSV
|
||||
</button>
|
||||
<button class="btn btn-outline-success me-2" onclick="exportSubmissions('excel')">
|
||||
<i class="fas fa-file-excel"></i> Export as Excel
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" onclick="exportSubmissions('json')">
|
||||
<i class="fas fa-file-code"></i> Export as JSON
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-md-6 text-end">
|
||||
<small class="text-muted">
|
||||
Download all submission data for analysis
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submissions List -->
|
||||
{% if page_obj %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Recent Submissions</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Submitted</th>
|
||||
<th>IP Address</th>
|
||||
<th>User Agent</th>
|
||||
<th>Data Fields</th>
|
||||
<th>Files</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for submission in page_obj %}
|
||||
<tr>
|
||||
<td>
|
||||
<span class="badge bg-primary">{{ submission.id }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div>
|
||||
<strong>{{ submission.submitted_at|date:"M d, Y" }}</strong><br>
|
||||
<small class="text-muted">{{ submission.submitted_at|time:"g:i A" }}</small>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<code class="text-muted">{{ submission.ip_address|default:"N/A" }}</code>
|
||||
</td>
|
||||
<td>
|
||||
<small class="text-muted" title="{{ submission.user_agent }}">
|
||||
{% if submission.user_agent %}
|
||||
{{ submission.user_agent|truncatechars:50 }}
|
||||
{% else %}
|
||||
N/A
|
||||
{% endif %}
|
||||
</small>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-info">{{ submission.submission_data.keys|length }} fields</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-success">{{ submission.files.count }} files</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button type="button" class="btn btn-outline-primary" onclick="viewSubmission({{ submission.id }})" title="View Details">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-info" onclick="downloadSubmission({{ submission.id }})" title="Download">
|
||||
<i class="fas fa-download"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-danger" onclick="deleteSubmission({{ submission.id }})" title="Delete">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if page_obj.has_other_pages %}
|
||||
<nav aria-label="Submissions pagination" class="mt-4">
|
||||
<ul class="pagination justify-content-center">
|
||||
{% if page_obj.has_previous %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page=1">First</a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.previous_page_number }}">Previous</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
<li class="page-item active">
|
||||
<span class="page-link">{{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span>
|
||||
</li>
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.next_page_number }}">Next</a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}">Last</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="card">
|
||||
<div class="card-body text-center py-5">
|
||||
<i class="fas fa-inbox fa-3x text-muted mb-3"></i>
|
||||
<h4>No submissions yet</h4>
|
||||
<p class="text-muted">This form hasn't received any submissions yet.</p>
|
||||
<a href="{% url 'form_preview' form.id %}" class="btn btn-primary" target="_blank">
|
||||
<i class="fas fa-external-link-alt"></i> Test Form
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submission Detail Modal -->
|
||||
<div class="modal fade" id="submissionModal" tabindex="-1" aria-labelledby="submissionModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="submissionModalLabel">
|
||||
<i class="fas fa-file-alt"></i> Submission Details
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="submissionDetails">
|
||||
<!-- Submission details will be loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
<button type="button" class="btn btn-primary" id="downloadSubmissionBtn">
|
||||
<i class="fas fa-download"></i> Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
let currentSubmissionId = null;
|
||||
|
||||
function viewSubmission(submissionId) {
|
||||
currentSubmissionId = submissionId;
|
||||
|
||||
fetch(`/recruitment/api/submissions/${submissionId}/`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
displaySubmissionDetails(data.submission);
|
||||
const modal = new bootstrap.Modal(document.getElementById('submissionModal'));
|
||||
modal.show();
|
||||
} else {
|
||||
alert('Error loading submission: ' + data.error);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('Error loading submission details');
|
||||
});
|
||||
}
|
||||
|
||||
function displaySubmissionDetails(submission) {
|
||||
const detailsHtml = `
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6>Submission Information</h6>
|
||||
<table class="table table-sm">
|
||||
<tr>
|
||||
<td><strong>ID:</strong></td>
|
||||
<td>${submission.id}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Submitted:</strong></td>
|
||||
<td>${new Date(submission.submitted_at).toLocaleString()}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>IP Address:</strong></td>
|
||||
<td>${submission.ip_address || 'N/A'}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6>Technical Details</h6>
|
||||
<table class="table table-sm">
|
||||
<tr>
|
||||
<td><strong>User Agent:</strong></td>
|
||||
<td><small>${submission.user_agent || 'N/A'}</small></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Files:</strong></td>
|
||||
<td>${submission.files ? submission.files.length : 0}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Data Fields:</strong></td>
|
||||
<td>${Object.keys(submission.submission_data).length}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<h6>Submitted Data</h6>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Field</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${Object.entries(submission.submission_data).map(([key, value]) => `
|
||||
<tr>
|
||||
<td><strong>${key}:</strong></td>
|
||||
<td>${formatFieldValue(value)}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
${submission.files && submission.files.length > 0 ? `
|
||||
<hr>
|
||||
<h6>Uploaded Files</h6>
|
||||
<div class="list-group">
|
||||
${submission.files.map(file => `
|
||||
<div class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<i class="fas fa-file me-2"></i>
|
||||
<strong>${file.original_filename}</strong>
|
||||
<br>
|
||||
<small class="text-muted">
|
||||
${file.file_size ? formatFileSize(file.file_size) : 'Unknown size'} •
|
||||
Uploaded ${new Date(file.uploaded_at).toLocaleString()}
|
||||
</small>
|
||||
</div>
|
||||
<a href="${file.file_url}" class="btn btn-sm btn-outline-primary" target="_blank">
|
||||
<i class="fas fa-download"></i> Download
|
||||
</a>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
|
||||
document.getElementById('submissionDetails').innerHTML = detailsHtml;
|
||||
document.getElementById('downloadSubmissionBtn').onclick = () => downloadSubmission(submission.id);
|
||||
}
|
||||
|
||||
function formatFieldValue(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.join(', ');
|
||||
}
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
return value || 'N/A';
|
||||
}
|
||||
|
||||
function formatFileSize(bytes) {
|
||||
if (!bytes) return 'Unknown';
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function downloadSubmission(submissionId) {
|
||||
window.open(`/recruitment/api/submissions/${submissionId}/download/`, '_blank');
|
||||
}
|
||||
|
||||
function deleteSubmission(submissionId) {
|
||||
if (confirm('Are you sure you want to delete this submission? This action cannot be undone.')) {
|
||||
fetch(`/recruitment/api/submissions/${submissionId}/delete/`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRFToken': getCsrfToken()
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Error deleting submission: ' + data.error);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('Error deleting submission');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function exportSubmissions(format) {
|
||||
window.open(`/recruitment/api/forms/{{ form.id }}/export/?format=${format}`, '_blank');
|
||||
}
|
||||
|
||||
function getCsrfToken() {
|
||||
const cookie = document.cookie.split(';').find(c => c.trim().startsWith('csrftoken='));
|
||||
return cookie ? cookie.split('=')[1] : '';
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
3
templates/icons/add.html
Normal file
@ -0,0 +1,3 @@
|
||||
<svg class="heroicon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 4v16m8-8H4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 219 B |
3
templates/icons/back.html
Normal file
@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6 heroicon">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 15 3 9m0 0 6-6M3 9h12a6 6 0 0 1 0 12h-3" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 251 B |
4
templates/icons/dashboard.html
Normal file
@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6 heroicon">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 6a7.5 7.5 0 1 0 7.5 7.5h-7.5V6Z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 10.5H21A7.5 7.5 0 0 0 13.5 3v7.5Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 347 B |
3
templates/icons/delete.html
Normal file
@ -0,0 +1,3 @@
|
||||
<svg class="heroicon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 330 B |
3
templates/icons/download.html
Normal file
@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6 heroicon">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9.75v6.75m0 0-3-3m3 3 3-3m-8.25 6a4.5 4.5 0 0 1-1.41-8.775 5.25 5.25 0 0 1 10.233-2.33 3 3 0 0 1 3.758 3.848A3.752 3.752 0 0 1 18 19.5H6.75Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 352 B |
3
templates/icons/edit.html
Normal file
@ -0,0 +1,3 @@
|
||||
<svg class="heroicon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 323 B |
3
templates/icons/jobs.html
Normal file
@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6 heroicon">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 14.15v4.25c0 1.094-.787 2.036-1.872 2.18-2.087.277-4.216.42-6.378.42s-4.291-.143-6.378-.42c-1.085-.144-1.872-1.086-1.872-2.18v-4.25m16.5 0a2.18 2.18 0 0 0 .75-1.661V8.706c0-1.081-.768-2.015-1.837-2.175a48.114 48.114 0 0 0-3.413-.387m4.5 8.006c-.194.165-.42.295-.673.38A23.978 23.978 0 0 1 12 15.75c-2.648 0-5.195-.429-7.577-1.22a2.016 2.016 0 0 1-.673-.38m0 0A2.18 2.18 0 0 1 3 12.489V8.706c0-1.081.768-2.015 1.837-2.175a48.111 48.111 0 0 1 3.413-.387m7.5 0V5.25A2.25 2.25 0 0 0 13.5 3h-3a2.25 2.25 0 0 0-2.25 2.25v.894m7.5 0a48.667 48.667 0 0 0-7.5 0M12 12.75h.008v.008H12v-.008Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 795 B |
3
templates/icons/logout.html
Normal file
@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6 heroicon">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6a2.25 2.25 0 0 0-2.25 2.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15m3 0 3-3m0 0-3-3m3 3H9" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 354 B |
3
templates/icons/meeting.html
Normal file
@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6 heroicon">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 412 B |
3
templates/icons/question-mark.html
Normal file
@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 5.25h.008v.008H12v-.008Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 398 B |
3
templates/icons/right.html
Normal file
@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6 heroicon">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 229 B |
3
templates/icons/users.html
Normal file
@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6 heroicon">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 585 B |
4
templates/icons/view.html
Normal file
@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6 heroicon">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 490 B |
32
templates/includes/delete_modal.html
Normal file
@ -0,0 +1,32 @@
|
||||
{% load i18n %}
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="deleteModalLabel">
|
||||
<svg class="heroicon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
{% trans "Confirm Delete" %}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p id="deleteModalMessage">{% trans "Are you sure you want to delete this item?" %}</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% trans "Cancel" %}</button>
|
||||
<form id="deleteForm" method="post">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<svg class="heroicon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
{% trans "Delete" %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
21
templates/includes/search_form.html
Normal file
@ -0,0 +1,21 @@
|
||||
{% load i18n %}
|
||||
<form method="get" class="d-flex gap-2 align-items-center">
|
||||
<div class="input-group flex-grow-1" style="max-width: 300px;">
|
||||
<span class="input-group-text bg-white border-end-0">
|
||||
<svg class="heroicon icon-sm" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
</span>
|
||||
<input type="text" name="search"
|
||||
class="form-control border-start-0"
|
||||
placeholder="{% trans 'Search...' %}"
|
||||
value="{{ search_query }}"
|
||||
aria-label="{% trans 'Search' %}">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<svg class="heroicon icon-sm" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
{% trans "Search" %}
|
||||
</button>
|
||||
</form>
|
||||
103
templates/jobs/apply_form.html
Normal file
@ -0,0 +1,103 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Apply for {{ job.title }} - University Careers{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2><i class="fas fa-file-signature"></i> Apply for {{ job.title }}</h2>
|
||||
<p class="text-muted">{{ job.department }} • {{ job.get_location_display }}</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
|
||||
<h4>Personal Information</h4>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">First Name *</label>
|
||||
{{ form.first_name }}
|
||||
{% if form.first_name.errors %}<div class="text-danger">{{ form.first_name.errors }}</div>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Last Name *</label>
|
||||
{{ form.last_name }}
|
||||
{% if form.last_name.errors %}<div class="text-danger">{{ form.last_name.errors }}</div>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Email *</label>
|
||||
{{ form.email }}
|
||||
{% if form.email.errors %}<div class="text-danger">{{ form.email.errors }}</div>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Phone</label>
|
||||
{{ form.phone }}
|
||||
{% if form.phone.errors %}<div class="text-danger">{{ form.phone.errors }}</div>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 class="mt-4">Documents</h4>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Resume/CV * (PDF or Word)</label>
|
||||
{{ form.resume }}
|
||||
{% if form.resume.errors %}<div class="text-danger">{{ form.resume.errors }}</div>{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Cover Letter (Optional)</label>
|
||||
{{ form.cover_letter }}
|
||||
{% if form.cover_letter.errors %}<div class="text-danger">{{ form.cover_letter.errors }}</div>{% endif %}
|
||||
</div>
|
||||
|
||||
<h4 class="mt-4">Additional Information</h4>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">LinkedIn Profile</label>
|
||||
{{ form.linkedin_profile }}
|
||||
{% if form.linkedin_profile.errors %}<div class="text-danger">{{ form.linkedin_profile.errors }}</div>{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Portfolio/Website</label>
|
||||
{{ form.portfolio_url }}
|
||||
{% if form.portfolio_url.errors %}<div class="text-danger">{{ form.portfolio_url.errors }}</div>{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Salary Expectations</label>
|
||||
{{ form.salary_expectations }}
|
||||
{% if form.salary_expectations.errors %}<div class="text-danger">{{ form.salary_expectations.errors }}</div>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Availability</label>
|
||||
{{ form.availability }}
|
||||
{% if form.availability.errors %}<div class="text-danger">{{ form.availability.errors }}</div>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100 mt-3">
|
||||
<i class="fas fa-paper-plane"></i> Submit Application
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
269
templates/jobs/create_job.html
Normal file
@ -0,0 +1,269 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<form method="post" id="jobForm" class="mb-5">
|
||||
{% csrf_token %}
|
||||
|
||||
<!-- Basic Information Section -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-light">
|
||||
<h5 class="mb-0"><i class="fas fa-info-circle"></i> Basic Information</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.title.id_for_label }}" class="form-label">Job Title <span class="text-danger">*</span></label>
|
||||
{{ form.title }}
|
||||
{% if form.title.errors %}
|
||||
<div class="text-danger mt-1">{{ form.title.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.job_type.id_for_label }}" class="form-label">Job Type <span class="text-danger">*</span></label>
|
||||
{{ form.job_type }}
|
||||
{% if form.job_type.errors %}
|
||||
<div class="text-danger mt-1">{{ form.job_type.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.department.id_for_label }}" class="form-label">Department</label>
|
||||
{{ form.department }}
|
||||
{% if form.department.errors %}
|
||||
<div class="text-danger mt-1">{{ form.department.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.position_number.id_for_label }}" class="form-label">Position Number</label>
|
||||
{{ form.position_number }}
|
||||
{% if form.position_number.errors %}
|
||||
<div class="text-danger mt-1">{{ form.position_number.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.workplace_type.id_for_label }}" class="form-label">Workplace Type <span class="text-danger">*</span></label>
|
||||
{{ form.workplace_type }}
|
||||
{% if form.workplace_type.errors %}
|
||||
<div class="text-danger mt-1">{{ form.workplace_type.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.created_by.id_for_label }}" class="form-label">Created By</label>
|
||||
{{ form.created_by }}
|
||||
{% if form.created_by.errors %}
|
||||
<div class="text-danger mt-1">{{ form.created_by.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Location Section -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-light">
|
||||
<h5 class="mb-0"><i class="fas fa-map-marker-alt"></i> Location</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.location_city.id_for_label }}" class="form-label">City</label>
|
||||
{{ form.location_city }}
|
||||
{% if form.location_city.errors %}
|
||||
<div class="text-danger mt-1">{{ form.location_city.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.location_state.id_for_label }}" class="form-label">State/Province</label>
|
||||
{{ form.location_state }}
|
||||
{% if form.location_state.errors %}
|
||||
<div class="text-danger mt-1">{{ form.location_state.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.location_country.id_for_label }}" class="form-label">Country</label>
|
||||
{{ form.location_country }}
|
||||
{% if form.location_country.errors %}
|
||||
<div class="text-danger mt-1">{{ form.location_country.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Job Details Section -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-light">
|
||||
<h5 class="mb-0"><i class="fas fa-file-alt"></i> Job Details</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.description.id_for_label }}" class="form-label">Job Description <span class="text-danger">*</span></label>
|
||||
{{ form.description }}
|
||||
{% if form.description.errors %}
|
||||
<div class="text-danger mt-1">{{ form.description.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.qualifications.id_for_label }}" class="form-label">Qualifications and Requirements</label>
|
||||
{{ form.qualifications }}
|
||||
{% if form.qualifications.errors %}
|
||||
<div class="text-danger mt-1">{{ form.qualifications.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.salary_range.id_for_label }}" class="form-label">Salary Range</label>
|
||||
{{ form.salary_range }}
|
||||
{% if form.salary_range.errors %}
|
||||
<div class="text-danger mt-1">{{ form.salary_range.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.benefits.id_for_label }}" class="form-label">Benefits</label>
|
||||
{{ form.benefits }}
|
||||
{% if form.benefits.errors %}
|
||||
<div class="text-danger mt-1">{{ form.benefits.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Application Information Section -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-light">
|
||||
<h5 class="mb-0"><i class="fas fa-file-signature"></i> Application Information</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.application_url.id_for_label }}" class="form-label">Application URL <span class="text-danger">*</span></label>
|
||||
{{ form.application_url }}
|
||||
{% if form.application_url.errors %}
|
||||
<div class="text-danger mt-1">{{ form.application_url.errors }}</div>
|
||||
{% endif %}
|
||||
<div class="form-text">Full URL where candidates will apply</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.application_deadline.id_for_label }}" class="form-label">Application Deadline</label>
|
||||
{{ form.application_deadline }}
|
||||
{% if form.application_deadline.errors %}
|
||||
<div class="text-danger mt-1">{{ form.application_deadline.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.start_date.id_for_label }}" class="form-label">Desired Start Date</label>
|
||||
{{ form.start_date }}
|
||||
{% if form.start_date.errors %}
|
||||
<div class="text-danger mt-1">{{ form.start_date.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.application_instructions.id_for_label }}" class="form-label">Application Instructions</label>
|
||||
{{ form.application_instructions }}
|
||||
{% if form.application_instructions.errors %}
|
||||
<div class="text-danger mt-1">{{ form.application_instructions.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Post Reach -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-light">
|
||||
<h5 class="mb-0"><i class="fas fa-star"></i>Post Reach Field</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.reporting_to.id_for_label }}" class="form-label">Hashtags</label>
|
||||
{{ form.hash_tags }}
|
||||
{% if form.hash_tags.errors %}
|
||||
<div class="text-danger mt-1">{{ form.hash_tags.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Internal Information Section -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-light">
|
||||
<h5 class="mb-0"><i class="fas fa-building"></i> Internal Information</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.reporting_to.id_for_label }}" class="form-label">Reports To</label>
|
||||
{{ form.reporting_to }}
|
||||
{% if form.reporting_to.errors %}
|
||||
<div class="text-danger mt-1">{{ form.reporting_to.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.open_positions.id_for_label }}" class="form-label">Open Positions</label>
|
||||
{{ form.open_positions }}
|
||||
{% if form.open_positions.errors %}
|
||||
<div class="text-danger mt-1">{{ form.open_positions.errors }}</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="{% url 'job_list' %}" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Cancel
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i> Create Job
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
||||
259
templates/jobs/edit_job.html
Normal file
@ -0,0 +1,259 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Edit {{ job.title }} - University ATS{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-10">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2><i class="fas fa-edit"></i> Edit Job Posting</h2>
|
||||
<small class="text-muted">Internal ID: {{ job.internal_job_id }}</small>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" id="jobForm">
|
||||
{% csrf_token %}
|
||||
|
||||
<!-- Basic Information Section -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-light">
|
||||
<h5 class="mb-0"><i class="fas fa-info-circle"></i> Basic Information</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.title.id_for_label }}" class="form-label">Job Title <span class="text-danger">*</span></label>
|
||||
{{ form.title }}
|
||||
{% if form.title.errors %}
|
||||
<div class="text-danger mt-1">{{ form.title.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.job_type.id_for_label }}" class="form-label">Job Type <span class="text-danger">*</span></label>
|
||||
{{ form.job_type }}
|
||||
{% if form.job_type.errors %}
|
||||
<div class="text-danger mt-1">{{ form.job_type.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.department.id_for_label }}" class="form-label">Department</label>
|
||||
{{ form.department }}
|
||||
{% if form.department.errors %}
|
||||
<div class="text-danger mt-1">{{ form.department.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.position_number.id_for_label }}" class="form-label">Position Number</label>
|
||||
{{ form.position_number }}
|
||||
{% if form.position_number.errors %}
|
||||
<div class="text-danger mt-1">{{ form.position_number.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.workplace_type.id_for_label }}" class="form-label">Workplace Type <span class="text-danger">*</span></label>
|
||||
{{ form.workplace_type }}
|
||||
{% if form.workplace_type.errors %}
|
||||
<div class="text-danger mt-1">{{ form.workplace_type.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.created_by.id_for_label }}" class="form-label">Created By</label>
|
||||
{{ form.created_by }}
|
||||
{% if form.created_by.errors %}
|
||||
<div class="text-danger mt-1">{{ form.created_by.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Location Section -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-light">
|
||||
<h5 class="mb-0"><i class="fas fa-map-marker-alt"></i> Location</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.location_city.id_for_label }}" class="form-label">City</label>
|
||||
{{ form.location_city }}
|
||||
{% if form.location_city.errors %}
|
||||
<div class="text-danger mt-1">{{ form.location_city.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.location_state.id_for_label }}" class="form-label">State/Province</label>
|
||||
{{ form.location_state }}
|
||||
{% if form.location_state.errors %}
|
||||
<div class="text-danger mt-1">{{ form.location_state.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.location_country.id_for_label }}" class="form-label">Country</label>
|
||||
{{ form.location_country }}
|
||||
{% if form.location_country.errors %}
|
||||
<div class="text-danger mt-1">{{ form.location_country.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Job Details Section -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-light">
|
||||
<h5 class="mb-0"><i class="fas fa-file-alt"></i> Job Details</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.description.id_for_label }}" class="form-label">Job Description <span class="text-danger">*</span></label>
|
||||
{{ form.description }}
|
||||
{% if form.description.errors %}
|
||||
<div class="text-danger mt-1">{{ form.description.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.qualifications.id_for_label }}" class="form-label">Qualifications and Requirements</label>
|
||||
{{ form.qualifications }}
|
||||
{% if form.qualifications.errors %}
|
||||
<div class="text-danger mt-1">{{ form.qualifications.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.salary_range.id_for_label }}" class="form-label">Salary Range</label>
|
||||
{{ form.salary_range }}
|
||||
{% if form.salary_range.errors %}
|
||||
<div class="text-danger mt-1">{{ form.salary_range.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.benefits.id_for_label }}" class="form-label">Benefits</label>
|
||||
{{ form.benefits }}
|
||||
{% if form.benefits.errors %}
|
||||
<div class="text-danger mt-1">{{ form.benefits.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Application Information Section -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-light">
|
||||
<h5 class="mb-0"><i class="fas fa-file-signature"></i> Application Information</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.application_url.id_for_label }}" class="form-label">Application URL <span class="text-danger">*</span></label>
|
||||
{{ form.application_url }}
|
||||
{% if form.application_url.errors %}
|
||||
<div class="text-danger mt-1">{{ form.application_url.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.application_deadline.id_for_label }}" class="form-label">Application Deadline</label>
|
||||
{{ form.application_deadline }}
|
||||
{% if form.application_deadline.errors %}
|
||||
<div class="text-danger mt-1">{{ form.application_deadline.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.start_date.id_for_label }}" class="form-label">Desired Start Date</label>
|
||||
{{ form.start_date }}
|
||||
{% if form.start_date.errors %}
|
||||
<div class="text-danger mt-1">{{ form.start_date.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.application_instructions.id_for_label }}" class="form-label">Application Instructions</label>
|
||||
{{ form.application_instructions }}
|
||||
{% if form.application_instructions.errors %}
|
||||
<div class="text-danger mt-1">{{ form.application_instructions.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Internal Information Section -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-light">
|
||||
<h5 class="mb-0"><i class="fas fa-building"></i> Internal Information</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.reporting_to.id_for_label }}" class="form-label">Reports To</label>
|
||||
{{ form.reporting_to }}
|
||||
{% if form.reporting_to.errors %}
|
||||
<div class="text-danger mt-1">{{ form.reporting_to.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.open_positions.id_for_label }}" class="form-label">Open positions</label>
|
||||
{{ form.open_positions }}
|
||||
{% if form.open_positions.errors %}
|
||||
<div class="text-danger mt-1">{{ form.open_positions.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="{% url 'job_detail' job.slug %}" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Cancel
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i> Update Job
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
275
templates/jobs/job_candidates_list.html
Normal file
@ -0,0 +1,275 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static i18n %}
|
||||
|
||||
{% block title %}{{ job.title }} - Applicants{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-1">
|
||||
<a href="{% url 'job_detail' job.slug %}" class="text-decoration-none">
|
||||
<i class="fas fa-arrow-left me-2"></i>
|
||||
</a>
|
||||
Applicants for "{{ job.title }}"
|
||||
</h1>
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{% url 'dashboard' %}">Dashboard</a></li>
|
||||
<li class="breadcrumb-item"><a href="{% url 'job_list' %}">Jobs</a></li>
|
||||
<li class="breadcrumb-item"><a href="{% url 'job_detail' job.slug %}">{{ job.title }}</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">Applicants</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{% url 'candidate_create_for_job' job.slug %}" class="btn btn-success">
|
||||
<i class="fas fa-user-plus"></i> Add New Applicant
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Job Summary Card -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<h5 class="card-title mb-3">{{ job.title }}</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<small class="text-muted">Department:</small>
|
||||
<div>{{ job.department|default:"Not specified" }}</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<small class="text-muted">Location:</small>
|
||||
<div>{{ job.get_location_display }}</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<small class="text-muted">Job Type:</small>
|
||||
<div>{{ job.get_job_type_display }}</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<small class="text-muted">Workplace:</small>
|
||||
<div>{{ job.get_workplace_type_display }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="text-end">
|
||||
<span class="badge bg-{{ job.status|lower }} status-badge">
|
||||
{{ job.get_status_display }}
|
||||
</span>
|
||||
{% if candidates %}
|
||||
<div class="mt-2">
|
||||
<small class="text-muted">Total Applicants:</small>
|
||||
<h4 class="text-primary mb-0">{{ candidates.count }}</h4>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search and Filters -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<form method="get" class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label for="search" class="form-label">Search Applicants</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="fas fa-search"></i></span>
|
||||
<input type="text" class="form-control" id="search" name="search"
|
||||
placeholder="Search by name, email, phone, or stage..."
|
||||
value="{{ search_query }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 d-flex align-items-end">
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="fas fa-filter"></i> Search
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-md-3 d-flex align-items-end">
|
||||
<a href="{% url 'job_candidates_list' job.slug %}" class="btn btn-outline-secondary w-100">
|
||||
<i class="fas fa-times"></i> Clear
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Candidates Table -->
|
||||
{% if candidates %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">Applicants ({{ candidates.count }})</h5>
|
||||
<div class="d-flex gap-2">
|
||||
<select class="form-select form-select-sm" style="width: auto;" onchange="window.location.href='?stage='+this.value+'&search={{ search_query }}'">
|
||||
<option value="">All Stages</option>
|
||||
<option value="Applied" {% if request.GET.stage == 'Applied' %}selected{% endif %}>Applied</option>
|
||||
<option value="Interview" {% if request.GET.stage == 'Interview' %}selected{% endif %}>Interview</option>
|
||||
<option value="Offer" {% if request.GET.stage == 'Offer' %}selected{% endif %}>Offer</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">
|
||||
<input type="checkbox" class="form-check-input" id="selectAll">
|
||||
</th>
|
||||
<th scope="col">Name</th>
|
||||
<th scope="col">Email</th>
|
||||
<th scope="col">Phone</th>
|
||||
<th scope="col">Stage</th>
|
||||
<th scope="col">Applied Date</th>
|
||||
<th scope="col" class="text-center">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for candidate in candidates %}
|
||||
<tr>
|
||||
<td>
|
||||
<input type="checkbox" class="form-check-input candidate-checkbox" value="{{ candidate.slug }}">
|
||||
</td>
|
||||
<td>
|
||||
<div>
|
||||
<strong>{{ candidate.first_name }} {{ candidate.last_name }}</strong>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ candidate.email }}</td>
|
||||
<td>{{ candidate.phone|default:"-" }}</td>
|
||||
<td>
|
||||
<span class="badge bg-{% if candidate.stage == 'Applied' %}primary{% elif candidate.stage == 'Interview' %}info{% elif candidate.stage == 'Offer' %}success{% else %}secondary{% endif %}">
|
||||
{{ candidate.stage }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ candidate.created_at|date:"M d, Y" }}</td>
|
||||
<td class="text-center">
|
||||
<div class="btn-group" role="group">
|
||||
<a href="{% url 'candidate_detail' candidate.slug %}" class="btn btn-outline-primary btn-sm" title="View">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
{% if user.is_staff %}
|
||||
<a href="{% url 'candidate_update' candidate.slug %}" class="btn btn-outline-secondary btn-sm" title="Edit">
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" title="Delete"
|
||||
data-bs-toggle="deleteModal"
|
||||
data-delete-url="{% url 'candidate_delete' candidate.slug %}"
|
||||
data-item-name="{{ candidate.first_name }} {{ candidate.last_name }}">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Actions -->
|
||||
<div class="card-footer d-flex justify-content-between align-items-center">
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="me-3">Selected: <span id="selectedCount">0</span></span>
|
||||
<button class="btn btn-sm btn-outline-primary me-2" onclick="bulkAction('interview')"
|
||||
{% if not user.is_staff %}disabled{% endif %}>
|
||||
<i class="fas fa-comments"></i> Mark as Interview
|
||||
</button>
|
||||
<button class="btn btn-sm btn-success" onclick="bulkAction('offer')"
|
||||
{% if not user.is_staff %}disabled{% endif %}>
|
||||
<i class="fas fa-handshake"></i> Mark as Offer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if is_paginated %}
|
||||
<nav aria-label="Page navigation">
|
||||
<ul class="pagination pagination-sm mb-0">
|
||||
{% if page_obj.has_previous %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if search_query %}&search={{ search_query }}{% endif %}" aria-label="Previous">
|
||||
<span aria-hidden="true">«</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% for num in page_obj.paginator.page_range %}
|
||||
{% if page_obj.number == num %}
|
||||
<li class="page-item active"><span class="page-link">{{ num }}</span></li>
|
||||
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ num }}{% if search_query %}&search={{ search_query }}{% endif %}">{{ num }}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% if search_query %}&search={{ search_query }}{% endif %}" aria-label="Next">
|
||||
<span aria-hidden="true">»</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="fas fa-user-slash fa-3x text-muted mb-3"></i>
|
||||
<h4 class="text-muted">No applicants found</h4>
|
||||
<p class="text-muted">There are no candidates who have applied for this position yet.</p>
|
||||
<a href="{% url 'candidate_create_for_job' job.slug %}" class="btn btn-primary">
|
||||
<i class="fas fa-user-plus"></i> Add First Applicant
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Delete Modal -->
|
||||
{% include "includes/delete_modal.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Select all checkbox functionality
|
||||
document.getElementById('selectAll').addEventListener('change', function() {
|
||||
const checkboxes = document.querySelectorAll('.candidate-checkbox');
|
||||
checkboxes.forEach(checkbox => {
|
||||
checkbox.checked = this.checked;
|
||||
});
|
||||
updateSelectedCount();
|
||||
});
|
||||
|
||||
// Individual checkbox change
|
||||
document.querySelectorAll('.candidate-checkbox').forEach(checkbox => {
|
||||
checkbox.addEventListener('change', updateSelectedCount);
|
||||
});
|
||||
|
||||
function updateSelectedCount() {
|
||||
const checkedBoxes = document.querySelectorAll('.candidate-checkbox:checked');
|
||||
document.getElementById('selectedCount').textContent = checkedBoxes.length;
|
||||
}
|
||||
|
||||
function bulkAction(stage) {
|
||||
const selectedCandidates = Array.from(document.querySelectorAll('.candidate-checkbox:checked'))
|
||||
.map(cb => cb.value);
|
||||
|
||||
if (selectedCandidates.length === 0) {
|
||||
alert('Please select at least one candidate.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (confirm(`Mark ${selectedCandidates.length} candidate(s) as ${stage}?`)) {
|
||||
// Here you would typically make an AJAX request to update the candidates
|
||||
console.log('Updating candidates:', selectedCandidates, 'to stage:', stage);
|
||||
// For now, just show a message
|
||||
alert(`Bulk update functionality would mark ${selectedCandidates.length} candidates as ${stage}`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
292
templates/jobs/job_detail.html
Normal file
@ -0,0 +1,292 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ job.title }} - University ATS{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h2>{{ job.title }}</h2>
|
||||
<span class="badge bg-{{ job.status|lower }} status-badge">
|
||||
{{ job.get_status_display }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Job Details -->
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<strong>Department:</strong> {{ job.department|default:"Not specified" }}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<strong>Position Number:</strong> {{ job.position_number|default:"Not specified" }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<strong>Job Type:</strong> {{ job.get_job_type_display }}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<strong>Workplace:</strong> {{ job.get_workplace_type_display }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<strong>Location:</strong> {{ job.get_location_display }}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<strong>Created By:</strong> {{ job.created_by|default:"Not specified" }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if job.salary_range %}
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
<strong>Salary Range:</strong> {{ job.salary_range }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if job.start_date %}
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
<strong>Start Date:</strong> {{ job.start_date }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if job.application_deadline %}
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
<strong>Application Deadline:</strong> {{ job.application_deadline }}
|
||||
{% if job.is_expired %}
|
||||
<span class="badge bg-danger">EXPIRED</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
<!-- Description -->
|
||||
{% if job.description %}
|
||||
<div class="mb-3">
|
||||
<h5>Description</h5>
|
||||
<div>{{ job.description|linebreaks }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if job.qualifications %}
|
||||
<div class="mb-3">
|
||||
<h5>Qualifications</h5>
|
||||
<div>{{ job.qualifications|linebreaks }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if job.benefits %}
|
||||
<div class="mb-3">
|
||||
<h5>Benefits</h5>
|
||||
<div>{{ job.benefits|linebreaks }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if job.application_instructions %}
|
||||
<div class="mb-3">
|
||||
<h5>Application Instructions</h5>
|
||||
<div>{{ job.application_instructions|linebreaks }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Application URL -->
|
||||
{% if job.application_url %}
|
||||
<div class="mb-3">
|
||||
<button type="button" class="btn btn-info" data-bs-toggle="modal" data-bs-target="#myModalForm">
|
||||
Upload Image for Post
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<!-- LinkedIn Integration Card -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5><i class="fab fa-linkedin"></i> LinkedIn Integration</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if job.posted_to_linkedin %}
|
||||
<div class="alert alert-success">
|
||||
<i class="fas fa-check-circle"></i> Posted to LinkedIn successfully!
|
||||
</div>
|
||||
{% if job.linkedin_post_url %}
|
||||
<a href="{{ job.linkedin_post_url }}" target="_blank" class="btn btn-primary w-100 mb-2">
|
||||
<i class="fab fa-linkedin"></i> View on LinkedIn
|
||||
</a>
|
||||
{% endif %}
|
||||
<small class="text-muted">Posted on: {{ job.linkedin_posted_at|date:"M d, Y" }}</small>
|
||||
{% else %}
|
||||
<p class="text-muted">This job has not been posted to LinkedIn yet.</p>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{% url 'post_to_linkedin' job.slug %}" class="mt-3">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-primary w-100"
|
||||
{% if not request.session.linkedin_authenticated %}disabled{% endif %}>
|
||||
<i class="fab fa-linkedin"></i>
|
||||
{% if job.posted_to_linkedin %}Re-post to LinkedIn{% else %}Post to LinkedIn{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{% if not request.session.linkedin_authenticated %}
|
||||
<small class="text-muted">You need to <a href="{% url 'linkedin_login' %}">authenticate with LinkedIn</a> first.</small>
|
||||
{% endif %}
|
||||
|
||||
{% if job.linkedin_post_status and 'ERROR' in job.linkedin_post_status %}
|
||||
<div class="alert alert-danger mt-2">
|
||||
<small>{{ job.linkedin_post_status }}</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card border-secondary shadow-sm mb-4">
|
||||
<div class="card-header bg-secondary text-white">
|
||||
<h5 class="mb-0">Applicant Form Management</h5>
|
||||
</div>
|
||||
<div class="card-body d-grid gap-2">
|
||||
|
||||
<p class="text-muted mb-3">
|
||||
Manage the custom application forms associated with this job posting.
|
||||
</p>
|
||||
|
||||
{# Primary Action: Highlight the creation of a NEW form #}
|
||||
<a href=""
|
||||
class="btn btn-lg btn-success">
|
||||
<i class="fas fa-plus-circle me-2"></i> Create New Form
|
||||
</a>
|
||||
|
||||
{# Secondary Action: Make the list button less prominent #}
|
||||
<a href=""
|
||||
class="btn btn-sm btn-outline-primary">
|
||||
View All Existing Forms
|
||||
</a>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Applicants List Card -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-users"></i> Applicants ({{ total_candidates }})
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if total_candidates > 0 %}
|
||||
<!-- Quick Stats -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-around text-center">
|
||||
<div class="p-2">
|
||||
<div class="h5 text-primary">{{ applied_count }}</div>
|
||||
<small class="text-muted">Applied</small>
|
||||
</div>
|
||||
<div class="p-2">
|
||||
<div class="h5 text-info">{{ interview_count }}</div>
|
||||
<small class="text-muted">Interview</small>
|
||||
</div>
|
||||
<div class="p-2">
|
||||
<div class="h5 text-success">{{ offer_count }}</div>
|
||||
<small class="text-muted">Offer</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Applicants List -->
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Stage</th>
|
||||
<th>Date Applied</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for candidate in candidates %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ candidate.first_name }} {{ candidate.last_name }}</strong>
|
||||
</td>
|
||||
<td>{{ candidate.email }}</td>
|
||||
<td>
|
||||
<span class="badge bg-{% if candidate.stage == 'Applied' %}primary{% elif candidate.stage == 'Interview' %}info{% elif candidate.stage == 'Offer' %}success{% else %}secondary{% endif %}">
|
||||
{{ candidate.stage }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ candidate.created_at|date:"M d, Y" }}</td>
|
||||
<td>
|
||||
<a href="{% url 'candidate_detail' candidate.slug %}" class="btn btn-sm btn-outline-primary">
|
||||
<i class="fas fa-eye"></i> View
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if candidates|length > 5 %}
|
||||
<div class="text-center mt-3">
|
||||
<a href="{% url 'job_candidates_list' job.slug %}" class="btn btn-sm btn-outline-primary">
|
||||
View All Applicants ({{ total_candidates }})
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="text-center py-4">
|
||||
<i class="fas fa-user-slash fa-3x text-muted mb-3"></i>
|
||||
<h6 class="text-muted">No applicants yet</h6>
|
||||
<p class="text-muted small">Candidates will appear here once they apply for this position.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Internal Info Card -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5>Internal Information</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p><strong>Internal Job ID:</strong> {{ job.internal_job_id }}</p>
|
||||
<p><strong>Created:</strong> {{ job.created_at|date:"M d, Y" }}</p>
|
||||
<p><strong>Last Updated:</strong> {{ job.updated_at|date:"M d, Y" }}</p>
|
||||
{% if job.reporting_to %}
|
||||
<p><strong>Reports To:</strong> {{ job.reporting_to }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 mb-5">
|
||||
<a href="{% url 'job_update' job.slug %}" class="btn btn-outline-primary me-2">
|
||||
<i class="fas fa-edit"></i> Edit Job
|
||||
</a>
|
||||
<a href="{% url 'candidate_create_for_job' job.slug %}" class="btn btn-success me-2">
|
||||
<i class="fas fa-user-plus"></i> Create Candidate
|
||||
</a>
|
||||
<a href="{% url 'job_list' %}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Back to Jobs
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!--modal class for upload file-->
|
||||
{% include "jobs/partials/image_upload.html" %}
|
||||
{% endblock %}
|
||||
113
templates/jobs/job_list.html
Normal file
@ -0,0 +1,113 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Job Postings - University ATS{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1><i class="fas fa-briefcase"></i> Job Postings</h1>
|
||||
<a href="{% url 'job_create' %}" class="btn btn-primary">
|
||||
<i class="fas fa-plus"></i> Create New Job
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<form method="get" class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label for="status" class="form-label">Status</label>
|
||||
<select name="status" id="status" class="form-select">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="DRAFT" {% if status_filter == 'DRAFT' %}selected{% endif %}>Draft</option>
|
||||
<option value="ACTIVE" {% if status_filter == 'ACTIVE' %}selected{% endif %}>Active</option>
|
||||
<option value="CLOSED" {% if status_filter == 'CLOSED' %}selected{% endif %}>Closed</option>
|
||||
<option value="ARCHIVED" {% if status_filter == 'ARCHIVED' %}selected{% endif %}>Archived</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3 d-flex align-items-end">
|
||||
<button type="submit" class="btn btn-outline-primary">Filter</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Job List -->
|
||||
{% if page_obj %}
|
||||
<div class="row">
|
||||
{% for job in page_obj %}
|
||||
<div class="col-md-6 col-lg-4 mb-4">
|
||||
<div class="card job-card h-100 {% if job.posted_to_linkedin %}linkedin-posted{% endif %}">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<h5 class="card-title mb-1">{{ job.title }}</h5>
|
||||
<span class="badge bg-{{ job.status|lower }} status-badge">
|
||||
{{ job.get_status_display }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="card-text text-muted small">
|
||||
<i class="fas fa-building"></i> {{ job.department|default:"No Department" }}<br>
|
||||
<i class="fas fa-map-marker-alt"></i> {{ job.get_location_display }}<br>
|
||||
<i class="fas fa-clock"></i> {{ job.get_job_type_display }}
|
||||
</p>
|
||||
|
||||
<div class="mt-3">
|
||||
{% if job.posted_to_linkedin %}
|
||||
<span class="badge bg-info">
|
||||
<i class="fab fa-linkedin"></i> Posted to LinkedIn
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-2">
|
||||
<a href="{% url 'job_detail' job.slug %}" class="btn btn-sm btn-outline-primary me-2">
|
||||
View
|
||||
</a>
|
||||
<a href="{% url 'job_update' job.slug %}" class="btn btn-sm btn-outline-secondary">
|
||||
Edit
|
||||
</a>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if page_obj.has_other_pages %}
|
||||
<nav aria-label="Job pagination">
|
||||
<ul class="pagination justify-content-center">
|
||||
{% if page_obj.has_previous %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page=1{% if status_filter %}&status={{ status_filter }}{% endif %}">First</a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if status_filter %}&status={{ status_filter }}{% endif %}">Previous</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
<li class="page-item active">
|
||||
<span class="page-link">{{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span>
|
||||
</li>
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% if status_filter %}&status={{ status_filter }}{% endif %}">Next</a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}{% if status_filter %}&status={{ status_filter }}{% endif %}">Last</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="fas fa-briefcase fa-3x text-muted mb-3"></i>
|
||||
<h3>No job postings found</h3>
|
||||
<p class="text-muted">Create your first job posting to get started.</p>
|
||||
<a href="{% url 'job_create' %}" class="btn btn-primary">Create Job</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
35
templates/jobs/partials/delete_modal.html
Normal file
@ -0,0 +1,35 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Delete Job - University ATS{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-header bg-danger text-white">
|
||||
<h4><i class="fas fa-exclamation-triangle"></i> Delete Job Posting</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p>Are you sure you want to delete the job posting "<strong>{{ job.title }}</strong>"?</p>
|
||||
|
||||
<div class="alert alert-warning">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<strong>This action cannot be undone.</strong> All associated data will be permanently removed.
|
||||
</div>
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="{% url 'job_list' %}" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Cancel
|
||||
</a>
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="fas fa-trash"></i> Delete Job
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
21
templates/jobs/partials/image_upload.html
Normal file
@ -0,0 +1,21 @@
|
||||
<div class="modal fade" id="myModalForm" tabindex="-1" aria-labelledby="myModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="myModalLabel">Add New Comment</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form method="post" action="{% url 'job_create' %}">
|
||||
{% csrf_token %}
|
||||
{{ image_form }}
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
<button type="submit" class="btn btn-primary">Save changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,191 +1,44 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Create Zoom Meeting</title>
|
||||
<style>
|
||||
:root {
|
||||
--primary-color: #1b8354;
|
||||
--background-color: #fcfcfc;
|
||||
--text-color: #333;
|
||||
--border-color: #e0e0e0;
|
||||
--card-bg: #ffffff;
|
||||
--secondary-text: #666;
|
||||
}
|
||||
{% extends "base.html" %}
|
||||
{% load static i18n %}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
{% block title %}{% trans "Create Zoom Meeting" %} - {{ block.super }}{% endblock %}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background-color: var(--background-color);
|
||||
color: var(--text-color);
|
||||
line-height: 1.6;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
padding: 20px 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
color: var(--primary-color);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||
padding: 25px;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
color: var(--primary-color);
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-weight: 600;
|
||||
margin-bottom: 5px;
|
||||
color: var(--secondary-text);
|
||||
}
|
||||
|
||||
.form-input {
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 5px;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 12px 25px;
|
||||
border-radius: 5px;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
border: 1px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: white;
|
||||
color: var(--primary-color);
|
||||
border: 1px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.messages {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>Zoom Meeting Manager</h1>
|
||||
<p>Create a new Zoom meeting</p>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
{% if messages %}
|
||||
<div class="messages">
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags }}">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Create Meeting Form -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5>Create New Meeting</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="form-row">
|
||||
<label for="topic" class="form-label">Topic</label>
|
||||
<input type="text" class="form-input" id="topic" name="topic" placeholder="Enter meeting topic" required>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label for="start_time" class="form-label">Start Time (UTC)</label>
|
||||
<input type="datetime-local" class="form-input" id="start_time" name="start_time" required>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label for="duration" class="form-label">Duration (minutes)</label>
|
||||
<input type="number" class="form-input" id="duration" name="duration" value="60" min="1">
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<button type="submit" class="btn btn-primary">Create Meeting</button>
|
||||
<a href="{% url 'list_meetings' %}" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h1>
|
||||
<svg class="heroicon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 4v16m8-8H4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
{% trans "Create New Zoom Meeting" %}
|
||||
</h1>
|
||||
<a href="{% url 'list_meetings' %}" class="btn btn-secondary">{% trans "Back to Meetings" %}</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="form-row">
|
||||
<label class="form-label">{% trans "Topic" %}</label>
|
||||
{{ form.topic }}
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="form-label">{% trans "Start Time" %}</label>
|
||||
{{ form.start_time }}
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="form-label">{% trans "Duration (minutes)" %}</label>
|
||||
{{ form.duration }}
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<svg class="heroicon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5 13l4 4L19 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
{% trans "Create Meeting" %}
|
||||
</button>
|
||||
<a href="{% url 'list_meetings' %}" class="btn btn-secondary">{% trans "Cancel" %}</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@ -1,249 +1,237 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Zoom Meetings</title>
|
||||
<style>
|
||||
:root {
|
||||
--primary-color: #1b8354;
|
||||
--background-color: #fcfcfc;
|
||||
--text-color: #333;
|
||||
--border-color: #e0e0e0;
|
||||
--card-bg: #ffffff;
|
||||
--secondary-text: #666;
|
||||
}
|
||||
{% extends "base.html" %}
|
||||
{% load static i18n %}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
{% block title %}{% trans "Zoom Meetings" %} - {{ block.super }}{% endblock %}
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.meetings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.meeting-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
padding: 1.5rem;
|
||||
border: 1px solid #e0e0e0;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
height: 100%;
|
||||
}
|
||||
.meeting-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.meeting-topic {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: #1b8354;
|
||||
margin-bottom: 1rem;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
.meeting-detail {
|
||||
display: flex;
|
||||
margin-bottom: 0.75rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.detail-label {
|
||||
font-weight: 600;
|
||||
min-width: 80px;
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.detail-value {
|
||||
flex: 1;
|
||||
font-size: 0.9rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.status-waiting {
|
||||
background: #fff8e1;
|
||||
color: #ff8f00;
|
||||
}
|
||||
.status-started {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
.status-ended {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
.actions {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.btn-small {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background-color: var(--background-color);
|
||||
color: var(--text-color);
|
||||
line-height: 1.6;
|
||||
padding: 20px;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.meetings-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.meeting-detail {
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.detail-label {
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
.container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h1 class="h3 mb-0">
|
||||
{% include "icons/meeting.html" %}
|
||||
{% trans "Zoom Meetings" %}
|
||||
</h1>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<!-- Search Form -->
|
||||
{% include "includes/search_form.html" with search_query=search_query %}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
padding: 20px 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
color: var(--primary-color);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.meetings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.meeting-card {
|
||||
background: var(--card-bg);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||
padding: 20px;
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.meeting-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.meeting-topic {
|
||||
font-size: 1.2em;
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.meeting-detail {
|
||||
display: flex;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-weight: 600;
|
||||
min-width: 100px;
|
||||
color: var(--secondary-text);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
flex: 1;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.8em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-waiting {
|
||||
background-color: #fff8e1;
|
||||
color: #ff8f00;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 15px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 15px;
|
||||
border-radius: 5px;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
font-size: 0.85em;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
border: 1px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: white;
|
||||
color: var(--primary-color);
|
||||
border: 1px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: #ff4d4d;
|
||||
color: white;
|
||||
border: 1px solid #ff4d4d;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.messages {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.meetings-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>Zoom Meetings</h1>
|
||||
<p>Your upcoming and past meetings</p>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
{% if messages %}
|
||||
<div class="messages">
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags }}">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<a href="{% url 'create_meeting' %}" class="btn btn-primary">
|
||||
<svg class="heroicon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 4v16m8-8H4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
{% trans "Create Meeting" %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="{% url 'create_meeting' %}" class="btn btn-primary" style="margin-bottom: 20px;">Create Meeting</a>
|
||||
{% if meetings %}
|
||||
<div class="meetings-grid">
|
||||
{% for meeting in meetings %}
|
||||
<div class="meeting-card">
|
||||
<div class="meeting-topic">{{ meeting.topic }}</div>
|
||||
|
||||
{% if meetings %}
|
||||
<div class="meetings-grid">
|
||||
{% for meeting in meetings %}
|
||||
<div class="meeting-card">
|
||||
<div class="meeting-topic">{{ meeting.topic }}</div>
|
||||
<div class="meeting-detail">
|
||||
<div class="detail-label">{% trans "ID" %}:</div>
|
||||
<div class="detail-value">{{ meeting.meeting_id|default:meeting.id }}</div>
|
||||
</div>
|
||||
|
||||
<div class="meeting-detail">
|
||||
<div class="detail-label">ID:</div>
|
||||
<div class="detail-value">{{ meeting.id }}</div>
|
||||
</div>
|
||||
<div class="meeting-detail">
|
||||
<div class="detail-label">{% trans "Start Time" %}:</div>
|
||||
<div class="detail-value">{{ meeting.start_time|date:"M d, Y H:i" }}</div>
|
||||
</div>
|
||||
|
||||
<div class="meeting-detail">
|
||||
<div class="detail-label">Time:</div>
|
||||
<div class="detail-value">{{ meeting.start_time }}</div>
|
||||
</div>
|
||||
<div class="meeting-detail">
|
||||
<div class="detail-label">{% trans "Duration" %}:</div>
|
||||
<div class="detail-value">{{ meeting.duration }} minutes</div>
|
||||
</div>
|
||||
|
||||
<div class="meeting-detail">
|
||||
<div class="detail-label">Duration:</div>
|
||||
<div class="detail-value">{{ meeting.duration }} minutes</div>
|
||||
</div>
|
||||
|
||||
<div class="meeting-detail">
|
||||
<div class="detail-label">Status:</div>
|
||||
<div class="detail-value">
|
||||
<span class="status-badge status-waiting">{{ meeting.status|title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions" style="display: flex; align-items: center;">
|
||||
<a href="{% url 'meeting_details' meeting.pk %}" class="btn btn-primary">View</a>
|
||||
<a href="{% url 'update_meeting' meeting.pk %}" class="btn btn-secondary" style="margin-left: 10px;">Update</a>
|
||||
<form method="post" action="{% url 'delete_meeting' meeting.pk %}" style="display:inline; margin-left: 10px;">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-danger">Delete</button>
|
||||
</form>
|
||||
<div class="meeting-detail">
|
||||
<div class="detail-label">{% trans "Status" %}:</div>
|
||||
<div class="detail-value">
|
||||
<span class="badge {% if meeting.status == 'waiting' %}bg-warning{% elif meeting.status == 'started' %}bg-success{% elif meeting.status == 'ended' %}bg-danger{% endif %}">
|
||||
{% if meeting.status == 'waiting' %}
|
||||
{% trans "Waiting" %}
|
||||
{% elif meeting.status == 'started' %}
|
||||
{% trans "Started" %}
|
||||
{% elif meeting.status == 'ended' %}
|
||||
{% trans "Ended" %}
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if next_page_token %}
|
||||
<div style="text-align: center; margin-top: 30px;">
|
||||
<a href="?next_page_token={{ next_page_token }}" class="btn btn-primary">Load More</a>
|
||||
{% if meeting.join_url %}
|
||||
<div class="meeting-detail">
|
||||
<div class="detail-label">{% trans "Join URL" %}:</div>
|
||||
<div class="detail-value">
|
||||
<a href="{{ meeting.join_url }}" target="_blank" class="btn btn-outline-primary btn-sm">
|
||||
{% trans "Join Meeting" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="actions">
|
||||
<a href="{% url 'meeting_details' meeting.pk %}" class="btn btn-outline-primary btn-sm" title="{% trans 'View' %}">
|
||||
<svg class="heroicon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
<path d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0 8.268-2.943-9.542-7z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
</a>
|
||||
<a href="{% url 'update_meeting' meeting.pk %}" class="btn btn-outline-secondary btn-sm" title="{% trans 'Update' %}">
|
||||
<svg class="heroicon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
</a>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" title="{% trans 'Delete' %}"
|
||||
data-bs-toggle="deleteModal"
|
||||
data-delete-url="{% url 'delete_meeting' meeting.pk %}"
|
||||
data-item-name="{{ meeting.topic }}">
|
||||
<svg class="heroicon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="meeting-card">
|
||||
<p>No meetings found.</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if is_paginated %}
|
||||
<nav aria-label="Page navigation" class="mt-4">
|
||||
<ul class="pagination justify-content-center">
|
||||
{% if page_obj.has_previous %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.previous_page_number }}&search={{ search_query }}" aria-label="Previous">
|
||||
<span aria-hidden="true">«</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% for num in page_obj.paginator.page_range %}
|
||||
{% if page_obj.number == num %}
|
||||
<li class="page-item active"><span class="page-link">{{ num }}</span></li>
|
||||
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ num }}&search={{ search_query }}">{{ num }}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.next_page_number }}&search={{ search_query }}" aria-label="Next">
|
||||
<span aria-hidden="true">»</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="text-muted mb-3" style="width: 80px;">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 5.25h.008v.008H12v-.008Z" />
|
||||
</svg>
|
||||
<p class="text-muted">{% trans "No meetings found." %}</p>
|
||||
{% if user.is_staff %}
|
||||
<a href="{% url 'create_meeting' %}" class="btn btn-primary mt-3">
|
||||
<svg class="heroicon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 4v16m8-8H4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
{% trans "Create Your First Meeting" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@ -1,254 +1,102 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Meeting Details</title>
|
||||
<style>
|
||||
:root {
|
||||
--primary-color: #1b8354;
|
||||
--background-color: #fcfcfc;
|
||||
--text-color: #333;
|
||||
--border-color: #e0e0e0;
|
||||
--card-bg: #ffffff;
|
||||
--secondary-text: #666;
|
||||
}
|
||||
{% extends "base.html" %}
|
||||
{% load static i18n %}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
{% block title %}{% trans "Meeting Details" %} - {{ block.super }}{% endblock %}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background-color: var(--background-color);
|
||||
color: var(--text-color);
|
||||
line-height: 1.6;
|
||||
padding: 20px;
|
||||
}
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h1>
|
||||
<svg class="heroicon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
<path d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0 8.268-2.943-9.542-7z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
{{ meeting.topic }}
|
||||
</h1>
|
||||
<span class="status-badge status-{{ meeting.status }}">
|
||||
{{ meeting.status|title }}
|
||||
</span>
|
||||
<a href="{% url 'list_meetings' %}" class="btn btn-secondary">{% trans "Back to Meetings" %}</a>
|
||||
</div>
|
||||
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
padding: 20px 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
color: var(--primary-color);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||
padding: 25px;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
color: var(--primary-color);
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-weight: 600;
|
||||
min-width: 200px;
|
||||
color: var(--secondary-text);
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 5px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.85em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-waiting {
|
||||
background-color: #fff8e1;
|
||||
color: #ff8f00;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 12px 25px;
|
||||
border-radius: 5px;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
border: 1px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: white;
|
||||
color: var(--primary-color);
|
||||
border: 1px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.detail-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
min-width: auto;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>Zoom Meeting Details</h1>
|
||||
<p>All information about your scheduled meeting</p>
|
||||
<div class="card">
|
||||
<h2>{% trans "Meeting Information" %}</h2>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">{% trans "Meeting ID" %}:</div>
|
||||
<div class="detail-value">{{ meeting.meeting_id }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
{% if messages %}
|
||||
<div class="messages">
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags }}">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
<div class="card">
|
||||
<h2 class="card-title">Meeting Information</h2>
|
||||
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Topic:</div>
|
||||
<div class="detail-value">{{ meeting.topic }}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Meeting ID:</div>
|
||||
<div class="detail-value">{{ meeting.id }}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Status:</div>
|
||||
<div class="detail-value">
|
||||
<span class="status-badge status-waiting">{{ meeting.status|title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Start Time:</div>
|
||||
<div class="detail-value">{{ meeting.start_time }}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Duration:</div>
|
||||
<div class="detail-value">{{ meeting.duration }} minutes</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Host:</div>
|
||||
<div class="detail-value">{{ meeting.host_email }}</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">{% trans "Start Time" %}:</div>
|
||||
<div class="detail-value">{{ meeting.start_time|date:"M d, Y H:i" }}</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 class="card-title">Join Information</h2>
|
||||
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Password:</div>
|
||||
<div class="detail-value">{{ meeting.password }}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">H.323 Password:</div>
|
||||
<div class="detail-value">{{ meeting.h323_password }}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">PSTN Password:</div>
|
||||
<div class="detail-value">{{ meeting.pstn_password }}</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">{% trans "Duration" %}:</div>
|
||||
<div class="detail-value">{{ meeting.duration }} minutes</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 class="card-title">Meeting Settings</h2>
|
||||
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Host Video:</div>
|
||||
<div class="detail-value">{{ meeting.settings.host_video|yesno:"Enabled,Disabled" }}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Participant Video:</div>
|
||||
<div class="detail-value">{{ meeting.settings.participant_video|yesno:"Enabled,Disabled" }}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Join Before Host:</div>
|
||||
<div class="detail-value">{{ meeting.settings.join_before_host|yesno:"Allowed,Not Allowed" }}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Waiting Room:</div>
|
||||
<div class="detail-value">{{ meeting.settings.waiting_room|yesno:"Enabled,Disabled" }}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Audio:</div>
|
||||
<div class="detail-value">{{ meeting.settings.audio }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<a href="{{ meeting.start_url }}" class="btn btn-primary" target="_blank">Start Meeting</a>
|
||||
<a href="{{ meeting.join_url }}" class="btn btn-secondary" target="_blank">Join Meeting</a>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">{% trans "Host Email" %}:</div>
|
||||
<div class="detail-value">{{ meeting.host_email }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
{% if meeting.join_url %}
|
||||
<div class="card">
|
||||
<h2>{% trans "Join Information" %}</h2>
|
||||
<a href="{{ meeting.join_url }}" class="btn btn-primary" target="_blank">{% trans "Join Meeting" %}</a>
|
||||
{% if meeting.password %}
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">{% trans "Password" %}:</div>
|
||||
<div class="detail-value">{{ meeting.password }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card">
|
||||
<h2>{% trans "Settings" %}</h2>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">{% trans "Host Video" %}:</div>
|
||||
<div class="detail-value">{{ meeting.host_video|yesno:"Yes,No" }}</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">{% trans "Participant Video" %}:</div>
|
||||
<div class="detail-value">{{ meeting.participant_video|yesno:"Yes,No" }}</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">{% trans "Join Before Host" %}:</div>
|
||||
<div class="detail-value">{{ meeting.join_before_host|yesno:"Yes,No" }}</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">{% trans "Mute Upon Entry" %}:</div>
|
||||
<div class="detail-value">{{ meeting.mute_upon_entry|yesno:"Yes,No" }}</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">{% trans "Waiting Room" %}:</div>
|
||||
<div class="detail-value">{{ meeting.waiting_room|yesno:"Yes,No" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
{% if meeting.zoom_gateway_response %}
|
||||
<a href="#" class="btn btn-secondary" onclick="toggleGateway()">{% trans "View API Response" %}</a>
|
||||
{% endif %}
|
||||
<a href="{% url 'update_meeting' meeting.pk %}" class="btn btn-primary">{% trans "Update Meeting" %}</a>
|
||||
<form method="post" action="{% url 'delete_meeting' meeting.pk %}" style="display: inline;">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-danger" onclick="return confirm('{% trans "Are you sure?" %}')">{% trans "Delete Meeting" %}</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if meeting.zoom_gateway_response %}
|
||||
<div id="gateway-response" style="display: none; margin-top: 2rem;">
|
||||
<div class="card">
|
||||
<h3>{% trans "Zoom API Response" %}</h3>
|
||||
<pre>{{ meeting.zoom_gateway_response|safe }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
function toggleGateway() {
|
||||
document.getElementById('gateway-response').style.display = document.getElementById('gateway-response').style.display === 'none' ? 'block' : 'none';
|
||||
}
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
@ -1,134 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Update Meeting</title>
|
||||
<style>
|
||||
:root {
|
||||
--primary-color: #1b8354;
|
||||
--background-color: #fcfcfc;
|
||||
--text-color: #333;
|
||||
--border-color: #e0e0e0;
|
||||
--card-bg: #ffffff;
|
||||
--secondary-text: #666;
|
||||
}
|
||||
{% extends "base.html" %}
|
||||
{% load static i18n %}
|
||||
{% block title %}{% trans "Update Zoom Meeting" %} - {{ block.super }}{% endblock %}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background-color: var(--background-color);
|
||||
color: var(--text-color);
|
||||
line-height: 1.6;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
padding: 20px 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
color: var(--primary-color);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||
padding: 25px;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
color: var(--primary-color);
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-weight: 600;
|
||||
margin-bottom: 5px;
|
||||
color: var(--secondary-text);
|
||||
}
|
||||
|
||||
.form-input {
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 5px;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 12px 25px;
|
||||
border-radius: 5px;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
border: 1px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: white;
|
||||
color: var(--primary-color);
|
||||
border: 1px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>Update Zoom Meeting</h1>
|
||||
<p>Modify the details of your scheduled meeting</p>
|
||||
<h1>{% trans "Update Zoom Meeting" %}</h1>
|
||||
<p>{% trans "Modify the details of your scheduled meeting" %}</p>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
@ -143,33 +21,32 @@
|
||||
{% endif %}
|
||||
|
||||
<div class="card">
|
||||
<h2 class="card-title">Meeting Information</h2>
|
||||
<h2 class="card-title">{% trans "Meeting Information" %}</h2>
|
||||
|
||||
<form method="post" action="{% url 'update_meeting' meeting.pk %}">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="form-row">
|
||||
<label for="topic" class="form-label">Topic:</label>
|
||||
<label for="topic" class="form-label">{% trans "Topic:" %}</label>
|
||||
<input type="text" id="topic" name="topic" class="form-input" value="{{ meeting.topic }}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label for="start_time" class="form-label">Start Time (ISO 8601):</label>
|
||||
<label for="start_time" class="form-label">{% trans "Start Time (ISO 8601):" %}</label>
|
||||
<input type="datetime-local" id="start_time" name="start_time" class="form-input"
|
||||
value="{{ meeting.start_time|slice:'0:16'|date:'Y-m-d\TH:i' }}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label for="duration" class="form-label">Duration (minutes):</label>
|
||||
<label for="duration" class="form-label">{% trans "Duration (minutes):" %}</label>
|
||||
<input type="number" id="duration" name="duration" class="form-input" value="{{ meeting.duration }}" required>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn btn-primary">Update Meeting</button>
|
||||
<a href="{% url 'meeting_details' meeting.pk %}" class="btn btn-secondary">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">{% trans "Update Meeting" %}</button>
|
||||
<a href="{% url 'meeting_details' meeting.pk %}" class="btn btn-secondary">{% trans "Cancel" %}</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{% endblock %}
|
||||
|
||||
29
templates/recruitment/candidate_create.html
Normal file
@ -0,0 +1,29 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static i18n crispy_forms_tags %}
|
||||
|
||||
{% block title %}Create Candidate - {{ block.super }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h1 class="h3 mb-0">
|
||||
<svg class="heroicon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 4v16m8-8H4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
{% trans "Create Candidate" %}
|
||||
</h1>
|
||||
<a href="{% url 'candidate_list' %}" class="btn btn-secondary">
|
||||
{% trans "Back to List" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
<button class="btn btn-primary" type="submit">{% trans "Create" %}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
11
templates/recruitment/candidate_delete.html
Normal file
@ -0,0 +1,11 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Delete Candidate - {{ block.super }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h1>
|
||||
<svg class="heroicon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2
|
||||
@ -1,43 +1,184 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{{ candidate.name }} - Resume Summary</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 2em; }
|
||||
.section { margin-bottom: 2em; }
|
||||
.section h2 { border-bottom: 1px solid #ddd; padding-bottom: 0.5em; color: #333; }
|
||||
ul { padding-left: 20px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Candidate: {{ candidate.name }}</h1>
|
||||
<div class="section">
|
||||
<h2>Contact Information</h2>
|
||||
<p><strong>Email:</strong> {{ candidate.email }}</p>
|
||||
</div>
|
||||
{% extends "base.html" %}
|
||||
{% load static humanize i18n %}
|
||||
|
||||
<div class="section">
|
||||
<h2>Resume Summary</h2>
|
||||
<p>{{ parsed.summary|default:"Not available." }}</p>
|
||||
</div>
|
||||
{% block title %}{{ candidate.name }} - {{ block.super }}{% endblock %}
|
||||
|
||||
<div class="section">
|
||||
<h2>Detected Name</h2>
|
||||
<p>{{ parsed.name|default:"N/A" }}</p>
|
||||
</div>
|
||||
{% block content %}
|
||||
<!-- Candidate Header Card -->
|
||||
<div class="card mb-4" data-signals-stage="'{{ candidate.stage }}'">
|
||||
<div class="card-header">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<h1 class="h3 mb-2">{{ candidate.name }}</h1>
|
||||
<div class="d-flex align-items-center gap-2 mb-2">
|
||||
<span class="badge {% if candidate.applied %}bg-success{% else %}bg-warning{% endif %}">
|
||||
{{ candidate.applied|yesno:"Applied,Pending" }}
|
||||
</span>
|
||||
<span id="stageDisplay" class="badge" data-class="{'bg-primary': $stage == 'Applied', 'bg-info': $stage == 'Exam', 'bg-warning': $stage == 'Interview', 'bg-success': $stage == 'Offer'}">
|
||||
Stage: <span data-text="$stage"></span>
|
||||
</span>
|
||||
</div>
|
||||
{% if user.is_staff %}
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-outline-info btn-sm" data-bs-toggle="modal" data-bs-target="#stageUpdateModal" title="{% trans 'Update Stage' %}">
|
||||
<i class="fas fa-exchange-alt"></i>
|
||||
<span class="d-none d-sm-inline">{% trans "Update Stage" %}</span>
|
||||
</button>
|
||||
<a href="{% url 'candidate_update' candidate.slug %}" class="btn btn-outline-primary btn-sm" title="{% trans 'Edit' %}">
|
||||
{% include "icons/edit.html" %}
|
||||
<span class="d-none d-sm-inline">{% trans "Edit" %}</span>
|
||||
</a>
|
||||
<a href="{% url 'candidate_delete' candidate.slug %}" class="btn btn-outline-danger btn-sm" title="{% trans 'Delete' %}" onclick="return confirm('{% trans "Are you sure?" %}')">
|
||||
{% include "icons/delete.html" %}
|
||||
<span class="d-none d-sm-inline">{% trans "Delete" %}</span>
|
||||
</a>
|
||||
<a href="{% url 'candidate_list' %}" class="btn btn-outline-secondary btn-sm" title="{% trans 'Back to List' %}">
|
||||
{% include "icons/back.html" %}
|
||||
<span class="d-none d-sm-inline">{% trans "Back to List" %}</span>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Key Skills</h2>
|
||||
{% if parsed.skills %}
|
||||
<ul>
|
||||
{% for skill in parsed.skills|slice:":10" %}
|
||||
<li>{{ skill }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p>No skills extracted.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
<!-- Contact Information Card -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h2 class="h5 mb-0">
|
||||
<svg class="heroicon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
{% trans "Contact Information" %}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<svg class="heroicon icon-sm text-muted me-3" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
<path d="M22 6l-10 7L2 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
<div>
|
||||
<small class="text-muted d-block">{% trans "Email" %}</small>
|
||||
<strong>{{ candidate.email }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<svg class="heroicon icon-sm text-muted me-3" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
<path d="M3.27 6.96L12 12.01l8.73-5.05" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
<path d="M3 16v5a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
<div>
|
||||
<small class="text-muted d-block">{% trans "Job Position" %}</small>
|
||||
<strong>{{ candidate.job.title }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="d-flex align-items-center">
|
||||
<svg class="heroicon icon-sm text-muted me-3" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
<div>
|
||||
<small class="text-muted d-block">{% trans "Applied Date" %}</small>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<strong>{{ candidate.created_at|date:"M d, Y H:i" }}</strong>
|
||||
<span class="badge bg-light text-dark">
|
||||
<svg class="heroicon icon-sm" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" stroke="currentColor" stroke-width="2"></path>
|
||||
</svg>
|
||||
{{ candidate.created_at|naturaltime }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resume Card -->
|
||||
{% if candidate.resume %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h2 class="h5 mb-0">
|
||||
<svg class="heroicon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
<path d="M14 2v6h6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
<path d="M16 13H8M16 17H8M10 9H8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
{% trans "Resume" %}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<p class="mb-1"><strong>{{ candidate.resume.name }}</strong></p>
|
||||
<small class="text-muted">{{ candidate.resume }} • {{ candidate.resume.name|truncatechars:30 }}</small>
|
||||
</div>
|
||||
<a href="{{ candidate.resume.url }}" download class="btn btn-primary">
|
||||
{% include "icons/download.html" %}
|
||||
{% trans "Download Resume" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Parsed Summary Card -->
|
||||
{% if candidate.parsed_summary %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h2 class="h5 mb-0">
|
||||
<svg class="heroicon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
{% trans "Parsed Summary" %}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="border-start border-primary ps-3">
|
||||
<p class="mb-0">{{ candidate.parsed_summary|linebreaks }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Parsed Data Card -->
|
||||
{% if parsed %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="h5 mb-0">
|
||||
<svg class="heroicon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
<path d="M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
{% trans "Parsed Data" %}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
{% for key, value in parsed.items %}
|
||||
<div class="col-md-6 col-lg-4 mb-3">
|
||||
<div class="border rounded p-3 h-100">
|
||||
<h6 class="text-muted small text-uppercase mb-1">{{ key|title }}</h6>
|
||||
<p class="mb-0">{{ value }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Stage Update Modal -->
|
||||
{% if user.is_staff %}
|
||||
{% include "recruitment/partials/stage_update_modal.html" with candidate=candidate form=stage_form %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
123
templates/recruitment/candidate_list.html
Normal file
@ -0,0 +1,123 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static i18n %}
|
||||
|
||||
{% block title %}Candidates - {{ block.super }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h1 class="h3 mb-0">
|
||||
{% include "icons/users.html" %}
|
||||
{% trans "Candidates" %}
|
||||
</h1>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<!-- Search Form -->
|
||||
{% include "includes/search_form.html" with search_query=search_query %}
|
||||
|
||||
{% if user.is_staff %}
|
||||
<a href="{% url 'candidate_create' %}" class="btn btn-primary">
|
||||
<svg class="heroicon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 4v16m8-8H4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
{% trans "Add New Candidate" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if candidates %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{% trans "Name" %}</th>
|
||||
<th scope="col">{% trans "Email" %}</th>
|
||||
<th scope="col">{% trans "Phone" %}</th>
|
||||
<th scope="col">{% trans "Job" %}</th>
|
||||
<th scope="col">{% trans "Applied" %}</th>
|
||||
<th scope="col">{% trans "Created" %}</th>
|
||||
<th scope="col" class="text-center">{% trans "Actions" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for candidate in candidates %}
|
||||
<tr>
|
||||
<td><strong>{{ candidate.name }}</strong></td>
|
||||
<td>{{ candidate.email }}</td>
|
||||
<td>{{ candidate.phone }}</td>
|
||||
<td> <span class="badge bg-primary">{{ candidate.job.title }}</span></td>
|
||||
<td>
|
||||
<span class="badge {% if candidate.applied %}bg-success{% else %}bg-warning{% endif %}">
|
||||
{{ candidate.applied|yesno:"Yes,No" }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ candidate.created_at|date:"M d, Y" }}</td>
|
||||
<td class="text-center">
|
||||
<div class="btn-group" role="group">
|
||||
<a href="{% url 'candidate_detail' candidate.slug %}" class="btn btn-outline-primary btn-sm" title="{% trans 'View' %}">
|
||||
{% include "icons/view.html" %}
|
||||
</a>
|
||||
{% if user.is_staff %}
|
||||
<a href="{% url 'candidate_update' candidate.slug %}" class="btn btn-outline-primary btn-sm" title="{% trans 'Edit' %}">
|
||||
{% include "icons/edit.html" %}
|
||||
</a>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" title="{% trans 'Delete' %}"
|
||||
data-bs-toggle="deleteModal"
|
||||
data-delete-url="{% url 'candidate_delete' candidate.slug %}"
|
||||
data-item-name="{{ candidate.name }}">
|
||||
{% include "icons/delete.html" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if is_paginated %}
|
||||
<nav aria-label="Page navigation">
|
||||
<ul class="pagination justify-content-center">
|
||||
{% if page_obj.has_previous %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.previous_page_number }}" aria-label="Previous">
|
||||
<span aria-hidden="true">«</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% for num in page_obj.paginator.page_range %}
|
||||
{% if page_obj.number == num %}
|
||||
<li class="page-item active"><span class="page-link">{{ num }}</span></li>
|
||||
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ num }}">{{ num }}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.next_page_number }}" aria-label="Next">
|
||||
<span aria-hidden="true">»</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="text-center py-4">
|
||||
<p class="text-muted">{% trans "No candidates found." %}</p>
|
||||
{% if user.is_staff %}
|
||||
<a href="{% url 'candidate_create' %}" class="btn btn-primary">
|
||||
{% trans "Add Your First Candidate" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
48
templates/recruitment/candidate_update.html
Normal file
@ -0,0 +1,48 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static i18n crispy_forms_tags %}
|
||||
|
||||
{% block title %}Update Candidate - {{ block.super }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Header Card -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div class="flex-grow-1">
|
||||
<h1 class="h3 mb-2">
|
||||
<svg class="heroicon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
Update Candidate: {{ object.name }}
|
||||
</h1>
|
||||
<p class="text-muted mb-0">{% trans "Edit candidate information and details" %}</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{% url 'candidate_list' %}" class="btn btn-outline-secondary btn-sm" title="{% trans 'Back to List' %}">
|
||||
{% include "icons/back.html" %}
|
||||
<span class="d-none d-sm-inline">{% trans "Back to List" %}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form Card -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="h5 mb-0">
|
||||
<svg class="heroicon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
{% trans "Candidate Information" %}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
<button class="btn btn-primary" type="submit">{% trans "Update" %}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -1,82 +1,105 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Recruitment Dashboard</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
padding: 2rem;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
.stats {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
.card {
|
||||
background: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 10px rgba(0,0,0,0.1);
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
h2 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
canvas {
|
||||
background: white;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 10px rgba(0,0,0,0.05);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
<h1>Recruitment Dashboard</h1>
|
||||
{% block title %}Dashboard - {{ block.super }}{% endblock %}
|
||||
|
||||
<div class="stats">
|
||||
<div class="card">
|
||||
<h2>Total Jobs</h2>
|
||||
<p>{{ total_jobs }}</p>
|
||||
{% block content %}
|
||||
<div class="stats" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 2rem; margin-bottom: 3rem;">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 style="display: flex; align-items: center; color: var(--primary-color);">
|
||||
{% include "icons/jobs.html" %}
|
||||
Total Jobs
|
||||
</h3>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Total Candidates</h2>
|
||||
<p>{{ total_candidates }}</p>
|
||||
<div style="font-size: 2.5rem; text-align: center; color: var(--primary-color); font-weight: bold;">
|
||||
{{ total_jobs }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 style="display: flex; align-items: center; color: var(--primary-color);">
|
||||
{% include "icons/users.html" %}
|
||||
Total Candidates
|
||||
</h3>
|
||||
</div>
|
||||
<div style="font-size: 2.5rem; text-align: center; color: var(--primary-color); font-weight: bold;">
|
||||
{{ total_candidates }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 style="display: flex; align-items: center; color: var(--primary-color);">
|
||||
{% include "icons/meeting.html" %}
|
||||
Avg. Applications per Job
|
||||
</h3>
|
||||
</div>
|
||||
<div style="font-size: 2.5rem; text-align: center; color: var(--primary-color); font-weight: bold;">
|
||||
{{ average_applications|floatformat:1 }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Applications per Job</h2>
|
||||
<canvas id="applicationsChart" width="800" height="400"></canvas>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 style="display: flex; align-items: center; color: var(--primary-color);">
|
||||
<svg class="heroicon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style="width: 2rem; height: 2rem; margin-right: 1rem;">
|
||||
<path d="M3 7v10c0 .5 .4 .9.9 .9h16.2c.4 0 .8 -.4.8 -.9V7c0 -.5 -.4 -.9 -.9 -.9H3.9c-.5 0 -.9 .4 -.9 .9z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
<path d="M3 7h16.2c.5 0 .9 .4.9 .9v8.2c0 .5 -.4 .9 -.9 .9H3.9a.9 .9 0 0 1 -.9 -.9V7.9c0 -.5 .4 -.9.9 -.9z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
Applications per Job
|
||||
</h2>
|
||||
</div>
|
||||
<div style="padding: 2rem;">
|
||||
<canvas id="applicationsChart" style="max-height: 400px;"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const ctx = document.getElementById('applicationsChart').getContext('2d');
|
||||
const chart = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: {{ job_titles|safe }},
|
||||
datasets: [{
|
||||
label: 'Applications',
|
||||
data: {{ job_app_counts|safe }},
|
||||
backgroundColor: 'rgba(54, 162, 235, 0.7)',
|
||||
borderColor: 'rgba(54, 162, 235, 1)',
|
||||
borderWidth: 1
|
||||
}]
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script>
|
||||
const ctx = document.getElementById('applicationsChart').getContext('2d');
|
||||
const chart = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: {{ job_titles|safe }},
|
||||
datasets: [{
|
||||
label: 'Applications',
|
||||
data: {{ job_app_counts|safe }},
|
||||
backgroundColor: 'rgba(27, 131, 84, 0.8)', // Green theme
|
||||
borderColor: 'rgba(27, 131, 84, 1)',
|
||||
borderWidth: 1,
|
||||
barThickness: 50
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: '#333333'
|
||||
}
|
||||
}
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
precision: 0
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
color: '#333333'
|
||||
},
|
||||
grid: {
|
||||
color: '#e0e0e0'
|
||||
}
|
||||
},
|
||||
x: {
|
||||
ticks: {
|
||||
color: '#333333'
|
||||
},
|
||||
grid: {
|
||||
color: '#e0e0e0'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@ -1,10 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Title</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@ -1,10 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Title</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
</body>
|
||||
</html>
|
||||