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

View File

@ -38,6 +38,7 @@ INSTALLED_APPS = [
"unfold.contrib.guardian", # optional, if django-guardian package is used
"unfold.contrib.simple_history",
'django.contrib.admin',
'django.contrib.humanize',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
@ -53,8 +54,10 @@ INSTALLED_APPS = [
'allauth.socialaccount.providers.linkedin_oauth2',
'channels',
'django_filters',
'crispy_forms',
'crispy_bootstrap5',
'django_extensions',
'template_partials',
]
SITE_ID = 1
@ -135,11 +138,30 @@ AUTH_PASSWORD_VALIDATORS = [
},
]
# Crispy Forms Configuration
CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5"
CRISPY_TEMPLATE_PACK = "bootstrap5"
# Bootstrap 5 Configuration
CRISPY_BS5 = {
'include_placeholder_text': True,
'use_css_helpers': True,
}
# Internationalization
# https://docs.djangoproject.com/en/5.2/topics/i18n/
LANGUAGES = [
('en', 'English'),
('ar', 'Arabic'),
]
LANGUAGE_CODE = 'en-us'
LOCALE_PATHS = [
BASE_DIR / 'locale',
]
TIME_ZONE = 'UTC'
USE_I18N = True
@ -188,4 +210,10 @@ UNFOLD = {
ZOOM_ACCOUNT_ID = 'HoGikHXsQB2GNDC5Rvyw9A'
ZOOM_CLIENT_ID = 'brC39920R8C8azfudUaQgA'
ZOOM_CLIENT_SECRET = 'rvfhjlbID4ychXPOvZ2lYsoAC0B0Ny2L'
SECRET_TOKEN = '6KdTGyF0SSCSL_V4Xa34aw'
SECRET_TOKEN = '6KdTGyF0SSCSL_V4Xa34aw'
# Maximum file upload size (in bytes)
DATA_UPLOAD_MAX_MEMORY_SIZE = 10485760 # 10MB
FILE_UPLOAD_MAX_MEMORY_SIZE = 10485760 # 10MB
CORS_ALLOW_CREDENTIALS = True

View File

@ -23,7 +23,7 @@ from rest_framework.routers import DefaultRouter
from recruitment import views
router = DefaultRouter()
router.register(r'jobs', views.JobViewSet)
router.register(r'jobs', views.JobPostingViewSet)
router.register(r'candidates', views.CandidateViewSet)
urlpatterns = [
@ -33,4 +33,4 @@ urlpatterns = [
path('', include('recruitment.urls')),
]
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)

Binary file not shown.

1495
i.html Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@ -44,7 +44,7 @@ def parse_resumes(modeladmin, request, queryset):
@admin.register(models.Candidate)
class CandidateAdmin(ModelAdmin):
list_display = ('name', 'email', 'job', 'applied', 'created_at')
list_display = ('first_name','last_name','phone', 'email', 'job', 'applied', 'created_at')
list_filter = ('applied', 'job')
search_fields = ('name', 'email')
# readonly_fields = ('parsed_summary',)

View File

@ -1,7 +1,2 @@
import pandas as pd
from . import models
def get_dashboard_data():
df = pd.DataFrame(list(models.Candidate.objects.all().values('applied', 'created_at')))
summary = df['applied'].value_counts().to_dict()
return summary
# This file is intentionally left empty
# The dashboard functionality has been moved to views_frontend.py

View File

@ -1,15 +1,368 @@
from django import forms
from .models import ZoomMeeting, Candidate
from .validators import validate_hash_tags
from crispy_forms.helper import FormHelper
from django.core.validators import URLValidator
from django.utils.translation import gettext_lazy as _
from crispy_forms.layout import Layout, Submit, HTML, Div, Field
from .models import ZoomMeeting, Candidate,Job,TrainingMaterial,JobPosting
class CandidateForm(forms.ModelForm):
class Meta:
model = Candidate
fields = ['name', 'email', 'resume']
fields = ['job', 'first_name', 'last_name', 'phone', 'email', 'resume', 'stage']
labels = {
'first_name': _('First Name'),
'last_name': _('Last Name'),
'phone': _('Phone'),
'email': _('Email'),
'resume': _('Resume'),
'stage': _('Application Stage'),
}
widgets = {
'first_name': forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Enter first name')}),
'last_name': forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Enter last name')}),
'phone': forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Enter phone number')}),
'email': forms.EmailInput(attrs={'class': 'form-control', 'placeholder': _('Enter email')}),
'stage': forms.Select(attrs={'class': 'form-select'}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_method = 'post'
self.helper.form_class = 'form-horizontal'
self.helper.label_class = 'col-md-3'
self.helper.field_class = 'col-md-9'
# Make job field read-only if it's being pre-populated
job_value = self.initial.get('job')
if job_value:
self.fields['job'].widget.attrs['readonly'] = True
self.helper.layout = Layout(
Field('job', css_class='form-control'),
Field('first_name', css_class='form-control'),
Field('last_name', css_class='form-control'),
Field('phone', css_class='form-control'),
Field('email', css_class='form-control'),
Field('stage', css_class='form-control'),
Field('resume', css_class='form-control'),
Submit('submit', _('Submit'), css_class='btn btn-primary')
)
class CandidateStageForm(forms.ModelForm):
"""Form specifically for updating candidate stage with validation"""
class Meta:
model = Candidate
fields = ['stage']
labels = {
'stage': _('New Application Stage'),
}
widgets = {
'stage': forms.Select(attrs={'class': 'form-select'}),
}
def __init__(self, *args, **kwargs):
# Get the current candidate instance for validation
self.candidate = kwargs.pop('candidate', None)
super().__init__(*args, **kwargs)
# Dynamically filter stage choices based on current stage
if self.candidate and self.candidate.pk:
current_stage = self.candidate.stage
available_stages = self.candidate.get_available_stages()
# Filter choices to only include available stages
choices = [(stage, self.candidate.Stage(stage).label)
for stage in available_stages]
self.fields['stage'].choices = choices
# Set initial value to current stage
self.fields['stage'].initial = current_stage
else:
# For new candidates, only show 'Applied' stage
self.fields['stage'].choices = [('Applied', _('Applied'))]
self.fields['stage'].initial = 'Applied'
def clean_stage(self):
"""Validate stage transition"""
new_stage = self.cleaned_data.get('stage')
if not new_stage:
raise forms.ValidationError(_('Please select a stage.'))
# Use model validation for stage transitions
if self.candidate and self.candidate.pk:
current_stage = self.candidate.stage
if new_stage != current_stage:
if not self.candidate.can_transition_to(new_stage):
allowed_stages = self.candidate.get_available_stages()
raise forms.ValidationError(
_('Cannot transition from "%(current)s" to "%(new)s". '
'Allowed transitions: %(allowed)s') % {
'current': current_stage,
'new': new_stage,
'allowed': ', '.join(allowed_stages) or 'None (final stage)'
}
)
return new_stage
class ZoomMeetingForm(forms.ModelForm):
class Meta:
model = ZoomMeeting
fields = ['topic', 'start_time', 'duration']
labels = {
'topic': _('Topic'),
'start_time': _('Start Time'),
'duration': _('Duration'),
}
widgets = {
'start_time': forms.DateTimeInput(attrs={'type': 'datetime-local'}),
}
'topic': forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Enter meeting topic'),}),
'start_time': forms.DateTimeInput(attrs={'class': 'form-control','type': 'datetime-local'}),
'duration': forms.NumberInput(attrs={'class': 'form-control','min': 1, 'placeholder': _('60')}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_method = 'post'
self.helper.form_class = 'form-horizontal'
self.helper.label_class = 'col-md-3'
self.helper.field_class = 'col-md-9'
self.helper.layout = Layout(
Field('topic', css_class='form-control'),
Field('start_time', css_class='form-control'),
Field('duration', css_class='form-control'),
Submit('submit', _('Create Meeting'), css_class='btn btn-primary')
)
# Old JobForm removed - replaced by JobPostingForm
class TrainingMaterialForm(forms.ModelForm):
class Meta:
model = TrainingMaterial
fields = ['title', 'content', 'video_link', 'file']
labels = {
'title': _('Title'),
'content': _('Content'),
'video_link': _('Video Link'),
'file': _('File'),
}
widgets = {
'title': forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Enter title')}),
'content': forms.Textarea(attrs={'rows': 6, 'class': 'form-control', 'placeholder': _('Enter material content')}),
'video_link': forms.URLInput(attrs={'class': 'form-control', 'placeholder': _('https://www.youtube.com/watch?v=...')}),
'file': forms.FileInput(attrs={'class': 'form-control'}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_method = 'post'
self.helper.form_class = 'form-horizontal'
self.helper.label_class = 'col-md-3'
self.helper.field_class = 'col-md-9'
self.helper.layout = Layout(
Field('title', css_class='form-control'),
Field('content', css_class='form-control'),
Div(
Field('video_link', css_class='form-control'),
Field('file', css_class='form-control'),
css_class='row'
),
Submit('submit', _('Save Material'), css_class='btn btn-primary mt-3')
)
class JobPostingForm(forms.ModelForm):
"""Form for creating and editing job postings"""
class Meta:
model = JobPosting
fields = [
'title', 'department', 'job_type', 'workplace_type',
'location_city', 'location_state', 'location_country',
'description', 'qualifications', 'salary_range', 'benefits',
'application_url', 'application_deadline', 'application_instructions',
'position_number', 'reporting_to', 'start_date', 'status',
'created_by','open_positions','hash_tags'
]
widgets = {
# Basic Information
'title': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Assistant Professor of Computer Science',
'required': True
}),
'department': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Computer Science, Human Resources, etc.'
}),
'job_type': forms.Select(attrs={
'class': 'form-select',
'required': True
}),
'workplace_type': forms.Select(attrs={
'class': 'form-select',
'required': True
}),
# Location
'location_city': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Boston'
}),
'location_state': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'MA'
}),
'location_country': forms.TextInput(attrs={
'class': 'form-control',
'value': 'United States'
}),
# Job Details
'description': forms.Textarea(attrs={
'class': 'form-control',
'rows': 6,
'placeholder': 'Provide a comprehensive description of the role, responsibilities, and expectations...',
'required': True
}),
'qualifications': forms.Textarea(attrs={
'class': 'form-control',
'rows': 4,
'placeholder': 'List required qualifications, skills, education, and experience...'
}),
'salary_range': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': '$60,000 - $80,000'
}),
'benefits': forms.Textarea(attrs={
'class': 'form-control',
'rows': 2,
'placeholder': 'Health insurance, retirement plans, tuition reimbursement, etc.'
}),
# Application Information
'application_url': forms.URLInput(attrs={
'class': 'form-control',
'placeholder': 'https://university.edu/careers/job123',
'required': True
}),
'application_deadline': forms.DateInput(attrs={
'class': 'form-control',
'type': 'date'
}),
'application_instructions': forms.Textarea(attrs={
'class': 'form-control',
'rows': 3,
'placeholder': 'Special instructions for applicants (e.g., required documents, reference requirements, etc.)'
}),
'open_positions': forms.NumberInput(attrs={
'class': 'form-control',
'min': 1,
'placeholder': 'Number of open positions'
}),
'hash_tags': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': '#hiring,#jobopening',
'validators':validate_hash_tags,
}),
# Internal Information
'position_number': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'UNIV-2025-001'
}),
'reporting_to': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Department Chair, Director, etc.'
}),
'start_date': forms.DateInput(attrs={
'class': 'form-control',
'type': 'date'
}),
'created_by': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'University Administrator'
}),
}
def __init__(self,*args,**kwargs):
# Extract your custom argument BEFORE calling super()
self.is_anonymous_user = kwargs.pop('is_anonymous_user', False)
# Now call the parent __init__ with remaining args
super().__init__(*args, **kwargs)
if not self.instance.pk:# Creating new job posting
if not self.is_anonymous_user:
self.fields['created_by'].initial = 'University Administrator'
self.fields['status'].initial = 'Draft'
self.fields['location_city'].initial='Riyadh'
self.fields['location_state'].initial='Riyadh Province'
self.fields['location_country'].initial='Saudi Arabia'
def clean_hash_tags(self):
hash_tags=self.cleaned_data.get('hash_tags')
if hash_tags:
tags=[tag.strip() for tag in hash_tags.split(',') if tag.strip()]
for tag in tags:
if not tag.startswith('#'):
raise forms.ValidationError("Each hashtag must start with '#' symbol and must be comma(,) sepearted.")
return ','.join(tags)
return hash_tags # Allow blank
def clean_title(self):
title=self.cleaned_data.get('title')
if not title or len(title.strip())<3:
raise forms.ValidationError("Job title must be at least 3 characters long.")
if len(title)>200:
raise forms.ValidationError("Job title cannot exceed 200 characters.")
return title.strip()
def clean_description(self):
description=self.cleaned_data.get('description')
if not description or len(description.strip())<20:
raise forms.ValidationError("Job description must be at least 20 characters long.")
return description.strip() # to remove leading/trailing whitespace
def clean_application_url(self):
url=self.cleaned_data.get('application_url')
if url:
validator=URLValidator()
try:
validator(url)
except forms.ValidationError:
raise forms.ValidationError('Please enter a valid URL (e.g., https://example.com)')
return url
def clean(self):
"""Cross-field validation"""
cleaned_data = super().clean()
# Validate dates
start_date = cleaned_data.get('start_date')
application_deadline = cleaned_data.get('application_deadline')
# Perform cross-field validation only if both fields have values
if start_date and application_deadline:
if application_deadline > start_date:
self.add_error('application_deadline',
'The application deadline must be set BEFORE the job start date.')
# # Validate that if status is ACTIVE, we have required fields
# status = cleaned_data.get('status')
# if status == 'ACTIVE':
# if not cleaned_data.get('application_url'):
# self.add_error('application_url',
# 'Application URL is required for active jobs.')
# if not cleaned_data.get('description'):
# self.add_error('description',
# 'Job description is required for active jobs.')
return cleaned_data

View File

@ -0,0 +1,552 @@
# jobs/linkedin_service.py
import uuid
from urllib.parse import quote
import requests
import logging
from django.conf import settings
from urllib.parse import urlencode, quote
logger = logging.getLogger(__name__)
class LinkedInService:
def __init__(self):
self.client_id = settings.LINKEDIN_CLIENT_ID
self.client_secret = settings.LINKEDIN_CLIENT_SECRET
self.redirect_uri = settings.LINKEDIN_REDIRECT_URI
self.access_token = None
def get_auth_url(self):
"""Generate LinkedIn OAuth URL"""
params = {
'response_type': 'code',
'client_id': self.client_id,
'redirect_uri': self.redirect_uri,
'scope': 'w_member_social openid profile',
'state': 'university_ats_linkedin'
}
return f"https://www.linkedin.com/oauth/v2/authorization?{urlencode(params)}"
def get_access_token(self, code):
"""Exchange authorization code for access token"""
# This function exchanges LinkedIns temporary authorization code for a usable access token.
url = "https://www.linkedin.com/oauth/v2/accessToken"
data = {
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': self.redirect_uri,
'client_id': self.client_id,
'client_secret': self.client_secret
}
try:
response = requests.post(url, data=data, timeout=60)
response.raise_for_status()
token_data = response.json()
"""
Example response:{
"access_token": "AQXq8HJkLmNpQrStUvWxYz...",
"expires_in": 5184000
}
"""
self.access_token = token_data.get('access_token')
return self.access_token
except Exception as e:
logger.error(f"Error getting access token: {e}")
raise
def get_user_profile(self):
"""Get user profile information"""
if not self.access_token:
raise Exception("No access token available")
url = "https://api.linkedin.com/v2/userinfo"
headers = {'Authorization': f'Bearer {self.access_token}'}
try:
response = requests.get(url, headers=headers, timeout=60)
response.raise_for_status() # Ensure we raise an error for bad responses(4xx, 5xx) and does nothing for 2xx(success)
return response.json() # returns a dict from json response (deserialize)
except Exception as e:
logger.error(f"Error getting user profile: {e}")
raise
def register_image_upload(self, person_urn):
"""Step 1: Register image upload with LinkedIn"""
url = "https://api.linkedin.com/v2/assets?action=registerUpload"
headers = {
'Authorization': f'Bearer {self.access_token}',
'Content-Type': 'application/json',
'X-Restli-Protocol-Version': '2.0.0'
}
payload = {
"registerUploadRequest": {
"recipes": ["urn:li:digitalmediaRecipe:feedshare-image"],
"owner": f"urn:li:person:{person_urn}",
"serviceRelationships": [{
"relationshipType": "OWNER",
"identifier": "urn:li:userGeneratedContent"
}]
}
}
response = requests.post(url, headers=headers, json=payload, timeout=30)
response.raise_for_status()
data = response.json()
return {
'upload_url': data['value']['uploadMechanism']['com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest']['uploadUrl'],
'asset': data['value']['asset']
}
def upload_image_to_linkedin(self, upload_url, image_file):
"""Step 2: Upload actual image file to LinkedIn"""
# Open and read the Django ImageField
image_file.open()
image_content = image_file.read()
image_file.close()
headers = {
'Authorization': f'Bearer {self.access_token}',
}
response = requests.post(upload_url, headers=headers, data=image_content, timeout=60)
response.raise_for_status()
return True
def create_job_post(self, job_posting):
"""Create a job announcement post on LinkedIn (with image support)"""
if not self.access_token:
raise Exception("Not authenticated with LinkedIn")
try:
# Get user profile for person URN
profile = self.get_user_profile()
person_urn = profile.get('sub')
if not person_urn:
raise Exception("Could not retrieve LinkedIn user ID")
# Check if job has an image
try:
image_upload = job_posting.files.first()
has_image = image_upload and image_upload.linkedinpost_image
except Exception:
has_image = False
if has_image:
# === POST WITH IMAGE ===
try:
# Step 1: Register image upload
upload_info = self.register_image_upload(person_urn)
# Step 2: Upload image
self.upload_image_to_linkedin(
upload_info['upload_url'],
image_upload.linkedinpost_image
)
# Step 3: Create post with image
return self.create_job_post_with_image(
job_posting,
image_upload.linkedinpost_image,
person_urn,
upload_info['asset']
)
except Exception as e:
logger.error(f"Image upload failed: {e}")
# Fall back to text-only post if image upload fails
has_image = False
# === FALLBACK TO URL/ARTICLE POST ===
# Add unique timestamp to prevent duplicates
from django.utils import timezone
import random
unique_suffix = f"\n\nPosted: {timezone.now().strftime('%b %d, %Y at %I:%M %p')} (ID: {random.randint(1000, 9999)})"
message_parts = [f"🚀 **We're Hiring: {job_posting.title}**"]
if job_posting.department:
message_parts.append(f"**Department:** {job_posting.department}")
if job_posting.description:
message_parts.append(f"\n{job_posting.description}")
details = []
if job_posting.job_type:
details.append(f"💼 {job_posting.get_job_type_display()}")
if job_posting.get_location_display() != 'Not specified':
details.append(f"📍 {job_posting.get_location_display()}")
if job_posting.workplace_type:
details.append(f"🏠 {job_posting.get_workplace_type_display()}")
if job_posting.salary_range:
details.append(f"💰 {job_posting.salary_range}")
if details:
message_parts.append("\n" + " | ".join(details))
if job_posting.application_url:
message_parts.append(f"\n🔗 **Apply now:** {job_posting.application_url}")
hashtags = self.hashtags_list(job_posting.hash_tags)
if job_posting.department:
dept_hashtag = f"#{job_posting.department.replace(' ', '')}"
hashtags.insert(0, dept_hashtag)
message_parts.append("\n\n" + " ".join(hashtags))
message_parts.append(unique_suffix)
message = "\n".join(message_parts)
# 🔥 FIX URL - REMOVE TRAILING SPACES 🔥
url = "https://api.linkedin.com/v2/ugcPosts"
headers = {
'Authorization': f'Bearer {self.access_token}',
'Content-Type': 'application/json',
'X-Restli-Protocol-Version': '2.0.0'
}
payload = {
"author": f"urn:li:person:{person_urn}",
"lifecycleState": "PUBLISHED",
"specificContent": {
"com.linkedin.ugc.ShareContent": {
"shareCommentary": {"text": message},
"shareMediaCategory": "ARTICLE",
"media": [{
"status": "READY",
"description": {"text": f"Apply for {job_posting.title} at our university!"},
"originalUrl": job_posting.application_url,
"title": {"text": job_posting.title}
}]
}
},
"visibility": {
"com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"
}
}
response = requests.post(url, headers=headers, json=payload, timeout=60)
response.raise_for_status()
post_id = response.headers.get('x-restli-id', '')
post_url = f"https://www.linkedin.com/feed/update/{quote(post_id)}/" if post_id else ""
return {
'success': True,
'post_id': post_id,
'post_url': post_url,
'status_code': response.status_code
}
except Exception as e:
logger.error(f"Error creating LinkedIn post: {e}")
return {
'success': False,
'error': str(e),
'status_code': getattr(e.response, 'status_code', 500) if hasattr(e, 'response') else 500
}
# def create_job_post_with_image(self, job_posting, image_file, person_urn, asset_urn):
# """Step 3: Create post with uploaded image"""
# url = "https://api.linkedin.com/v2/ugcPosts"
# headers = {
# 'Authorization': f'Bearer {self.access_token}',
# 'Content-Type': 'application/json',
# 'X-Restli-Protocol-Version': '2.0.0'
# }
# # Build the same message as before
# message_parts = [f"🚀 **We're Hiring: {job_posting.title}**"]
# if job_posting.department:
# message_parts.append(f"**Department:** {job_posting.department}")
# if job_posting.description:
# message_parts.append(f"\n{job_posting.description}")
# details = []
# if job_posting.job_type:
# details.append(f"💼 {job_posting.get_job_type_display()}")
# if job_posting.get_location_display() != 'Not specified':
# details.append(f"📍 {job_posting.get_location_display()}")
# if job_posting.workplace_type:
# details.append(f"🏠 {job_posting.get_workplace_type_display()}")
# if job_posting.salary_range:
# details.append(f"💰 {job_posting.salary_range}")
# if details:
# message_parts.append("\n" + " | ".join(details))
# if job_posting.application_url:
# message_parts.append(f"\n🔗 **Apply now:** {job_posting.application_url}")
# hashtags = self.hashtags_list(job_posting.hash_tags)
# if job_posting.department:
# dept_hashtag = f"#{job_posting.department.replace(' ', '')}"
# hashtags.insert(0, dept_hashtag)
# message_parts.append("\n\n" + " ".join(hashtags))
# message = "\n".join(message_parts)
# # Create image post payload
# payload = {
# "author": f"urn:li:person:{person_urn}",
# "lifecycleState": "PUBLISHED",
# "specificContent": {
# "com.linkedin.ugc.ShareContent": {
# "shareCommentary": {"text": message},
# "shareMediaCategory": "IMAGE",
# "media": [{
# "status": "READY",
# "media": asset_urn
# }]
# }
# },
# "visibility": {
# "com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"
# }
# }
# response = requests.post(url, headers=headers, json=payload, timeout=30)
# response.raise_for_status()
# post_id = response.headers.get('x-restli-id', '')
# post_url = f"https://www.linkedin.com/feed/update/{quote(post_id)}/" if post_id else ""
# return {
# 'success': True,
# 'post_id': post_id,
# 'post_url': post_url,
# 'status_code': response.status_code
# }
def hashtags_list(self,hash_tags_str):
"""Convert comma-separated hashtags string to list"""
if not hash_tags_str:
return [""]
tags = [tag.strip() for tag in hash_tags_str.split(',') if tag.strip()]
if not tags:
return ["#HigherEd", "#Hiring", "#FacultyJobs", "#UniversityJobs"]
return tags
# def create_job_post(self, job_posting):
# """Create a job announcement post on LinkedIn (with image support)"""
# if not self.access_token:
# raise Exception("Not authenticated with LinkedIn")
# try:
# # Get user profile for person URN
# profile = self.get_user_profile()
# person_urn = profile.get('sub')
# if not person_urn:
# raise Exception("Could not retrieve LinkedIn user ID")
# # Check if job has an image
# try:
# image_upload = job_posting.files.first()
# has_image = image_upload and image_upload.linkedinpost_image
# except Exception:
# has_image = False
# if has_image:
# # === POST WITH IMAGE ===
# upload_info = self.register_image_upload(person_urn)
# self.upload_image_to_linkedin(
# upload_info['upload_url'],
# image_upload.linkedinpost_image
# )
# return self.create_job_post_with_image(
# job_posting,
# image_upload.linkedinpost_image,
# person_urn,
# upload_info['asset']
# )
# else:
# # === FALLBACK TO URL/ARTICLE POST ===
# # 🔥 ADD UNIQUE TIMESTAMP TO PREVENT DUPLICATES 🔥
# from django.utils import timezone
# import random
# unique_suffix = f"\n\nPosted: {timezone.now().strftime('%b %d, %Y at %I:%M %p')} (ID: {random.randint(1000, 9999)})"
# message_parts = [f"🚀 **We're Hiring: {job_posting.title}**"]
# if job_posting.department:
# message_parts.append(f"**Department:** {job_posting.department}")
# if job_posting.description:
# message_parts.append(f"\n{job_posting.description}")
# details = []
# if job_posting.job_type:
# details.append(f"💼 {job_posting.get_job_type_display()}")
# if job_posting.get_location_display() != 'Not specified':
# details.append(f"📍 {job_posting.get_location_display()}")
# if job_posting.workplace_type:
# details.append(f"🏠 {job_posting.get_workplace_type_display()}")
# if job_posting.salary_range:
# details.append(f"💰 {job_posting.salary_range}")
# if details:
# message_parts.append("\n" + " | ".join(details))
# if job_posting.application_url:
# message_parts.append(f"\n🔗 **Apply now:** {job_posting.application_url}")
# hashtags = self.hashtags_list(job_posting.hash_tags)
# if job_posting.department:
# dept_hashtag = f"#{job_posting.department.replace(' ', '')}"
# hashtags.insert(0, dept_hashtag)
# message_parts.append("\n\n" + " ".join(hashtags))
# message_parts.append(unique_suffix) # 🔥 Add unique suffix
# message = "\n".join(message_parts)
# # 🔥 FIX URL - REMOVE TRAILING SPACES 🔥
# url = "https://api.linkedin.com/v2/ugcPosts"
# headers = {
# 'Authorization': f'Bearer {self.access_token}',
# 'Content-Type': 'application/json',
# 'X-Restli-Protocol-Version': '2.0.0'
# }
# payload = {
# "author": f"urn:li:person:{person_urn}",
# "lifecycleState": "PUBLISHED",
# "specificContent": {
# "com.linkedin.ugc.ShareContent": {
# "shareCommentary": {"text": message},
# "shareMediaCategory": "ARTICLE",
# "media": [{
# "status": "READY",
# "description": {"text": f"Apply for {job_posting.title} at our university!"},
# "originalUrl": job_posting.application_url,
# "title": {"text": job_posting.title}
# }]
# }
# },
# "visibility": {
# "com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"
# }
# }
# response = requests.post(url, headers=headers, json=payload, timeout=60)
# response.raise_for_status()
# post_id = response.headers.get('x-restli-id', '')
# # 🔥 FIX POST URL - REMOVE TRAILING SPACES 🔥
# post_url = f"https://www.linkedin.com/feed/update/{quote(post_id)}/" if post_id else ""
# return {
# 'success': True,
# 'post_id': post_id,
# 'post_url': post_url,
# 'status_code': response.status_code
# }
# except Exception as e:
# logger.error(f"Error creating LinkedIn post: {e}")
# return {
# 'success': False,
# 'error': str(e),
# 'status_code': getattr(e.response, 'status_code', 500) if hasattr(e, 'response') else 500
# }
# def create_job_post(self, job_posting):
# """Create a job announcement post on LinkedIn"""
# if not self.access_token:
# raise Exception("Not authenticated with LinkedIn")
# try:
# # Get user profile for person URN
# profile = self.get_user_profile()
# person_urn = profile.get('sub')
# if not person_urn: # uniform resource name used to uniquely identify linked-id for internal systems and apis
# raise Exception("Could not retrieve LinkedIn user ID")
# # Build professional job post message
# message_parts = [f"🚀 **We're Hiring: {job_posting.title}**"]
# if job_posting.department:
# message_parts.append(f"**Department:** {job_posting.department}")
# if job_posting.description:
# message_parts.append(f"\n{job_posting.description}")
# # Add job details
# details = []
# if job_posting.job_type:
# details.append(f"💼 {job_posting.get_job_type_display()}")
# if job_posting.get_location_display() != 'Not specified':
# details.append(f"📍 {job_posting.get_location_display()}")
# if job_posting.workplace_type:
# details.append(f"🏠 {job_posting.get_workplace_type_display()}")
# if job_posting.salary_range:
# details.append(f"💰 {job_posting.salary_range}")
# if details:
# message_parts.append("\n" + " | ".join(details))
# # Add application link
# if job_posting.application_url:
# message_parts.append(f"\n🔗 **Apply now:** {job_posting.application_url}")
# # Add hashtags
# hashtags = ["#HigherEd", "#Hiring", "#FacultyJobs", "#UniversityJobs"]
# if job_posting.department:
# dept_hashtag = f"#{job_posting.department.replace(' ', '')}"
# hashtags.insert(0, dept_hashtag)
# message_parts.append("\n\n" + " ".join(hashtags))
# message = "\n".join(message_parts)
# # Create LinkedIn post
# url = "https://api.linkedin.com/v2/ugcPosts"
# headers = {
# 'Authorization': f'Bearer {self.access_token}',
# 'Content-Type': 'application/json',
# 'X-Restli-Protocol-Version': '2.0.0'
# }
# payload = {
# "author": f"urn:li:person:{person_urn}",
# "lifecycleState": "PUBLISHED",
# "specificContent": {
# "com.linkedin.ugc.ShareContent": {
# "shareCommentary": {"text": message},
# "shareMediaCategory": "ARTICLE",
# "media": [{
# "status": "READY",
# "description": {"text": f"Apply for {job_posting.title} at our university!"},
# "originalUrl": job_posting.application_url,
# "title": {"text": job_posting.title}
# }]
# }
# },
# "visibility": {
# "com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"
# }
# }
# response = requests.post(url, headers=headers, json=payload, timeout=60)
# response.raise_for_status()
# # Extract post ID from response
# post_id = response.headers.get('x-restli-id', '')
# post_url = f"https://www.linkedin.com/feed/update/{quote(post_id)}/" if post_id else ""
# return {
# 'success': True,
# 'post_id': post_id,
# 'post_url': post_url,
# 'status_code': response.status_code
# }
# except Exception as e:
# logger.error(f"Error creating LinkedIn post: {e}")
# return {
# 'success': False,
# 'error': str(e),
# 'status_code': getattr(e.response, 'status_code', 500) if hasattr(e, 'response') else 500
# }

View File

@ -0,0 +1,318 @@
# Generated by Django 5.2.6 on 2025-10-02 14:14
import django.core.validators
import django.db.models.deletion
import recruitment.validators
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0005_zoommeeting'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='JobPosting',
fields=[
('title', models.CharField(max_length=200)),
('department', models.CharField(blank=True, max_length=100)),
('job_type', models.CharField(choices=[('FULL_TIME', 'Full-time'), ('PART_TIME', 'Part-time'), ('CONTRACT', 'Contract'), ('INTERNSHIP', 'Internship'), ('FACULTY', 'Faculty'), ('TEMPORARY', 'Temporary')], default='FULL_TIME', max_length=20)),
('workplace_type', models.CharField(choices=[('ON_SITE', 'On-site'), ('REMOTE', 'Remote'), ('HYBRID', 'Hybrid')], default='ON_SITE', max_length=20)),
('location_city', models.CharField(blank=True, max_length=100)),
('location_state', models.CharField(blank=True, max_length=100)),
('location_country', models.CharField(default='United States', max_length=100)),
('description', models.TextField(help_text='Full job description including responsibilities and requirements')),
('qualifications', models.TextField(blank=True, help_text='Required qualifications and skills')),
('salary_range', models.CharField(blank=True, help_text='e.g., $60,000 - $80,000', max_length=200)),
('benefits', models.TextField(blank=True, help_text='Benefits offered')),
('application_url', models.URLField(help_text='URL where candidates apply', validators=[django.core.validators.URLValidator()])),
('application_deadline', models.DateField(blank=True, null=True)),
('application_instructions', models.TextField(blank=True, help_text='Special instructions for applicants')),
('internal_job_id', models.CharField(editable=False, max_length=50, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('created_by', models.CharField(blank=True, help_text='Name of person who created this job', max_length=100)),
('status', models.CharField(choices=[('DRAFT', 'Draft'), ('ACTIVE', 'Active'), ('CLOSED', 'Closed'), ('ARCHIVED', 'Archived')], default='DRAFT', max_length=20)),
('hash_tags', models.CharField(blank=True, help_text='Comma-separated hashtags for linkedin post like #hiring,#jobopening', max_length=200, validators=[recruitment.validators.validate_hash_tags])),
('linkedin_post_id', models.CharField(blank=True, help_text='LinkedIn post ID after posting', max_length=200)),
('linkedin_post_url', models.URLField(blank=True, help_text='Direct URL to LinkedIn post')),
('posted_to_linkedin', models.BooleanField(default=False)),
('linkedin_post_status', models.CharField(blank=True, help_text='Status of LinkedIn posting', max_length=50)),
('linkedin_posted_at', models.DateTimeField(blank=True, null=True)),
('position_number', models.CharField(blank=True, help_text='University position number', max_length=50)),
('reporting_to', models.CharField(blank=True, help_text='Who this position reports to', max_length=100)),
('start_date', models.DateField(blank=True, help_text='Desired start date', null=True)),
('open_positions', models.PositiveIntegerField(default=1, help_text='Number of open positions for this job')),
],
options={
'verbose_name': 'Job Posting',
'verbose_name_plural': 'Job Postings',
'ordering': ['-created_at'],
},
),
migrations.AlterModelOptions(
name='candidate',
options={'verbose_name': 'Candidate', 'verbose_name_plural': 'Candidates'},
),
migrations.AlterModelOptions(
name='job',
options={'verbose_name': 'Job', 'verbose_name_plural': 'Jobs'},
),
migrations.AlterModelOptions(
name='trainingmaterial',
options={'verbose_name': 'Training Material', 'verbose_name_plural': 'Training Materials'},
),
migrations.RemoveField(
model_name='zoommeeting',
name='host_email',
),
migrations.RemoveField(
model_name='zoommeeting',
name='host_video',
),
migrations.RemoveField(
model_name='zoommeeting',
name='password',
),
migrations.RemoveField(
model_name='zoommeeting',
name='status',
),
migrations.AddField(
model_name='candidate',
name='exam_date',
field=models.DateField(blank=True, null=True, verbose_name='Exam Date'),
),
migrations.AddField(
model_name='candidate',
name='exam_status',
field=models.CharField(blank=True, choices=[('Passed', 'Passed'), ('Failed', 'Failed')], max_length=100, null=True, verbose_name='Exam Status'),
),
migrations.AddField(
model_name='candidate',
name='first_name',
field=models.CharField(default='user', max_length=255, verbose_name='First Name'),
preserve_default=False,
),
migrations.AddField(
model_name='candidate',
name='interview_date',
field=models.DateField(blank=True, null=True, verbose_name='Interview Date'),
),
migrations.AddField(
model_name='candidate',
name='interview_status',
field=models.CharField(blank=True, choices=[('Accepted', 'Accepted'), ('Rejected', 'Rejected')], max_length=100, null=True, verbose_name='Interview Status'),
),
migrations.AddField(
model_name='candidate',
name='join_date',
field=models.DateField(blank=True, null=True, verbose_name='Join Date'),
),
migrations.AddField(
model_name='candidate',
name='last_name',
field=models.CharField(default='user', max_length=255, verbose_name='Last Name'),
preserve_default=False,
),
migrations.AddField(
model_name='candidate',
name='offer_date',
field=models.DateField(blank=True, null=True, verbose_name='Offer Date'),
),
migrations.AddField(
model_name='candidate',
name='offer_status',
field=models.CharField(blank=True, choices=[('Accepted', 'Accepted'), ('Rejected', 'Rejected')], max_length=100, null=True, verbose_name='Offer Status'),
),
migrations.AddField(
model_name='candidate',
name='phone',
field=models.CharField(default='0569874562', max_length=20, verbose_name='Phone'),
preserve_default=False,
),
migrations.AddField(
model_name='candidate',
name='stage',
field=models.CharField(default='Applied', max_length=100, verbose_name='Stage'),
),
migrations.AlterField(
model_name='candidate',
name='applied',
field=models.BooleanField(default=False, verbose_name='Applied'),
),
migrations.AlterField(
model_name='candidate',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='Created at'),
),
migrations.AlterField(
model_name='candidate',
name='email',
field=models.EmailField(max_length=254, verbose_name='Email'),
),
migrations.AlterField(
model_name='candidate',
name='name',
field=models.CharField(max_length=255, verbose_name='Name'),
),
migrations.AlterField(
model_name='candidate',
name='parsed_summary',
field=models.TextField(blank=True, verbose_name='Parsed Summary'),
),
migrations.AlterField(
model_name='candidate',
name='resume',
field=models.FileField(upload_to='resumes/', verbose_name='Resume'),
),
migrations.AlterField(
model_name='candidate',
name='updated_at',
field=models.DateTimeField(auto_now=True, verbose_name='Updated at'),
),
migrations.AlterField(
model_name='job',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='Created at'),
),
migrations.AlterField(
model_name='job',
name='description_ar',
field=models.TextField(verbose_name='Description Arabic'),
),
migrations.AlterField(
model_name='job',
name='description_en',
field=models.TextField(verbose_name='Description English'),
),
migrations.AlterField(
model_name='job',
name='is_published',
field=models.BooleanField(default=False, verbose_name='Published'),
),
migrations.AlterField(
model_name='job',
name='posted_to_linkedin',
field=models.BooleanField(default=False, verbose_name='Posted to LinkedIn'),
),
migrations.AlterField(
model_name='job',
name='title',
field=models.CharField(max_length=255, verbose_name='Title'),
),
migrations.AlterField(
model_name='job',
name='updated_at',
field=models.DateTimeField(auto_now=True, verbose_name='Updated at'),
),
migrations.AlterField(
model_name='trainingmaterial',
name='content',
field=models.TextField(blank=True, verbose_name='Content'),
),
migrations.AlterField(
model_name='trainingmaterial',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='Created at'),
),
migrations.AlterField(
model_name='trainingmaterial',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Created by'),
),
migrations.AlterField(
model_name='trainingmaterial',
name='file',
field=models.FileField(blank=True, upload_to='training_materials/', verbose_name='File'),
),
migrations.AlterField(
model_name='trainingmaterial',
name='title',
field=models.CharField(max_length=255, verbose_name='Title'),
),
migrations.AlterField(
model_name='trainingmaterial',
name='updated_at',
field=models.DateTimeField(auto_now=True, verbose_name='Updated at'),
),
migrations.AlterField(
model_name='trainingmaterial',
name='video_link',
field=models.URLField(blank=True, verbose_name='Video Link'),
),
migrations.AlterField(
model_name='zoommeeting',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='Created at'),
),
migrations.AlterField(
model_name='zoommeeting',
name='duration',
field=models.PositiveIntegerField(verbose_name='Duration'),
),
migrations.AlterField(
model_name='zoommeeting',
name='join_before_host',
field=models.BooleanField(default=False, verbose_name='Join Before Host'),
),
migrations.AlterField(
model_name='zoommeeting',
name='join_url',
field=models.URLField(verbose_name='Join URL'),
),
migrations.AlterField(
model_name='zoommeeting',
name='meeting_id',
field=models.CharField(max_length=20, unique=True, verbose_name='Meeting ID'),
),
migrations.AlterField(
model_name='zoommeeting',
name='mute_upon_entry',
field=models.BooleanField(default=False, verbose_name='Mute Upon Entry'),
),
migrations.AlterField(
model_name='zoommeeting',
name='participant_video',
field=models.BooleanField(default=True, verbose_name='Participant Video'),
),
migrations.AlterField(
model_name='zoommeeting',
name='start_time',
field=models.DateTimeField(verbose_name='Start Time'),
),
migrations.AlterField(
model_name='zoommeeting',
name='timezone',
field=models.CharField(max_length=50, verbose_name='Timezone'),
),
migrations.AlterField(
model_name='zoommeeting',
name='topic',
field=models.CharField(max_length=255, verbose_name='Topic'),
),
migrations.AlterField(
model_name='zoommeeting',
name='updated_at',
field=models.DateTimeField(auto_now=True, verbose_name='Updated at'),
),
migrations.AlterField(
model_name='zoommeeting',
name='waiting_room',
field=models.BooleanField(default=False, verbose_name='Waiting Room'),
),
migrations.AlterField(
model_name='zoommeeting',
name='zoom_gateway_response',
field=models.JSONField(blank=True, null=True, verbose_name='Zoom Gateway Response'),
),
migrations.AlterField(
model_name='candidate',
name='job',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='candidates', to='recruitment.jobposting', verbose_name='Job'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.6 on 2025-10-02 14:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0006_jobposting_alter_candidate_options_alter_job_options_and_more'),
]
operations = [
migrations.AlterField(
model_name='jobposting',
name='status',
field=models.CharField(blank=True, choices=[('DRAFT', 'Draft'), ('ACTIVE', 'Active'), ('CLOSED', 'Closed'), ('ARCHIVED', 'Archived')], default='DRAFT', max_length=20, null=True),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 5.2.6 on 2025-10-02 14:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0007_alter_jobposting_status'),
]
operations = [
migrations.AddField(
model_name='jobposting',
name='published_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AlterField(
model_name='jobposting',
name='status',
field=models.CharField(blank=True, choices=[('DRAFT', 'Draft'), ('PUBLISHED', 'Published'), ('CLOSED', 'Closed'), ('ARCHIVED', 'Archived')], default='DRAFT', max_length=20, null=True),
),
]

View File

@ -0,0 +1,49 @@
# Generated by Django 5.2.6 on 2025-10-02 14:39
import django_extensions.db.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0008_jobposting_published_at_alter_jobposting_status'),
]
operations = [
migrations.AddField(
model_name='candidate',
name='slug',
field=django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug'),
),
migrations.AddField(
model_name='job',
name='slug',
field=django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug'),
),
migrations.AddField(
model_name='jobposting',
name='slug',
field=django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug'),
),
migrations.AddField(
model_name='trainingmaterial',
name='slug',
field=django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug'),
),
migrations.AddField(
model_name='zoommeeting',
name='slug',
field=django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug'),
),
migrations.AlterField(
model_name='jobposting',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='Created at'),
),
migrations.AlterField(
model_name='jobposting',
name='updated_at',
field=models.DateTimeField(auto_now=True, verbose_name='Updated at'),
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 5.2.6 on 2025-10-02 15:16
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0009_candidate_slug_job_slug_jobposting_slug_and_more'),
]
operations = [
migrations.RemoveField(
model_name='candidate',
name='name',
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.6 on 2025-10-02 16:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0010_remove_candidate_name'),
]
operations = [
migrations.AlterField(
model_name='candidate',
name='stage',
field=models.CharField(choices=[('Applied', 'Applied'), ('Exam', 'Exam'), ('Interview', 'Interview'), ('Offer', 'Offer')], default='Applied', max_length=100, verbose_name='Stage'),
),
]

View File

@ -0,0 +1,57 @@
# Generated by Django 5.2.6 on 2025-10-04 12:39
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0011_alter_candidate_stage'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Form',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=200)),
('description', models.TextField(blank=True)),
('structure', models.JSONField(default=dict)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('is_active', models.BooleanField(default=True)),
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='FormSubmission',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('submission_data', models.JSONField(default=dict)),
('submitted_at', models.DateTimeField(auto_now_add=True)),
('ip_address', models.GenericIPAddressField(blank=True, null=True)),
('user_agent', models.TextField(blank=True)),
('form', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='submissions', to='recruitment.form')),
],
options={
'ordering': ['-submitted_at'],
},
),
migrations.CreateModel(
name='UploadedFile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('field_id', models.CharField(max_length=100)),
('file', models.FileField(upload_to='form_uploads/%Y/%m/%d/')),
('original_filename', models.CharField(max_length=255)),
('uploaded_at', models.DateTimeField(auto_now_add=True)),
('submission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to='recruitment.formsubmission')),
],
),
]

View File

@ -1,76 +1,315 @@
from django.db import models
from django.utils import timezone
from .validators import validate_hash_tags
from django.contrib.auth.models import User
from django.core.validators import URLValidator
from django.utils.translation import gettext_lazy as _
from django_extensions.db.fields import RandomCharField
from django.core.exceptions import ValidationError
class Base(models.Model):
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('Created at'))
updated_at = models.DateTimeField(auto_now=True, verbose_name=_('Updated at'))
slug = RandomCharField(length=8, unique=True, editable=False, verbose_name=_('Slug'))
class Meta:
abstract = True
# Create your models here.
class Job(models.Model):
title = models.CharField(max_length=255)
description_en = models.TextField()
description_ar = models.TextField()
is_published = models.BooleanField(default=False)
posted_to_linkedin = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Job(Base):
title = models.CharField(max_length=255, verbose_name=_('Title'))
description_en = models.TextField(verbose_name=_('Description English'))
description_ar = models.TextField(verbose_name=_('Description Arabic'))
is_published = models.BooleanField(default=False, verbose_name=_('Published'))
posted_to_linkedin = models.BooleanField(default=False, verbose_name=_('Posted to LinkedIn'))
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('Created at'))
updated_at = models.DateTimeField(auto_now=True, verbose_name=_('Updated at'))
class Meta:
verbose_name = _('Job')
verbose_name_plural = _('Jobs')
def __str__(self):
return self.title
class JobPosting(Base):
# Basic Job Information
JOB_TYPES = [
('FULL_TIME', 'Full-time'),
('PART_TIME', 'Part-time'),
('CONTRACT', 'Contract'),
('INTERNSHIP', 'Internship'),
('FACULTY', 'Faculty'),
('TEMPORARY', 'Temporary'),
]
WORKPLACE_TYPES = [
('ON_SITE', 'On-site'),
('REMOTE', 'Remote'),
('HYBRID', 'Hybrid'),
]
# Core Fields
title = models.CharField(max_length=200)
department = models.CharField(max_length=100, blank=True)
job_type = models.CharField(max_length=20, choices=JOB_TYPES, default='FULL_TIME')
workplace_type = models.CharField(max_length=20, choices=WORKPLACE_TYPES, default='ON_SITE')
# Location
location_city = models.CharField(max_length=100, blank=True)
location_state = models.CharField(max_length=100, blank=True)
location_country = models.CharField(max_length=100, default='United States')
# Job Details
description = models.TextField(help_text="Full job description including responsibilities and requirements")
qualifications = models.TextField(blank=True, help_text="Required qualifications and skills")
salary_range = models.CharField(max_length=200, blank=True, help_text="e.g., $60,000 - $80,000")
benefits = models.TextField(blank=True, help_text="Benefits offered")
# Application Information
application_url = models.URLField(validators=[URLValidator()], help_text="URL where candidates apply")
application_deadline = models.DateField(null=True, blank=True)
application_instructions = models.TextField(blank=True, help_text="Special instructions for applicants")
# Internal Tracking
internal_job_id = models.CharField(max_length=50, primary_key=True, editable=False)
created_by = models.CharField(max_length=100, blank=True, help_text="Name of person who created this job")
# Status Fields
STATUS_CHOICES = [
('DRAFT', 'Draft'),
('PUBLISHED', 'Published'),
('CLOSED', 'Closed'),
('ARCHIVED', 'Archived'),
]
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='DRAFT',null=True, blank=True)
#hashtags for social media
hash_tags = models.CharField(max_length=200, blank=True, help_text="Comma-separated hashtags for linkedin post like #hiring,#jobopening",validators=[validate_hash_tags])
# LinkedIn Integration Fields
linkedin_post_id = models.CharField(max_length=200, blank=True, help_text="LinkedIn post ID after posting")
linkedin_post_url = models.URLField(blank=True, help_text="Direct URL to LinkedIn post")
posted_to_linkedin = models.BooleanField(default=False)
linkedin_post_status = models.CharField(max_length=50, blank=True, help_text="Status of LinkedIn posting")
linkedin_posted_at = models.DateTimeField(null=True, blank=True)
published_at = models.DateTimeField(null=True, blank=True)
# University Specific Fields
position_number = models.CharField(max_length=50, blank=True, help_text="University position number")
reporting_to = models.CharField(max_length=100, blank=True, help_text="Who this position reports to")
start_date = models.DateField(null=True, blank=True, help_text="Desired start date")
open_positions = models.PositiveIntegerField(default=1, help_text="Number of open positions for this job")
class Meta:
ordering = ['-created_at']
verbose_name = "Job Posting"
verbose_name_plural = "Job Postings"
class Candidate(models.Model):
job = models.ForeignKey(Job, on_delete=models.CASCADE, related_name='candidates')
name = models.CharField(max_length=255)
email = models.EmailField()
resume = models.FileField(upload_to='resumes/')
parsed_summary = models.TextField(blank=True)
applied = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f"{self.title} - {self.get_status_display()}"
def save(self, *args, **kwargs):
# Generate unique internal job ID if not exists
if not self.internal_job_id:
prefix = "UNIV"
year = timezone.now().year
# Get next sequential number
last_job = JobPosting.objects.filter(
internal_job_id__startswith=f"{prefix}-{year}-"
).order_by('internal_job_id').last()
if last_job:
last_num = int(last_job.internal_job_id.split('-')[-1])
next_num = last_num + 1
else:
next_num = 1
self.internal_job_id = f"{prefix}-{year}-{next_num:04d}"
super().save(*args, **kwargs)
def get_location_display(self):
"""Return formatted location string"""
parts = []
if self.location_city:
parts.append(self.location_city)
if self.location_state:
parts.append(self.location_state)
if self.location_country and self.location_country != 'United States':
parts.append(self.location_country)
return ', '.join(parts) if parts else 'Not specified'
def is_expired(self):
"""Check if application deadline has passed"""
if self.application_deadline:
return self.application_deadline < timezone.now().date()
return False
class Candidate(Base):
class Stage(models.TextChoices):
APPLIED = 'Applied', _('Applied')
EXAM = 'Exam', _('Exam')
INTERVIEW = 'Interview', _('Interview')
OFFER = 'Offer', _('Offer')
class ExamStatus(models.TextChoices):
PASSED = 'Passed', _('Passed')
FAILED = 'Failed', _('Failed')
class Status(models.TextChoices):
ACCEPTED = 'Accepted', _('Accepted')
REJECTED = 'Rejected', _('Rejected')
# Stage transition validation constants
STAGE_SEQUENCE = {
'Applied': ['Exam', 'Interview', 'Offer'],
'Exam': ['Interview', 'Offer'],
'Interview': ['Offer'],
'Offer': [] # Final stage - no further transitions
}
job = models.ForeignKey(JobPosting, on_delete=models.CASCADE, related_name='candidates', verbose_name=_('Job'))
first_name = models.CharField(max_length=255, verbose_name=_('First Name'))
last_name = models.CharField(max_length=255, verbose_name=_('Last Name'))
email = models.EmailField(verbose_name=_('Email'))
phone = models.CharField(max_length=20, verbose_name=_('Phone'))
resume = models.FileField(upload_to='resumes/', verbose_name=_('Resume'))
parsed_summary = models.TextField(blank=True, verbose_name=_('Parsed Summary'))
applied = models.BooleanField(default=False, verbose_name=_('Applied'))
stage = models.CharField(max_length=100, default='Applied', choices=Stage.choices, verbose_name=_('Stage'))
exam_date = models.DateField(null=True, blank=True, verbose_name=_('Exam Date'))
exam_status = models.CharField(choices=ExamStatus.choices,max_length=100, null=True, blank=True, verbose_name=_('Exam Status'))
interview_date = models.DateField(null=True, blank=True, verbose_name=_('Interview Date'))
interview_status = models.CharField(choices=Status.choices,max_length=100, null=True, blank=True, verbose_name=_('Interview Status'))
offer_date = models.DateField(null=True, blank=True, verbose_name=_('Offer Date'))
offer_status = models.CharField(choices=Status.choices,max_length=100, null=True, blank=True, verbose_name=_('Offer Status'))
join_date = models.DateField(null=True, blank=True, verbose_name=_('Join Date'))
class Meta:
verbose_name = _('Candidate')
verbose_name_plural = _('Candidates')
@property
def name(self):
return f"{self.first_name} {self.last_name}"
@property
def full_name(self):
return self.name
def clean(self):
"""Validate stage transitions"""
# Only validate if this is an existing record (not being created)
if self.pk and self.stage != self.__class__.objects.get(pk=self.pk).stage:
old_stage = self.__class__.objects.get(pk=self.pk).stage
allowed_next_stages = self.STAGE_SEQUENCE.get(old_stage, [])
if self.stage not in allowed_next_stages:
raise ValidationError({
'stage': f'Cannot transition from "{old_stage}" to "{self.stage}". '
f'Allowed transitions: {", ".join(allowed_next_stages) or "None (final stage)"}'
})
# Validate that the stage is a valid choice
if self.stage not in [choice[0] for choice in self.Stage.choices]:
raise ValidationError({
'stage': f'Invalid stage. Must be one of: {", ".join(choice[0] for choice in self.Stage.choices)}'
})
def save(self, *args, **kwargs):
"""Override save to ensure validation is called"""
self.clean() # Call validation before saving
super().save(*args, **kwargs)
def can_transition_to(self, new_stage):
"""Check if a stage transition is allowed"""
if not self.pk: # New record - can be in Applied stage
return new_stage == 'Applied'
old_stage = self.__class__.objects.get(pk=self.pk).stage
allowed_next_stages = self.STAGE_SEQUENCE.get(old_stage, [])
return new_stage in allowed_next_stages
def get_available_stages(self):
"""Get list of stages this candidate can transition to"""
if not self.pk: # New record
return ['Applied']
old_stage = self.__class__.objects.get(pk=self.pk).stage
return self.STAGE_SEQUENCE.get(old_stage, [])
def __str__(self):
return self.full_name
class TrainingMaterial(Base):
title = models.CharField(max_length=255, verbose_name=_('Title'))
content = models.TextField(blank=True, verbose_name=_('Content'))
video_link = models.URLField(blank=True, verbose_name=_('Video Link'))
file = models.FileField(upload_to='training_materials/', blank=True, verbose_name=_('File'))
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, verbose_name=_('Created by'))
class Meta:
verbose_name = _('Training Material')
verbose_name_plural = _('Training Materials')
class TrainingMaterial(models.Model):
title = models.CharField(max_length=255)
content = models.TextField(blank=True)
video_link = models.URLField(blank=True)
file = models.FileField(upload_to='training_materials/', blank=True)
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.title
class ZoomMeeting(models.Model):
class ZoomMeeting(Base):
# Basic meeting details
topic = models.CharField(max_length=255)
meeting_id = models.CharField(max_length=20, unique=True) # Unique identifier for the meeting
start_time = models.DateTimeField()
duration = models.PositiveIntegerField() # Duration in minutes
timezone = models.CharField(max_length=50)
join_url = models.URLField() # URL for participants to join
password = models.CharField(max_length=50, blank=True, null=True)
topic = models.CharField(max_length=255, verbose_name=_('Topic'))
meeting_id = models.CharField(max_length=20, unique=True, verbose_name=_('Meeting ID')) # Unique identifier for the meeting
start_time = models.DateTimeField(verbose_name=_('Start Time'))
duration = models.PositiveIntegerField(verbose_name=_('Duration')) # Duration in minutes
timezone = models.CharField(max_length=50, verbose_name=_('Timezone'))
join_url = models.URLField(verbose_name=_('Join URL')) # URL for participants to join
participant_video = models.BooleanField(default=True, verbose_name=_('Participant Video'))
join_before_host = models.BooleanField(default=False, verbose_name=_('Join Before Host'))
mute_upon_entry = models.BooleanField(default=False, verbose_name=_('Mute Upon Entry'))
waiting_room = models.BooleanField(default=False, verbose_name=_('Waiting Room'))
# Host information
host_email = models.EmailField()
# Status
STATUS_CHOICES = [
('waiting', 'Waiting'),
('started', 'Started'),
('ended', 'Ended'),
]
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='waiting')
# Settings
host_video = models.BooleanField(default=True)
participant_video = models.BooleanField(default=True)
join_before_host = models.BooleanField(default=False)
mute_upon_entry = models.BooleanField(default=False)
waiting_room = models.BooleanField(default=False)
zoom_gateway_response = models.JSONField(blank=True, null=True)
zoom_gateway_response = models.JSONField(blank=True, null=True, verbose_name=_('Zoom Gateway Response'))
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.topic
return self.topic
class Form(models.Model):
title = models.CharField(max_length=200)
description = models.TextField(blank=True)
structure = models.JSONField(default=dict) # Stores the form schema
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
is_active = models.BooleanField(default=True)
class Meta:
ordering = ['-created_at']
def __str__(self):
return self.title
class FormSubmission(models.Model):
form = models.ForeignKey(Form, on_delete=models.CASCADE, related_name='submissions')
submission_data = models.JSONField(default=dict) # Stores form responses
submitted_at = models.DateTimeField(auto_now_add=True)
ip_address = models.GenericIPAddressField(null=True, blank=True)
user_agent = models.TextField(blank=True)
class Meta:
ordering = ['-submitted_at']
class UploadedFile(models.Model):
submission = models.ForeignKey(FormSubmission, on_delete=models.CASCADE, related_name='files')
field_id = models.CharField(max_length=100)
file = models.FileField(upload_to='form_uploads/%Y/%m/%d/')
original_filename = models.CharField(max_length=255)
uploaded_at = models.DateTimeField(auto_now_add=True)

View File

@ -1,12 +1,14 @@
from rest_framework import serializers
from . import models
from .models import JobPosting, Candidate
class JobSerializer(serializers.ModelSerializer):
class JobPostingSerializer(serializers.ModelSerializer):
class Meta:
model = models.Job
model = JobPosting
fields = '__all__'
class CandidateSerializer(serializers.ModelSerializer):
job_title = serializers.CharField(source='job.title', read_only=True)
class Meta:
model = models.Candidate
fields = '__all__'
model = Candidate
fields = '__all__'

View File

@ -3,17 +3,60 @@ from . import views_frontend
from . import views
urlpatterns = [
path('careers/', views_frontend.job_list, name='job_list'),
path('careers/<int:job_id>/', views_frontend.job_detail, name='job_detail'),
path('training/', views_frontend.training_list, name='training_list'),
path('candidate/<int:candidate_id>/view/', views_frontend.candidate_detail, name='candidate_detail'),
path('dashboard/', views_frontend.dashboard_view, name='dashboard'),
# Job URLs (using JobPosting model)
path('jobs/', views_frontend.JobListView.as_view(), name='job_list'),
path('jobs/create/', views.create_job, name='job_create'),
path('jobs/<slug:slug>/update/', views.edit_job, name='job_update'),
# path('jobs/<slug:slug>/delete/', views., name='job_delete'),
path('jobs/<slug:slug>/', views.job_detail, name='job_detail'),
# LinkedIn Integration URLs
path('jobs/<slug:slug>/post-to-linkedin/', views.post_to_linkedin, name='post_to_linkedin'),
path('jobs/linkedin/login/', views.linkedin_login, name='linkedin_login'),
path('jobs/linkedin/callback/', views.linkedin_callback, name='linkedin_callback'),
# Candidate URLs
path('candidates/', views_frontend.CandidateListView.as_view(), name='candidate_list'),
path('candidates/create/', views_frontend.CandidateCreateView.as_view(), name='candidate_create'),
path('candidates/create/<slug:slug>/', views_frontend.CandidateCreateView.as_view(), name='candidate_create_for_job'),
path('jobs/<slug:slug>/candidates/', views_frontend.JobCandidatesListView.as_view(), name='job_candidates_list'),
path('candidates/<slug:slug>/update/', views_frontend.CandidateUpdateView.as_view(), name='candidate_update'),
path('candidates/<slug:slug>/delete/', views_frontend.CandidateDeleteView.as_view(), name='candidate_delete'),
path('candidate/<slug:slug>/view/', views_frontend.candidate_detail, name='candidate_detail'),
path('candidate/<slug:slug>/update-stage/', views_frontend.candidate_update_stage, name='candidate_update_stage'),
# Training URLs
path('training/', views_frontend.TrainingListView.as_view(), name='training_list'),
path('training/create/', views_frontend.TrainingCreateView.as_view(), name='training_create'),
path('training/<slug:slug>/', views_frontend.TrainingDetailView.as_view(), name='training_detail'),
path('training/<slug:slug>/update/', views_frontend.TrainingUpdateView.as_view(), name='training_update'),
path('training/<slug:slug>/delete/', views_frontend.TrainingDeleteView.as_view(), name='training_delete'),
# Meeting URLs
path('', views.ZoomMeetingListView.as_view(), name='list_meetings'),
path('create-meeting/', views.ZoomMeetingCreateView.as_view(), name='create_meeting'),
path('meeting-details/<int:pk>/', views.ZoomMeetingDetailsView.as_view(), name='meeting_details'),
path('update-meeting/<int:pk>/', views.ZoomMeetingUpdateView.as_view(), name='update_meeting'),
path('delete-meeting/<int:pk>/', views.ZoomMeetingDeleteView, name='delete_meeting'),
path('meeting-details/<slug:slug>/', views.ZoomMeetingDetailsView.as_view(), name='meeting_details'),
path('update-meeting/<slug:slug>/', views.ZoomMeetingUpdateView.as_view(), name='update_meeting'),
path('delete-meeting/<slug:slug>/', views.ZoomMeetingDeleteView, name='delete_meeting'),
# JobPosting functional views URLs (keeping for compatibility)
path('api/create/', views.create_job, name='create_job_api'),
path('api/<slug:slug>/edit/', views.edit_job, name='edit_job_api'),
#
path('form_builder/', views.form_builder, name='form_builder'),
# Form Preview URLs
path('forms/', views.form_list, name='form_list'),
path('forms/<int:form_id>/', views.form_preview, name='form_preview'),
path('forms/<int:form_id>/submit/', views.form_submit, name='form_submit'),
path('forms/<int:form_id>/embed/', views.form_embed, name='form_embed'),
path('forms/<int:form_id>/submissions/', views.form_submissions, name='form_submissions'),
path('forms/<int:form_id>/edit/', views.edit_form, name='edit_form'),
path('api/forms/save/', views.save_form_builder, name='save_form_builder'),
path('api/forms/<int:form_id>/load/', views.load_form, name='load_form'),
path('api/forms/<int:form_id>/update/', views.update_form_builder, name='update_form_builder'),
]

View File

@ -1,8 +1,9 @@
import os
import fitz # PyMuPDF
import spacy
import os
import requests
from recruitment import models
from django.conf import settings
nlp = spacy.load("en_core_web_sm")

15
recruitment/validators.py Normal file
View File

@ -0,0 +1,15 @@
from django.core.exceptions import ValidationError
def validate_image_size(image):
max_size_mb = 2
if image.size > max_size_mb * 1024 * 1024:
raise ValidationError(f"Image size should not exceed {max_size_mb}MB.")
def validate_hash_tags(value):
if value:
tags = [tag.strip() for tag in value.split(',')]
for tag in tags:
if ' ' in tag:
raise ValidationError("Hash tags should not contain spaces.")
if not tag.startswith('#'):
raise ValidationError("Each hash tag should start with '#' symbol.")

View File

@ -1,23 +1,32 @@
import json
import requests
from django.views import View
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from django.http import JsonResponse
from recruitment.models import FormSubmission,Form,UploadedFile
from datetime import datetime
from django.views import View
from django.db.models import Q
from django.urls import reverse
from django.utils import timezone
from .forms import ZoomMeetingForm
from .forms import ZoomMeetingForm,JobPostingForm
from rest_framework import viewsets
from django.contrib import messages
from .models import ZoomMeeting, Job, Candidate
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from django.http import HttpResponseRedirect, JsonResponse
from .serializers import JobSerializer, CandidateSerializer
from django.core.paginator import Paginator
from .linkedin_service import LinkedInService
from .models import ZoomMeeting, Job, Candidate, JobPosting
from .serializers import JobPostingSerializer, CandidateSerializer
from django.shortcuts import get_object_or_404, render, redirect
from django.views.generic import CreateView,UpdateView,DetailView,ListView
from .utils import create_zoom_meeting, delete_zoom_meeting, list_zoom_meetings, get_zoom_meeting_details, update_zoom_meeting
from .utils import create_zoom_meeting, delete_zoom_meeting, update_zoom_meeting
class JobViewSet(viewsets.ModelViewSet):
queryset = Job.objects.all()
serializer_class = JobSerializer
import logging
logger=logging.getLogger(__name__)
class JobPostingViewSet(viewsets.ModelViewSet):
queryset = JobPosting.objects.all()
serializer_class = JobPostingSerializer
class CandidateViewSet(viewsets.ModelViewSet):
queryset = Candidate.objects.all()
@ -61,6 +70,26 @@ class ZoomMeetingListView(ListView):
model = ZoomMeeting
template_name = 'meetings/list_meetings.html'
context_object_name = 'meetings'
paginate_by = 10
def get_queryset(self):
queryset = super().get_queryset().order_by('-start_time')
# Handle search
search_query = self.request.GET.get('search', '')
if search_query:
queryset = queryset.filter(
Q(topic__icontains=search_query) |
Q(meeting_id__icontains=search_query) |
Q(host_email__icontains=search_query)
)
return queryset
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['search_query'] = self.request.GET.get('search', '')
return context
class ZoomMeetingDetailsView(DetailView):
model = ZoomMeeting
@ -108,3 +137,441 @@ def ZoomMeetingDeleteView(request, pk):
except Exception as e:
messages.error(request, str(e))
return redirect('/')
#Job Posting
def job_list(request):
"""Display the list of job postings order by creation date descending"""
jobs=JobPosting.objects.all().order_by('-created_at')
# Filter by status if provided
status=request.GET.get('status')
if status:
jobs=jobs.filter(status=status)
#pagination
paginator=Paginator(jobs,10) # Show 10 jobs per page
page_number=request.GET.get('page')
page_obj=paginator.get_page(page_number)
return render(request, 'jobs/job_list.html', {
'page_obj': page_obj,
'status_filter': status
})
def create_job(request):
"""Create a new job posting"""
if request.method=='POST':
form=JobPostingForm(request.POST,is_anonymous_user=not request.user.is_authenticated)
#to check user is authenticated or not
if form.is_valid():
try:
job=form.save(commit=False)
if request.user.is_authenticated:
job.created_by=request.user.get_full_name() or request.user.username
else:
job.created_by=request.POST.get('created_by','').strip()
if not job.created_by:
job.created_by="University Administrator"
job.save()
messages.success(request,f'Job "{job.title}" created successfully!')
return redirect('job_list')
except Exception as e:
logger.error(f"Error creating job: {e}")
messages.error(request,f"Error creating job: {e}")
else:
messages.error(request, f'Please correct the errors below.{form.errors}')
else:
form=JobPostingForm(is_anonymous_user=not request.user.is_authenticated)
return render(request,'jobs/create_job.html',{'form':form})
def edit_job(request,slug):
"""Edit an existing job posting"""
if request.method=='POST':
job=get_object_or_404(JobPosting,slug=slug)
form=JobPostingForm(request.POST,instance=job,is_anonymous_user=not request.user.is_authenticated)
if form.is_valid():
try:
job=form.save(commit=False)
if request.user.is_authenticated:
job.created_by=request.user.get_full_name() or request.user.username
else:
job.created_by=request.POST.get('created_by','').strip()
if not job.created_by:
job.created_by="University Administrator"
job.save()
messages.success(request,f'Job "{job.title}" updated successfully!')
return redirect('job_list')
except Exception as e:
logger.error(f"Error updating job: {e}")
messages.error(request,f"Error updating job: {e}")
else:
messages.error(request, 'Please correct the errors below.')
else:
job=get_object_or_404(JobPosting,slug=slug)
form=JobPostingForm(instance=job,is_anonymous_user=not request.user.is_authenticated)
return render(request,'jobs/edit_job.html',{'form':form,'job':job})
def job_detail(request, slug):
"""View details of a specific job"""
job = get_object_or_404(JobPosting, slug=slug)
# Get all candidates for this job, ordered by most recent
candidates = job.candidates.all().order_by('-created_at')
# Count candidates by stage for summary statistics
total_candidates = candidates.count()
applied_count = candidates.filter(stage='Applied').count()
interview_count = candidates.filter(stage='Interview').count()
offer_count = candidates.filter(stage='Offer').count()
context = {
'job': job,
'candidates': candidates,
'total_candidates': total_candidates,
'applied_count': applied_count,
'interview_count': interview_count,
'offer_count': offer_count,
}
return render(request, 'jobs/job_detail.html', context)
\
def post_to_linkedin(request,slug):
"""Post a job to LinkedIn"""
job=get_object_or_404(JobPosting,slug=slug)
if job.status!='ACTIVE':
messages.info(request,'Only active jobs can be posted to LinkedIn.')
return redirect('job_list')
if request.method=='POST':
try:
# Check if user is authenticated with LinkedIn
if 'linkedin_access_token' not in request.session:
messages.error(request,'Please authenticate with LinkedIn first.')
return redirect('linkedin_login')
# Clear previous LinkedIn data for re-posting
job.posted_to_linkedin=False
job.linkedin_post_id=''
job.linkedin_post_url=''
job.linkedin_post_status=''
job.linkedin_posted_at=None
job.save()
# Initialize LinkedIn service
service=LinkedInService()
service.access_token=request.session['linkedin_access_token']
# Post to LinkedIn
result=service.create_job_post(job)
if result['success']:
# Update job with LinkedIn info
job.posted_to_linkedin=True
job.linkedin_post_id=result['post_id']
job.linkedin_post_url=result['post_url']
job.linkedin_post_status='SUCCESS'
job.linkedin_posted_at=timezone.now()
job.save()
messages.success(request,'Job posted to LinkedIn successfully!')
else:
error_msg=result.get('error','Unknown error')
job.linkedin_post_status=f'ERROR: {error_msg}'
job.save()
messages.error(request,f'Error posting to LinkedIn: {error_msg}')
except Exception as e:
logger.error(f"Error in post_to_linkedin: {e}")
job.linkedin_post_status = f'ERROR: {str(e)}'
job.save()
messages.error(request, f'Error posting to LinkedIn: {e}')
return redirect('job_detail', slug=job.slug)
def linkedin_login(request):
"""Redirect to LinkedIn OAuth"""
service=LinkedInService()
auth_url=service.get_auth_url()
"""
It creates a special URL that:
Sends the user to LinkedIn to log in
Asks the user to grant your app permission to post on their behalf
Tells LinkedIn where to send the user back after they approve (your redirect_uri)
http://yoursite.com/linkedin/callback/?code=TEMPORARY_CODE_HERE
"""
return redirect(auth_url)
def linkedin_callback(request):
"""Handle LinkedIn OAuth callback"""
code=request.GET.get('code')
if not code:
messages.error(request,'No authorization code received from LinkedIn.')
return redirect('job_list')
try:
service=LinkedInService()
#get_access_token(code)->It makes a POST request to LinkedIns token endpoint with parameters
access_token=service.get_access_token(code)
request.session['linkedin_access_token']=access_token
request.session['linkedin_authenticated']=True
messages.success(request,'Successfully authenticated with LinkedIn!')
except Exception as e:
logger.error(f"LinkedIn authentication error: {e}")
messages.error(request,f'LinkedIn authentication failed: {e}')
return redirect('job_list')
#applicant views
def applicant_job_detail(request,slug):
"""View job details for applicants"""
job=get_object_or_404(JobPosting,slug=slug,status='ACTIVE')
return render(request,'jobs/applicant_job_detail.html',{'job':job})
def form_builder(request):
return render(request,'form_builder.html')
# Form Preview Views
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.core.paginator import Paginator
from django.contrib.auth.decorators import login_required
import json
def form_list(request):
"""Display list of all available forms"""
forms = Form.objects.filter(is_active=True).order_by('-created_at')
# Pagination
paginator = Paginator(forms, 12)
page_number = request.GET.get('page')
page_obj = paginator.get_page(page_number)
return render(request, 'forms/form_list.html', {
'page_obj': page_obj
})
def form_preview(request, form_id):
"""Display form preview for end users"""
form = get_object_or_404(Form, id=form_id, is_active=True)
# Get submission count for analytics
submission_count = form.submissions.count()
return render(request, 'forms/form_preview.html', {
'form': form,
'submission_count': submission_count,
'is_embed': request.GET.get('embed', 'false') == 'true'
})
@csrf_exempt
def form_submit(request, form_id):
"""Handle form submission via AJAX"""
if request.method != 'POST':
return JsonResponse({'success': False, 'error': 'Only POST method allowed'}, status=405)
form = get_object_or_404(Form, id=form_id, is_active=True)
try:
# Parse form data
submission_data = {}
files = {}
# Process regular form fields
for key, value in request.POST.items():
if key != 'csrfmiddlewaretoken':
submission_data[key] = value
# Process file uploads
for key, file in request.FILES.items():
if file:
files[key] = file
# Create form submission
submission = FormSubmission.objects.create(
form=form,
submission_data=submission_data,
ip_address=request.META.get('REMOTE_ADDR'),
user_agent=request.META.get('HTTP_USER_AGENT', '')
)
# Handle file uploads
for field_id, file in files.items():
UploadedFile.objects.create(
submission=submission,
field_id=field_id,
file=file,
original_filename=file.name
)
# TODO: Send email notification if configured
return JsonResponse({
'success': True,
'message': 'Form submitted successfully!',
'submission_id': submission.id
})
except Exception as e:
logger.error(f"Error submitting form {form_id}: {e}")
return JsonResponse({
'success': False,
'error': 'An error occurred while submitting the form. Please try again.'
}, status=500)
def form_embed(request, form_id):
"""Display embeddable version of form"""
form = get_object_or_404(Form, id=form_id, is_active=True)
return render(request, 'forms/form_embed.html', {
'form': form,
'is_embed': True
})
@login_required
def save_form_builder(request):
"""Save form from builder to database"""
if request.method != 'POST':
return JsonResponse({'success': False, 'error': 'Only POST method allowed'}, status=405)
try:
data = json.loads(request.body)
form_data = data.get('form', {})
# Check if this is an update or create
form_id = data.get('form_id')
if form_id:
# Update existing form
form = Form.objects.get(id=form_id, created_by=request.user)
form.title = form_data.get('title', 'Untitled Form')
form.description = form_data.get('description', '')
form.structure = form_data
form.save()
else:
# Create new form
form = Form.objects.create(
title=form_data.get('title', 'Untitled Form'),
description=form_data.get('description', ''),
structure=form_data,
created_by=request.user
)
return JsonResponse({
'success': True,
'form_id': form.id,
'message': 'Form saved successfully!'
})
except json.JSONDecodeError:
return JsonResponse({
'success': False,
'error': 'Invalid JSON data'
}, status=400)
except Exception as e:
logger.error(f"Error saving form: {e}")
return JsonResponse({
'success': False,
'error': 'An error occurred while saving the form'
}, status=500)
@login_required
def load_form(request, form_id):
"""Load form data for editing in builder"""
try:
form = get_object_or_404(Form, id=form_id, created_by=request.user)
return JsonResponse({
'success': True,
'form': {
'id': form.id,
'title': form.title,
'description': form.description,
'structure': form.structure
}
})
except Exception as e:
logger.error(f"Error loading form {form_id}: {e}")
return JsonResponse({
'success': False,
'error': 'An error occurred while loading the form'
}, status=500)
@csrf_exempt
def update_form_builder(request, form_id):
"""Update existing form from builder"""
if request.method != 'POST':
return JsonResponse({'success': False, 'error': 'Only POST method allowed'}, status=405)
try:
form = get_object_or_404(Form, id=form_id)
# Check if user has permission to edit this form
if form.created_by != request.user:
return JsonResponse({
'success': False,
'error': 'You do not have permission to edit this form'
}, status=403)
data = json.loads(request.body)
form_data = data.get('form', {})
# Update form
form.title = form_data.get('title', 'Untitled Form')
form.description = form_data.get('description', '')
form.structure = form_data
form.save()
return JsonResponse({
'success': True,
'form_id': form.id,
'message': 'Form updated successfully!'
})
except json.JSONDecodeError:
return JsonResponse({
'success': False,
'error': 'Invalid JSON data'
}, status=400)
except Exception as e:
logger.error(f"Error updating form {form_id}: {e}")
return JsonResponse({
'success': False,
'error': 'An error occurred while updating the form'
}, status=500)
def edit_form(request, form_id):
"""Display form edit page"""
form = get_object_or_404(Form, id=form_id)
# Check if user has permission to edit this form
if form.created_by != request.user:
messages.error(request, 'You do not have permission to edit this form.')
return redirect('form_list')
return render(request, 'forms/edit_form.html', {
'form': form
})
def form_submissions(request, form_id):
"""View submissions for a specific form"""
form = get_object_or_404(Form, id=form_id, created_by=request.user)
submissions = form.submissions.all().order_by('-submitted_at')
# Pagination
paginator = Paginator(submissions, 20)
page_number = request.GET.get('page')
page_obj = paginator.get_page(page_number)
return render(request, 'forms/form_submissions.html', {
'form': form,
'page_obj': page_obj
})

View File

@ -1,20 +1,192 @@
from django.shortcuts import render, get_object_or_404
from django.http import JsonResponse
from . import models
from django.utils.translation import get_language
from . import forms
from django.contrib.auth.decorators import login_required, user_passes_test
from django.contrib.auth.decorators import login_required
import ast
from .dashboard import get_dashboard_data
from django.template.loader import render_to_string
# from .dashboard import get_dashboard_data
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.messages.views import SuccessMessageMixin
from django.views.generic import ListView, CreateView, UpdateView, DeleteView, DetailView
# JobForm removed - using JobPostingForm instead
from django.urls import reverse_lazy
from django.db.models import Q
def job_list(request):
jobs = models.Job.objects.filter(is_published=True).order_by('-created_at')
lang = get_language()
return render(request, 'recruitment/job_list.html', {'jobs': jobs, 'lang': lang})
from datastar_py.django import (
DatastarResponse,
ServerSentEventGenerator as SSE,
read_signals,
)
def job_detail(request, job_id):
job = get_object_or_404(models.Job, id=job_id, is_published=True)
class JobListView(LoginRequiredMixin, ListView):
model = models.JobPosting
template_name = 'jobs/job_list.html'
context_object_name = 'jobs'
paginate_by = 10
def get_queryset(self):
queryset = super().get_queryset().order_by('-created_at')
# Handle search
search_query = self.request.GET.get('search', '')
if search_query:
queryset = queryset.filter(
Q(title__icontains=search_query) |
Q(description__icontains=search_query) |
Q(department__icontains=search_query)
)
# Filter for non-staff users
if not self.request.user.is_staff:
queryset = queryset.filter(status='Published')
return queryset
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['search_query'] = self.request.GET.get('search', '')
context['lang'] = get_language()
return context
class JobCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView):
model = models.JobPosting
form_class = forms.JobPostingForm
template_name = 'jobs/create_job.html'
success_url = reverse_lazy('job_list')
success_message = 'Job created successfully.'
class JobUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView):
model = models.JobPosting
form_class = forms.JobPostingForm
template_name = 'jobs/edit_job.html'
success_url = reverse_lazy('job_list')
success_message = 'Job updated successfully.'
slug_url_kwarg = 'slug'
class JobDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView):
model = models.JobPosting
template_name = 'jobs/partials/delete_modal.html'
success_url = reverse_lazy('job_list')
success_message = 'Job deleted successfully.'
slug_url_kwarg = 'slug'
class JobCandidatesListView(LoginRequiredMixin, ListView):
model = models.Candidate
template_name = 'jobs/job_candidates_list.html'
context_object_name = 'candidates'
paginate_by = 10
def get_queryset(self):
# Get the job by slug
self.job = get_object_or_404(models.JobPosting, slug=self.kwargs['slug'])
# Filter candidates for this specific job
queryset = models.Candidate.objects.filter(job=self.job)
# Handle search
search_query = self.request.GET.get('search', '')
if search_query:
queryset = queryset.filter(
Q(first_name__icontains=search_query) |
Q(last_name__icontains=search_query) |
Q(email__icontains=search_query) |
Q(phone__icontains=search_query) |
Q(stage__icontains=search_query)
)
# Filter for non-staff users
if not self.request.user.is_staff:
return models.Candidate.objects.none() # Restrict for non-staff
return queryset.order_by('-created_at')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['search_query'] = self.request.GET.get('search', '')
context['job'] = getattr(self, 'job', None)
return context
class CandidateListView(LoginRequiredMixin, ListView):
model = models.Candidate
template_name = 'recruitment/candidate_list.html'
context_object_name = 'candidates'
paginate_by = 10
def get_queryset(self):
queryset = super().get_queryset()
# Handle search
search_query = self.request.GET.get('search', '')
if search_query:
queryset = queryset.filter(
Q(first_name__icontains=search_query) |
Q(last_name__icontains=search_query) |
Q(email__icontains=search_query) |
Q(phone__icontains=search_query) |
Q(stage__icontains=search_query) |
Q(job__title__icontains=search_query)
)
# Filter for non-staff users
if not self.request.user.is_staff:
return models.Candidate.objects.none() # Restrict for non-staff
return queryset.order_by('-created_at')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['search_query'] = self.request.GET.get('search', '')
return context
class CandidateCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView):
model = models.Candidate
form_class = forms.CandidateForm
template_name = 'recruitment/candidate_create.html'
success_url = reverse_lazy('candidate_list')
success_message = 'Candidate created successfully.'
def get_initial(self):
initial = super().get_initial()
if 'slug' in self.kwargs:
job = get_object_or_404(models.JobPosting, slug=self.kwargs['slug'])
initial['job'] = job
return initial
def form_valid(self, form):
if 'slug' in self.kwargs:
job = get_object_or_404(models.JobPosting, slug=self.kwargs['slug'])
form.instance.job = job
return super().form_valid(form)
class CandidateUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView):
model = models.Candidate
form_class = forms.CandidateForm
template_name = 'recruitment/candidate_update.html'
success_url = reverse_lazy('candidate_list')
success_message = 'Candidate updated successfully.'
slug_url_kwarg = 'slug'
class CandidateDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView):
model = models.Candidate
template_name = 'recruitment/candidate_delete.html'
success_url = reverse_lazy('candidate_list')
success_message = 'Candidate deleted successfully.'
slug_url_kwarg = 'slug'
def job_detail(request, slug):
job = get_object_or_404(models.JobPosting, slug=slug, status='Published')
form = forms.CandidateForm()
return render(request, 'recruitment/job_detail.html', {'job': job, 'form': form})
return render(request, 'jobs/job_detail.html', {'job': job, 'form': form})
@login_required
@ -23,22 +195,163 @@ def training_list(request):
return render(request, 'recruitment/training_list.html', {'materials': materials})
def candidate_detail(request, candidate_id):
candidate = get_object_or_404(models.Candidate, id=candidate_id)
def candidate_detail(request, slug):
candidate = get_object_or_404(models.Candidate, slug=slug)
try:
parsed = ast.literal_eval(candidate.parsed_summary)
except:
parsed = {}
# Create stage update form for staff users
stage_form = None
if request.user.is_staff:
stage_form = forms.CandidateStageForm(candidate=candidate)
return render(request, 'recruitment/candidate_detail.html', {
'candidate': candidate,
'parsed': parsed,
'stage_form': stage_form,
})
def candidate_update_stage(request, slug):
"""Handle HTMX stage update requests"""
from time import sleep
sleep(5)
try:
if not request.user.is_staff:
return render(request, 'recruitment/partials/error.html', {'error': 'Permission denied'}, status=403)
candidate = get_object_or_404(models.Candidate, slug=slug)
if request.method != 'POST':
return render(request, 'recruitment/partials/error.html', {'error': 'Only POST method is allowed'}, status=405)
# Handle form data
form = forms.CandidateStageForm(request.POST, candidate=candidate)
if form.is_valid():
stage_value = form.cleaned_data['stage']
# Validate stage value
valid_stages = [choice[0] for choice in models.Candidate.Stage.choices]
if stage_value not in valid_stages:
return render(request, 'recruitment/partials/error.html', {'error': f'Invalid stage value. Must be one of: {", ".join(valid_stages)}'}, status=400)
# Check transition rules
if candidate.pk and stage_value != candidate.stage:
old_stage = candidate.stage
if not candidate.can_transition_to(stage_value):
return render(request, 'recruitment/partials/error.html', {'error': f'Cannot transition from "{old_stage}" to "{stage_value}". Transition not allowed.'}, status=400)
# Update the stage
old_stage = candidate.stage
candidate.stage = stage_value
candidate.save()
# Return success template
context = {
'form': form,
'success': True,
'message': f'Stage updated from "{old_stage}" to "{candidate.stage}"',
'new_stage': candidate.stage,
'new_stage_display': candidate.get_stage_display(),
'candidate': candidate
}
def response():
stage_form = forms.CandidateStageForm(candidate=candidate)
context['stage_form'] = stage_form
stage_form_partial = render_to_string('recruitment/partials/stage_update_modal.html#id-stage', context)
success_html = render_to_string('recruitment/partials/stage_update_success.html', context)
yield SSE.patch_elements(stage_form_partial,"#id_stage")
yield SSE.patch_elements(success_html,"#availableStagesInfo")
yield SSE.patch_signals({'stage':candidate.stage})
return DatastarResponse(response())
# return render(request, 'recruitment/partials/stage_update_success.html', context)
else:
# Return form with errors
context = {
'form': form,
'candidate': candidate,
'stage_form': forms.CandidateStageForm(candidate=candidate)
}
return render(request, 'recruitment/partials/stage_update_form.html', context)
except Exception as e:
# Log the error for debugging
import traceback
error_details = traceback.format_exc()
print(f"Error in candidate_update_stage: {error_details}")
return render(request, 'partials/error.html', {'error': f'Internal server error: {str(e)}'}, status=500)
class TrainingListView(LoginRequiredMixin, ListView):
model = models.TrainingMaterial
template_name = 'recruitment/training_list.html'
context_object_name = 'materials'
paginate_by = 10
def get_queryset(self):
queryset = super().get_queryset()
# Handle search
search_query = self.request.GET.get('search', '')
if search_query:
queryset = queryset.filter(
Q(title__icontains=search_query) |
Q(description__icontains=search_query)
)
# Filter for non-staff users
if not self.request.user.is_staff:
return models.TrainingMaterial.objects.none() # Restrict for non-staff
return queryset.filter(created_by=self.request.user).order_by('-created_at')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['search_query'] = self.request.GET.get('search', '')
return context
class TrainingCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView):
model = models.TrainingMaterial
form_class = forms.TrainingMaterialForm
template_name = 'recruitment/training_create.html'
success_url = reverse_lazy('training_list')
success_message = 'Training material created successfully.'
def form_valid(self, form):
form.instance.created_by = self.request.user
return super().form_valid(form)
class TrainingUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView):
model = models.TrainingMaterial
form_class = forms.TrainingMaterialForm
template_name = 'recruitment/training_update.html'
success_url = reverse_lazy('training_list')
success_message = 'Training material updated successfully.'
slug_url_kwarg = 'slug'
class TrainingDetailView(LoginRequiredMixin, DetailView):
model = models.TrainingMaterial
template_name = 'recruitment/training_detail.html'
context_object_name = 'material'
slug_url_kwarg = 'slug'
class TrainingDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView):
model = models.TrainingMaterial
template_name = 'recruitment/training_delete.html'
success_url = reverse_lazy('training_list')
success_message = 'Training material deleted successfully.'
def dashboard_view(request):
total_jobs = models.Job.objects.count()
total_jobs = models.JobPosting.objects.count()
total_candidates = models.Candidate.objects.count()
jobs = models.Job.objects.all()
jobs = models.JobPosting.objects.all()
job_titles = [job.title for job in jobs]
job_app_counts = [job.candidates.count() for job in jobs]
@ -51,4 +364,4 @@ def dashboard_view(request):
'job_app_counts': job_app_counts,
'average_applications': average_applications,
}
return render(request, 'recruitment/dashboard.html', context)
return render(request, 'recruitment/dashboard.html', context)

947
static/css/style.css Normal file
View File

@ -0,0 +1,947 @@
/* Custom CSS for NorahUniversity ATS */
/* Keep only essential custom styles that Bootstrap doesn't handle */
/* Primary Brand Color */
:root {
--primary-color: #1b8354;
--primary-hover: #155f3e;
}
/* Header and Navigation */
.header {
background-color: white !important;
box-shadow: 0 2px 4px rgba(0,0,0,0.1) !important;
border-bottom: 1px solid #e0e0e0 !important;
}
.navbar {
background-color: white !important;
border-bottom: 1px solid #e0e0e0 !important;
}
.nav-link.active {
background-color: var(--primary-color) !important;
color: white !important;
}
.nav-link:hover {
background-color: var(--primary-color) !important;
color: white !important;
}
.logo {
font-size: 1.5rem;
font-weight: 700;
color: var(--primary-color) !important;
}
/* Buttons - Override Bootstrap primary color */
.btn-primary {
background-color: var(--primary-color) !important;
border-color: var(--primary-color) !important;
}
.btn-primary:hover {
background-color: var(--primary-hover) !important;
border-color: var(--primary-hover) !important;
}
.btn-outline-primary {
border-color: var(--primary-color) !important;
color: var(--primary-color) !important;
}
.btn-outline-primary:hover {
background-color: var(--primary-color) !important;
border-color: var(--primary-color) !important;
color: white !important;
}
/* Cards */
.card {
border: 1px solid #e0e0e0 !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05) !important;
}
.card-header {
border-bottom: 1px solid #e0e0e0 !important;
background-color: white !important;
}
/* Table Improvements */
.table-hover tbody tr:hover {
background-color: rgba(27, 131, 84, 0.05) !important;
}
/* Custom Badge Colors */
.badge.bg-success {
background-color: #28a745 !important;
}
.badge.bg-warning {
background-color: #ffc107 !important;
}
/* Form Improvements */
.form-control:focus {
border-color: var(--primary-color) !important;
box-shadow: 0 0 0 0.2rem rgba(27, 131, 84, 0.25) !important;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.nav-list {
flex-direction: column;
}
.nav-item {
margin: 0;
}
}
/* Utility classes */
.text-primary-custom {
color: var(--primary-color) !important;
}
.bg-primary-custom {
background-color: var(--primary-color) !important;
}
.border-primary-custom {
border-color: var(--primary-color) !important;
}
/* Loading states */
.loading {
opacity: 0.6;
pointer-events: none;
}
/* Icon Styling */
.heroicon {
width: 1rem;
height: 1rem;
margin-right: 0.5rem;
vertical-align: middle;
flex-shrink: 0;
}
.size-6 {
width: 1.5rem !important;
height: 1.5rem !important;
}
/* Responsive icon sizing */
.icon-sm {
width: 0.875rem !important;
height: 0.875rem !important;
margin-right: 0.375rem !important;
}
.icon-md {
width: 1.125rem !important;
height: 1.125rem !important;
margin-right: 0.625rem !important;
}
.icon-lg {
width: 1.5rem !important;
height: 1.5rem !important;
margin-right: 0.75rem !important;
}
.icon-xl {
width: 2rem !important;
height: 2rem !important;
margin-right: 1rem !important;
}
/* Context-specific icon adjustments */
.btn-sm .heroicon,
.btn-sm .size-6,
.btn-sm .icon-md {
width: 0.875rem !important;
height: 0.875rem !important;
margin-right: 0.375rem !important;
}
.nav-link .heroicon,
.nav-link .size-6 {
width: 1.25rem !important;
height: 1.25rem !important;
margin-right: 0.5rem !important;
}
.card-header .heroicon,
.card-header .size-6 {
width: 1.375rem !important;
height: 1.375rem !important;
margin-right: 0.625rem !important;
}
/* Print styles */
@media print {
.navbar,
.header,
.btn,
.pagination {
display: none !important;
}
.card {
box-shadow: none !important;
border: 1px solid #ccc !important;
}
}
/* Responsive adjustments for icons */
@media (max-width: 768px) {
.nav-link .heroicon,
.nav-link .size-6 {
width: 1rem !important;
height: 1rem !important;
margin-right: 0.375rem !important;
}
.card-header .heroicon,
.card-header .size-6 {
width: 1.125rem !important;
height: 1.125rem !important;
margin-right: 0.5rem !important;
}
}
/* Header and Search Enhancements */
.card-header {
background-color: white !important;
border-bottom: 1px solid #e0e0e0 !important;
padding: 1.25rem 1.5rem !important;
}
.card-header h1,
.card-header h2,
.card-header h3 {
margin-bottom: 0 !important;
font-weight: 600 !important;
}
.card-header h1.h3,
.card-header h2.h3,
.card-header h3.h3 {
font-size: 1.25rem !important;
}
/* Search Form Enhancements */
.search-form-container {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.input-group {
flex: 1;
min-width: 250px;
}
.input-group-text {
background-color: #f8f9fa !important;
border: 1px solid #ced4da !important;
/* border-right: none !important; */
color: #495057 !important;
transition: all 0.2s ease !important;
}
.input-group-text:hover {
background-color: #e9ecef !important;
}
.input-group-text .heroicon {
width: 1rem !important;
height: 1rem !important;
margin-right: 0 !important;
}
.form-control:focus {
border-color: var(--primary-color) !important;
box-shadow: 0 0 0 0.2rem rgba(27, 131, 84, 0.15) !important;
}
.form-control:focus + .input-group-text {
border-color: var(--primary-color) !important;
background-color: rgba(27, 131, 84, 0.05) !important;
}
/* Button Group Enhancements */
.d-flex.gap-2 .btn {
white-space: nowrap !important;
transition: all 0.2s ease !important;
}
.d-flex.gap-2 .btn:hover {
transform: translateY(-1px) !important;
}
.d-flex.gap-2 .btn svg {
margin-right: 0.375rem !important;
}
/* Responsive Header Adjustments */
@media (max-width: 768px) {
.card-header {
padding: 1rem 1.25rem !important;
}
.card-header h1.h3,
.card-header h2.h3,
.card-header h3.h3 {
font-size: 1.125rem !important;
}
.input-group {
min-width: 200px !important;
max-width: 100% !important;
}
.d-flex.gap-2 {
flex-wrap: wrap !important;
gap: 0.5rem !important;
}
.d-flex.gap-2 .btn {
flex: 1 !important;
min-width: 120px !important;
}
}
@media (max-width: 576px) {
.card-header {
padding: 0.875rem 1rem !important;
}
.input-group {
min-width: 100% !important;
}
.d-flex.gap-2 .btn {
font-size: 0.875rem !important;
padding: 0.375rem 0.75rem !important;
}
}
/* Search Input Placeholder */
.form-control::placeholder {
color: #6c757d !important;
opacity: 0.7 !important;
}
/* Enhanced Focus States */
.form-control:focus::placeholder {
opacity: 1 !important;
}
/* Detail Page Enhancements */
.detail-page-header {
background: linear-gradient(135deg, var(--primary-color) 0%, rgba(27, 131, 84, 0.1) 100%);
border-bottom: 3px solid var(--primary-color) !important;
}
.detail-page-header h1 {
color: var(--primary-color) !important;
}
/* Information Cards Enhancement */
.info-card {
background: #f8f9fa;
border-left: 4px solid var(--primary-color);
border-radius: 0.375rem;
padding: 1rem;
margin-bottom: 1rem;
transition: all 0.3s ease;
}
.info-card:hover {
background: #e9ecef;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.info-card .info-label {
font-size: 0.875rem;
color: #6c757d;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
margin-bottom: 0.25rem;
}
.info-card .info-value {
font-size: 1rem;
color: #212529;
font-weight: 500;
}
/* Action Cards Enhancement */
.action-card {
text-align: center;
padding: 2rem;
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
border-radius: 0.75rem;
transition: all 0.3s ease;
}
.action-card:hover {
transform: translateY(-3px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.1);
}
/* Resume File Display */
.resume-file {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 0.5rem;
padding: 1rem;
transition: all 0.2s ease;
}
.resume-file:hover {
background: #e9ecef;
border-color: var(--primary-color);
}
.resume-file .file-name {
font-weight: 600;
color: #495057;
margin-bottom: 0.25rem;
}
.resume-file .file-info {
font-size: 0.875rem;
color: #6c757d;
}
/* Parsed Data Grid Enhancement */
.parsed-data-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1rem;
margin-top: 1rem;
}
.parsed-data-item {
background: white;
border: 1px solid #e0e0e0;
border-radius: 0.5rem;
padding: 1rem;
transition: all 0.2s ease;
}
.parsed-data-item:hover {
border-color: var(--primary-color);
box-shadow: 0 2px 8px rgba(27, 131, 84, 0.1);
}
.parsed-data-item .data-key {
font-size: 0.75rem;
text-transform: uppercase;
color: var(--primary-color);
font-weight: 600;
letter-spacing: 0.5px;
margin-bottom: 0.5rem;
}
.parsed-data-item .data-value {
font-size: 0.875rem;
color: #495057;
line-height: 1.4;
}
/* Status Badge Enhancement */
.status-badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border-radius: 2rem;
font-size: 0.875rem;
font-weight: 600;
transition: all 0.2s ease;
}
.status-badge .heroicon {
width: 1rem !important;
height: 1rem !important;
}
/* Contact Information Enhancement */
.contact-info-item {
padding: 0.75rem 1rem;
margin-bottom: 0.5rem;
background: white;
border-radius: 0.5rem;
border: 1px solid #e0e0e0;
transition: all 0.2s ease;
}
.contact-info-item:hover {
background: #f8f9fa;
border-color: var(--primary-color);
box-shadow: 0 2px 8px rgba(27, 131, 84, 0.1);
}
.contact-info-item .contact-label {
font-size: 0.75rem;
text-transform: uppercase;
color: #6c757d;
font-weight: 600;
letter-spacing: 0.5px;
}
.contact-info-item .contact-value {
font-size: 1rem;
color: #212529;
font-weight: 500;
margin-top: 0.125rem;
}
/* Responsive Detail Page Adjustments */
@media (max-width: 768px) {
.detail-page-header {
background: linear-gradient(135deg, var(--primary-color) 0%, rgba(27, 131, 84, 0.05) 100%);
border-bottom: 2px solid var(--primary-color);
}
.detail-page-header h1 {
font-size: 1.5rem !important;
}
.info-card {
padding: 0.75rem;
margin-bottom: 0.75rem;
}
.parsed-data-grid {
grid-template-columns: 1fr;
}
.contact-info-item {
padding: 0.5rem 0.75rem;
}
.action-card {
padding: 1.5rem;
}
}
@media (max-width: 576px) {
.detail-page-header h1 {
font-size: 1.25rem !important;
}
.info-card .info-label {
font-size: 0.75rem;
}
.info-card .info-value {
font-size: 0.875rem;
}
.status-badge {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
}
}
/* Loading Animation for Detail Pages */
.detail-loading {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: white;
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Print Styles for Detail Pages */
@media print {
.detail-page-header {
background: white !important;
border: 2px solid #dee2e6 !important;
}
.detail-page-header h1 {
color: #212529 !important;
}
.contact-info-item,
.info-card,
.parsed-data-item {
border: 1px solid #dee2e6 !important;
break-inside: avoid;
}
.btn,
.action-card {
display: none !important;
}
}
/* Form and Update Page Enhancements */
.form-page-header {
background: linear-gradient(135deg, var(--primary-color) 0%, rgba(27, 131, 84, 0.1) 100%);
border-bottom: 3px solid var(--primary-color) !important;
}
.form-page-header h1 {
color: var(--primary-color) !important;
}
.form-page-header p {
color: rgba(27, 131, 84, 0.8) !important;
}
/* Form Section Enhancement */
.form-section {
background: #f8f9fa;
border-left: 4px solid var(--primary-color);
border-radius: 0.375rem;
padding: 1.5rem;
margin-bottom: 2rem;
transition: all 0.3s ease;
}
.form-section:hover {
background: #e9ecef;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.form-section h5 {
color: var(--primary-color);
font-weight: 600;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.form-section .section-icon {
width: 1.25rem !important;
height: 1.25rem !important;
}
/* Form Field Enhancement */
.form-field-wrapper {
position: relative;
margin-bottom: 1.5rem;
}
.form-field-wrapper label {
font-weight: 600;
color: #495057;
margin-bottom: 0.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.form-field-wrapper .required-indicator {
color: #dc3545;
font-size: 0.875rem;
font-weight: bold;
}
.form-field-wrapper .field-icon {
position: absolute;
right: 1rem;
top: 2.5rem;
color: #6c757d;
pointer-events: none;
}
.form-control,
.form-select {
border: 2px solid #e0e0e0;
border-radius: 0.5rem;
padding: 0.75rem 1rem;
font-size: 1rem;
transition: all 0.2s ease;
}
.form-control:focus,
.form-select:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 0.2rem rgba(27, 131, 84, 0.15);
color: #212529;
background-color: #fff;
}
.form-control.is-invalid,
.form-select.is-invalid {
border-color: #dc3545;
padding-right: 2.5rem;
background-image: none;
}
.form-control.is-valid,
.form-select.is-valid {
border-color: #28a745;
padding-right: 2.5rem;
background-image: none;
}
.form-text {
font-size: 0.875rem;
color: #6c757d;
margin-top: 0.25rem;
}
.invalid-feedback,
.valid-feedback {
font-size: 0.875rem;
margin-top: 0.25rem;
}
.invalid-feedback {
color: #dc3545;
display: block;
}
.valid-feedback {
color: #28a745;
display: block;
}
/* Form Enhancement for Special Fields */
.form-check-input {
width: 1.25rem;
height: 1.25rem;
margin-top: 0.25rem;
}
.form-check-input:checked {
background-color: var(--primary-color);
border-color: var(--primary-color);
}
.form-check-label {
font-weight: 500;
color: #495057;
}
/* Action Buttons Enhancement */
.form-action-buttons {
background: white;
border: 1px solid #e0e0e0;
border-radius: 0.75rem;
padding: 1.5rem;
margin-top: 2rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.form-action-buttons .btn {
min-width: 120px;
font-weight: 500;
transition: all 0.2s ease;
}
.form-action-buttons .btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
/* Responsive Form Adjustments */
@media (max-width: 768px) {
.form-section {
padding: 1rem;
margin-bottom: 1.5rem;
}
.form-section h5 {
font-size: 1.1rem;
margin-bottom: 0.75rem;
}
.form-control,
.form-select {
font-size: 0.875rem;
padding: 0.625rem 0.75rem;
}
.form-field-wrapper label {
font-size: 0.875rem;
}
.form-text,
.invalid-feedback,
.valid-feedback {
font-size: 0.75rem;
}
.form-action-buttons {
padding: 1rem;
margin-top: 1.5rem;
}
.form-action-buttons .btn {
min-width: 100px;
font-size: 0.875rem;
padding: 0.5rem 1rem;
}
}
@media (max-width: 576px) {
.form-section {
padding: 0.75rem;
margin-bottom: 1rem;
}
.form-section h5 {
font-size: 1rem;
}
.form-control,
.form-select {
font-size: 0.813rem;
padding: 0.5rem 0.625rem;
}
.form-field-wrapper {
margin-bottom: 1rem;
}
.form-action-buttons {
flex-direction: column !important;
gap: 0.5rem !important;
}
.form-action-buttons .btn {
min-width: auto;
width: 100%;
}
}
/* Loading State for Forms */
.form-loading {
position: relative;
pointer-events: none;
opacity: 0.6;
}
.form-loading::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.8);
z-index: 1;
}
.form-loading .spinner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 2;
}
/* Print Styles for Forms */
@media print {
.form-page-header,
.form-section,
.form-action-buttons {
border: 1px solid #dee2e6 !important;
background: white !important;
box-shadow: none !important;
}
.form-section {
break-inside: avoid;
page-break-inside: avoid;
}
.btn {
display: none !important;
}
.form-control,
.form-select {
border: 1px solid #000 !important;
background: white !important;
}
}
/* File Upload Enhancement */
.form-control[type="file"] {
padding: 0.5rem;
border: 2px dashed #dee2e6;
background: #f8f9fa;
transition: all 0.2s ease;
}
.form-control[type="file"]:hover {
border-color: var(--primary-color);
background: #f0f8f4;
}
.form-control[type="file"]:focus {
border-color: var(--primary-color);
background: white;
box-shadow: 0 0 0 0.2rem rgba(27, 131, 84, 0.15);
}
/* Checkbox and Radio Enhancement */
.form-check-input:checked ~ .form-check-label::before {
background-color: var(--primary-color);
border-color: var(--primary-color);
}
.form-check-input:focus ~ .form-check-label::before {
box-shadow: 0 0 0 0.2rem rgba(27, 131, 84, 0.25);
}
/* Help Text Enhancement */
.help-text {
font-size: 0.813rem;
color: #6c757d;
margin-top: 0.25rem;
display: flex;
align-items: center;
gap: 0.25rem;
}
.help-text .help-icon {
width: 1rem !important;
height: 1rem !important;
color: #6c757d;
}
/* Error State Enhancement */
.field-error {
border-color: #dc3545 !important;
background-color: #fff5f5 !important;
}
.field-error:focus {
box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.25) !important;
}
/* Success State Enhancement */
.field-success {
border-color: #28a745 !important;
background-color: #f8fff9 !important;
}
.field-success:focus {
box-shadow: 0 0 0 0.2rem rgba(40, 167, 69, 0.25) !important;
}

126
static/js/modal_handlers.js Normal file
View File

@ -0,0 +1,126 @@
// Modal Handlers for Bootstrap
document.addEventListener('DOMContentLoaded', function() {
// Handle delete button clicks
const deleteButtons = document.querySelectorAll('[data-bs-toggle="deleteModal"]');
deleteButtons.forEach(button => {
button.addEventListener('click', function(e) {
e.preventDefault();
const targetModal = this.getAttribute('data-bs-target');
const modal = new bootstrap.Modal(document.getElementById('deleteModal'));
// Set up the delete form and message
const deleteUrl = this.getAttribute('data-delete-url');
const itemName = this.getAttribute('data-item-name') || 'this item';
const deleteForm = document.getElementById('deleteForm');
deleteForm.setAttribute('action', deleteUrl);
const modalMessage = document.getElementById('deleteModalMessage');
modalMessage.textContent = `Are you sure you want to delete "${itemName}"? This action cannot be undone.`;
modal.show();
});
});
// Handle update button clicks for inline editing
const updateButtons = document.querySelectorAll('[data-bs-toggle="updateModal"]');
updateButtons.forEach(button => {
button.addEventListener('click', function(e) {
e.preventDefault();
const targetModal = this.getAttribute('data-bs-target');
const modal = new bootstrap.Modal(document.getElementById('updateModal'));
// Load the form content via AJAX
const updateUrl = this.getAttribute('data-update-url');
fetch(updateUrl)
.then(response => response.text())
.then(html => {
const modalBody = document.querySelector('#updateModal .modal-body');
modalBody.innerHTML = html;
// Re-initialize any form validation or JavaScript within the modal
initializeModalForms();
modal.show();
})
.catch(error => {
console.error('Error loading form:', error);
alert('Error loading the form. Please try again.');
});
});
});
});
// Initialize forms within modals
function initializeModalForms() {
// Handle form submissions within modals
const modalForms = document.querySelectorAll('#updateModal form');
modalForms.forEach(form => {
form.addEventListener('submit', function(e) {
e.preventDefault();
const formData = new FormData(this);
const submitUrl = this.getAttribute('action');
fetch(submitUrl, {
method: 'POST',
body: formData,
headers: {
'X-CSRFToken': formData.get('csrfmiddlewaretoken')
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Close the modal and refresh the page or update the content
bootstrap.Modal.getInstance(document.getElementById('updateModal')).hide();
// Reload the page or update the specific element
if (data.reload) {
window.location.reload();
} else if (data.update_element) {
// Update specific element if needed
console.log('Element updated:', data.update_element);
}
} else {
// Show errors in the form
displayFormErrors(form, data.errors);
}
})
.catch(error => {
console.error('Error submitting form:', error);
alert('Error submitting the form. Please try again.');
});
});
});
}
// Display form errors
function displayFormErrors(form, errors) {
// Remove existing error messages
const existingErrors = form.querySelectorAll('.alert-danger');
existingErrors.forEach(error => error.remove());
// Add error messages
if (errors && Object.keys(errors).length > 0) {
const errorDiv = document.createElement('div');
errorDiv.className = 'alert alert-danger';
errorDiv.innerHTML = '<strong>Please correct the following errors:</strong><ul>';
for (const [field, messages] of Object.entries(errors)) {
messages.forEach(message => {
errorDiv.innerHTML += `<li>${field}: ${message}</li>`;
});
}
errorDiv.innerHTML += '</ul>';
form.insertBefore(errorDiv, form.firstChild);
}
}

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,3 @@
vin,
5FNRL38739B001353,
5FNRL38739B001354
1 vin,
2 5FNRL38739B001353,
3 5FNRL38739B001354

View File

@ -0,0 +1,3 @@
vin,
1HGCE1899RA009923,
1HGCE1899RA009924,
1 vin
2 1HGCE1899RA009923
3 1HGCE1899RA009924

View File

@ -0,0 +1,3 @@
vin,
1HGCE1899RA009923,
1HGCE1899RA009924,
1 vin
2 1HGCE1899RA009923
3 1HGCE1899RA009924

141
templates/base.html Normal file
View 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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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

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

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

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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

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

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

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

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

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

View 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">&laquo;</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">&raquo;</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 %}

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

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

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

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

View File

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

View File

@ -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">&laquo;</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">&raquo;</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 %}

View File

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

View File

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

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

View 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

View File

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

View 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">&laquo;</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">&raquo;</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 %}

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

View File

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

View File

@ -1,10 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
</body>
</html>

View File

@ -1,10 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More