frontend #32

Merged
ismail merged 7 commits from frontend into main 2025-11-18 13:21:45 +03:00
102 changed files with 28163 additions and 7053 deletions
Showing only changes of commit 0213bd6e11 - Show all commits

3
.env Normal file
View File

@ -0,0 +1,3 @@
DB_NAME=norahuniversity
DB_USER=norahuniversity
DB_PASSWORD=norahuniversity

View File

@ -9,10 +9,13 @@ https://docs.djangoproject.com/en/5.2/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.2/ref/settings/
"""
import os
from pathlib import Path
from django.templatetags.static import static
from dotenv import load_dotenv
load_dotenv()
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
@ -20,7 +23,7 @@ BASE_DIR = Path(__file__).resolve().parent.parent
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-_!ew&)1&r--3h17knd27^x8(xu(&-f4q3%x543lv5vx2!784s*'
SECRET_KEY = "django-insecure-_!ew&)1&r--3h17knd27^x8(xu(&-f4q3%x543lv5vx2!784s*"
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
@ -30,104 +33,102 @@ ALLOWED_HOSTS = ["*"]
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.humanize',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'rest_framework',
'recruitment.apps.RecruitmentConfig',
'corsheaders',
'django.contrib.sites',
'allauth',
'allauth.account',
'allauth.socialaccount',
'allauth.socialaccount.providers.linkedin_oauth2',
'channels',
'django_filters',
'crispy_forms',
"django.contrib.admin",
"django.contrib.humanize",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"rest_framework",
"recruitment.apps.RecruitmentConfig",
"corsheaders",
"django.contrib.sites",
"allauth",
"allauth.account",
"allauth.socialaccount",
"allauth.socialaccount.providers.linkedin_oauth2",
"channels",
"django_filters",
"crispy_forms",
# 'django_summernote',
# 'ckeditor',
'django_ckeditor_5',
'crispy_bootstrap5',
'django_extensions',
'template_partials',
'django_countries',
'django_celery_results',
'django_q',
'widget_tweaks',
'easyaudit'
"django_ckeditor_5",
"crispy_bootstrap5",
"django_extensions",
"template_partials",
"django_countries",
"django_celery_results",
"django_q",
"widget_tweaks",
"easyaudit",
]
SITE_ID = 1
LOGIN_REDIRECT_URL = '/'
ACCOUNT_LOGOUT_REDIRECT_URL = '/'
ACCOUNT_LOGOUT_REDIRECT_URL = "/"
ACCOUNT_SIGNUP_REDIRECT_URL = '/'
ACCOUNT_SIGNUP_REDIRECT_URL = "/"
LOGIN_URL = '/accounts/login/'
LOGIN_URL = "/accounts/login/"
AUTHENTICATION_BACKENDS = [
'django.contrib.auth.backends.ModelBackend',
'allauth.account.auth_backends.AuthenticationBackend',
"django.contrib.auth.backends.ModelBackend",
"allauth.account.auth_backends.AuthenticationBackend",
]
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware',
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.locale.LocaleMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'allauth.account.middleware.AccountMiddleware',
'easyaudit.middleware.easyaudit.EasyAuditMiddleware',
"corsheaders.middleware.CorsMiddleware",
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.locale.LocaleMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"allauth.account.middleware.AccountMiddleware",
"easyaudit.middleware.easyaudit.EasyAuditMiddleware",
]
ROOT_URLCONF = 'NorahUniversity.urls'
ROOT_URLCONF = "NorahUniversity.urls"
CORS_ALLOW_ALL_ORIGINS = True
ASGI_APPLICATION = 'hospital_recruitment.asgi.application'
ASGI_APPLICATION = "hospital_recruitment.asgi.application"
CHANNEL_LAYERS = {
'default': {
'BACKEND': 'channels_redis.core.RedisChannelLayer',
'CONFIG': {
'hosts': [('127.0.0.1', 6379)],
"default": {
"BACKEND": "channels_redis.core.RedisChannelLayer",
"CONFIG": {
"hosts": [("127.0.0.1", 6379)],
},
},
}
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [os.path.join(BASE_DIR, 'templates')],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [os.path.join(BASE_DIR, "templates")],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = 'NorahUniversity.wsgi.application'
WSGI_APPLICATION = "NorahUniversity.wsgi.application"
# Database
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
@ -135,9 +136,9 @@ WSGI_APPLICATION = 'NorahUniversity.wsgi.application'
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql_psycopg2',
'NAME': 'haikal_db',
'USER': 'faheed',
'PASSWORD': 'Faheed@215',
'NAME': os.getenv("DB_NAME"),
'USER': os.getenv("DB_USER"),
'PASSWORD': os.getenv("DB_PASSWORD"),
'HOST': '127.0.0.1',
'PORT': '5432',
}
@ -155,6 +156,23 @@ DATABASES = {
# AUTH_PASSWORD_VALIDATORS = [
# {
# 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
# },
# {
# 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
# },
# {
# 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
# },
# {
# 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
# },
# ]
# settings.py
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
@ -171,21 +189,20 @@ AUTH_PASSWORD_VALIDATORS = [
]
ACCOUNT_LOGIN_METHODS = ['email']
ACCOUNT_SIGNUP_FIELDS = ['email*', 'password1*', 'password2*']
ACCOUNT_LOGIN_METHODS = ["email"]
ACCOUNT_SIGNUP_FIELDS = ["email*", "password1*", "password2*"]
ACCOUNT_UNIQUE_EMAIL = True
ACCOUNT_EMAIL_VERIFICATION = 'none'
ACCOUNT_USER_MODEL_USERNAME_FIELD = None
ACCOUNT_EMAIL_VERIFICATION = "mandatory"
ACCOUNT_LOGIN_ON_EMAIL_CONFIRMATION = True
ACCOUNT_FORMS = {'signup': 'recruitment.forms.StaffSignupForm'}
ACCOUNT_FORMS = {"signup": "recruitment.forms.StaffSignupForm"}
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
# Crispy Forms Configuration
CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5"
@ -193,29 +210,29 @@ CRISPY_TEMPLATE_PACK = "bootstrap5"
# Bootstrap 5 Configuration
CRISPY_BS5 = {
'include_placeholder_text': True,
'use_css_helpers': True,
"include_placeholder_text": True,
"use_css_helpers": True,
}
ACCOUNT_RATE_LIMITS = {
'send_email_confirmation': None, # Disables the limit
"send_email_confirmation": None, # Disables the limit
}
# Internationalization
# https://docs.djangoproject.com/en/5.2/topics/i18n/
LANGUAGES = [
('en', 'English'),
('ar', 'Arabic'),
("en", "English"),
("ar", "Arabic"),
]
LANGUAGE_CODE = 'en-us'
LANGUAGE_CODE = "en-us"
LOCALE_PATHS = [
BASE_DIR / 'locale',
BASE_DIR / "locale",
]
TIME_ZONE = 'Asia/Riyadh'
TIME_ZONE = "Asia/Riyadh"
USE_I18N = True
@ -224,36 +241,35 @@ USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.2/howto/static-files/
STATIC_URL = '/static/'
MEDIA_URL = '/media/'
STATICFILES_DIRS = [
BASE_DIR / 'static'
]
STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles')
MEDIA_ROOT = os.path.join(BASE_DIR, 'static/media')
STATIC_URL = "/static/"
MEDIA_URL = "/media/"
STATICFILES_DIRS = [BASE_DIR / "static"]
STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles")
MEDIA_ROOT = os.path.join(BASE_DIR, "media")
# Default primary key field type
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# LinkedIn OAuth Config
SOCIALACCOUNT_PROVIDERS = {
'linkedin_oauth2': {
'SCOPE': [
'r_liteprofile', 'r_emailaddress', 'w_member_social',
'rw_organization_admin', 'w_organization_social'
"linkedin_oauth2": {
"SCOPE": [
"r_liteprofile",
"r_emailaddress",
"w_member_social",
"rw_organization_admin",
"w_organization_social",
],
'PROFILE_FIELDS': [
'id', 'first-name', 'last-name', 'email-address'
]
"PROFILE_FIELDS": ["id", "first-name", "last-name", "email-address"],
}
}
ZOOM_ACCOUNT_ID = 'HoGikHXsQB2GNDC5Rvyw9A'
ZOOM_CLIENT_ID = 'brC39920R8C8azfudUaQgA'
ZOOM_CLIENT_SECRET = 'rvfhjlbID4ychXPOvZ2lYsoAC0B0Ny2L'
SECRET_TOKEN = '6KdTGyF0SSCSL_V4Xa34aw'
ZOOM_ACCOUNT_ID = "HoGikHXsQB2GNDC5Rvyw9A"
ZOOM_CLIENT_ID = "brC39920R8C8azfudUaQgA"
ZOOM_CLIENT_SECRET = "rvfhjlbID4ychXPOvZ2lYsoAC0B0Ny2L"
SECRET_TOKEN = "6KdTGyF0SSCSL_V4Xa34aw"
ZOOM_WEBHOOK_API_KEY = "2GNDC5Rvyw9AHoGikHXsQB"
# Maximum file upload size (in bytes)
@ -262,146 +278,200 @@ FILE_UPLOAD_MAX_MEMORY_SIZE = 10485760 # 10MB
CORS_ALLOW_CREDENTIALS = True
CELERY_BROKER_URL = 'redis://localhost:6379/0' # Or your message broker URL
CELERY_RESULT_BACKEND = 'django-db' # If using django-celery-results
CELERY_ACCEPT_CONTENT = ['application/json']
CELERY_TASK_SERIALIZER = 'json'
CELERY_RESULT_SERIALIZER = 'json'
CELERY_TIMEZONE = 'UTC'
CELERY_BROKER_URL = "redis://localhost:6379/0" # Or your message broker URL
CELERY_RESULT_BACKEND = "django-db" # If using django-celery-results
CELERY_ACCEPT_CONTENT = ["application/json"]
CELERY_TASK_SERIALIZER = "json"
CELERY_RESULT_SERIALIZER = "json"
CELERY_TIMEZONE = "UTC"
LINKEDIN_CLIENT_ID = '867jwsiyem1504'
LINKEDIN_CLIENT_SECRET = 'WPL_AP1.QNH5lYnfRSQpp0Qp.GO8Srw=='
LINKEDIN_REDIRECT_URI = 'http://127.0.0.1:8000/jobs/linkedin/callback/'
LINKEDIN_CLIENT_ID = "867jwsiyem1504"
LINKEDIN_CLIENT_SECRET = "WPL_AP1.QNH5lYnfRSQpp0Qp.GO8Srw=="
LINKEDIN_REDIRECT_URI = "http://127.0.0.1:8000/jobs/linkedin/callback/"
Q_CLUSTER = {
'name': 'KAAUH_CLUSTER',
'workers': 8,
'recycle': 500,
'timeout': 60,
'max_attempts': 1,
'compress': True,
'save_limit': 250,
'queue_limit': 500,
'cpu_affinity': 1,
'label': 'Django Q2',
'redis': {
'host': '127.0.0.1',
'port': 6379,
'db': 3, },
'ALT_CLUSTERS': {
'long': {
'timeout': 3000,
'retry': 3600,
'max_attempts': 2,
"name": "KAAUH_CLUSTER",
"workers": 8,
"recycle": 500,
"timeout": 60,
"max_attempts": 1,
"compress": True,
"save_limit": 250,
"queue_limit": 500,
"cpu_affinity": 1,
"label": "Django Q2",
"redis": {
"host": "127.0.0.1",
"port": 6379,
"db": 3,
},
"ALT_CLUSTERS": {
"long": {
"timeout": 3000,
"retry": 3600,
"max_attempts": 2,
},
'short': {
'timeout': 10,
'max_attempts': 1,
"short": {
"timeout": 10,
"max_attempts": 1,
},
}
},
}
customColorPalette = [
{
'color': 'hsl(4, 90%, 58%)',
'label': 'Red'
},
{
'color': 'hsl(340, 82%, 52%)',
'label': 'Pink'
},
{
'color': 'hsl(291, 64%, 42%)',
'label': 'Purple'
},
{
'color': 'hsl(262, 52%, 47%)',
'label': 'Deep Purple'
},
{
'color': 'hsl(231, 48%, 48%)',
'label': 'Indigo'
},
{
'color': 'hsl(207, 90%, 54%)',
'label': 'Blue'
},
]
{"color": "hsl(4, 90%, 58%)", "label": "Red"},
{"color": "hsl(340, 82%, 52%)", "label": "Pink"},
{"color": "hsl(291, 64%, 42%)", "label": "Purple"},
{"color": "hsl(262, 52%, 47%)", "label": "Deep Purple"},
{"color": "hsl(231, 48%, 48%)", "label": "Indigo"},
{"color": "hsl(207, 90%, 54%)", "label": "Blue"},
]
# CKEDITOR_5_CUSTOM_CSS = 'path_to.css' # optional
# CKEDITOR_5_FILE_STORAGE = "path_to_storage.CustomStorage" # optional
CKEDITOR_5_CONFIGS = {
'default': {
'toolbar': {
'items': ['heading', '|', 'bold', 'italic', 'link',
'bulletedList', 'numberedList', 'blockQuote', 'imageUpload', ],
}
"default": {
"toolbar": {
"items": [
"heading",
"|",
"bold",
"italic",
"link",
"bulletedList",
"numberedList",
"blockQuote",
"imageUpload",
],
}
},
'extends': {
'blockToolbar': [
'paragraph', 'heading1', 'heading2', 'heading3',
'|',
'bulletedList', 'numberedList',
'|',
'blockQuote',
"extends": {
"blockToolbar": [
"paragraph",
"heading1",
"heading2",
"heading3",
"|",
"bulletedList",
"numberedList",
"|",
"blockQuote",
],
'toolbar': {
'items': ['heading', '|', 'outdent', 'indent', '|', 'bold', 'italic', 'link', 'underline', 'strikethrough',
'code','subscript', 'superscript', 'highlight', '|', 'codeBlock', 'sourceEditing', 'insertImage',
'bulletedList', 'numberedList', 'todoList', '|', 'blockQuote', 'imageUpload', '|',
'fontSize', 'fontFamily', 'fontColor', 'fontBackgroundColor', 'mediaEmbed', 'removeFormat',
'insertTable',
],
'shouldNotGroupWhenFull': 'true'
"toolbar": {
"items": [
"heading",
"|",
"outdent",
"indent",
"|",
"bold",
"italic",
"link",
"underline",
"strikethrough",
"code",
"subscript",
"superscript",
"highlight",
"|",
"codeBlock",
"sourceEditing",
"insertImage",
"bulletedList",
"numberedList",
"todoList",
"|",
"blockQuote",
"imageUpload",
"|",
"fontSize",
"fontFamily",
"fontColor",
"fontBackgroundColor",
"mediaEmbed",
"removeFormat",
"insertTable",
],
"shouldNotGroupWhenFull": "true",
},
'image': {
'toolbar': ['imageTextAlternative', '|', 'imageStyle:alignLeft',
'imageStyle:alignRight', 'imageStyle:alignCenter', 'imageStyle:side', '|'],
'styles': [
'full',
'side',
'alignLeft',
'alignRight',
'alignCenter',
]
"image": {
"toolbar": [
"imageTextAlternative",
"|",
"imageStyle:alignLeft",
"imageStyle:alignRight",
"imageStyle:alignCenter",
"imageStyle:side",
"|",
],
"styles": [
"full",
"side",
"alignLeft",
"alignRight",
"alignCenter",
],
},
'table': {
'contentToolbar': [ 'tableColumn', 'tableRow', 'mergeTableCells',
'tableProperties', 'tableCellProperties' ],
'tableProperties': {
'borderColors': customColorPalette,
'backgroundColors': customColorPalette
"table": {
"contentToolbar": [
"tableColumn",
"tableRow",
"mergeTableCells",
"tableProperties",
"tableCellProperties",
],
"tableProperties": {
"borderColors": customColorPalette,
"backgroundColors": customColorPalette,
},
"tableCellProperties": {
"borderColors": customColorPalette,
"backgroundColors": customColorPalette,
},
'tableCellProperties': {
'borderColors': customColorPalette,
'backgroundColors': customColorPalette
}
},
'heading' : {
'options': [
{ 'model': 'paragraph', 'title': 'Paragraph', 'class': 'ck-heading_paragraph' },
{ 'model': 'heading1', 'view': 'h1', 'title': 'Heading 1', 'class': 'ck-heading_heading1' },
{ 'model': 'heading2', 'view': 'h2', 'title': 'Heading 2', 'class': 'ck-heading_heading2' },
{ 'model': 'heading3', 'view': 'h3', 'title': 'Heading 3', 'class': 'ck-heading_heading3' }
"heading": {
"options": [
{
"model": "paragraph",
"title": "Paragraph",
"class": "ck-heading_paragraph",
},
{
"model": "heading1",
"view": "h1",
"title": "Heading 1",
"class": "ck-heading_heading1",
},
{
"model": "heading2",
"view": "h2",
"title": "Heading 2",
"class": "ck-heading_heading2",
},
{
"model": "heading3",
"view": "h3",
"title": "Heading 3",
"class": "ck-heading_heading3",
},
]
},
},
"list": {
"properties": {
"styles": "true",
"startIndex": "true",
"reversed": "true",
}
},
'list': {
'properties': {
'styles': 'true',
'startIndex': 'true',
'reversed': 'true',
}
}
}
# Define a constant in settings.py to specify file upload permissions
CKEDITOR_5_FILE_UPLOAD_PERMISSION = "staff" # Possible values: "staff", "authenticated", "any"
CKEDITOR_5_FILE_UPLOAD_PERMISSION = (
"staff" # Possible values: "staff", "authenticated", "any"
)
@ -409,3 +479,7 @@ from django.contrib.messages import constants as messages
MESSAGE_TAGS = {
messages.ERROR: 'danger',
}
# Custom User Model
AUTH_USER_MODEL = "recruitment.CustomUser"

View File

@ -26,6 +26,7 @@ urlpatterns = [
path('application/<slug:template_slug>/', views.application_submit_form, name='application_submit_form'),
path('application/<slug:template_slug>/submit/', views.application_submit, name='application_submit'),
path('application/<slug:slug>/apply/', views.application_detail, name='application_detail'),
path('application/<slug:slug>/signup/', views.candidate_signup, name='candidate_signup'),
path('application/<slug:slug>/success/', views.application_success, name='application_success'),
path('application/applicant/profile', views.applicant_profile, name='applicant_profile'),

View File

@ -0,0 +1,212 @@
#!/usr/bin/env python3
"""
Comprehensive Translation Merger
Merges all 35 translation batch files into the main django.po file
"""
import os
import re
import glob
def parse_batch_file(filename):
"""Parse a batch file and extract English-Arabic translation pairs"""
translations = {}
try:
with open(filename, 'r', encoding='utf-8') as f:
content = f.read()
# Pattern to match the format in completed batch files:
# msgid: "English text"
# msgstr: ""
# Arabic Translation:
# msgstr: "Arabic text"
pattern = r'msgid:\s*"([^"]*?)"\s*\nmsgstr:\s*""\s*\nArabic Translation:\s*\nmsgstr:\s*"([^"]*?)"'
matches = re.findall(pattern, content, re.MULTILINE | re.DOTALL)
for english, arabic in matches:
english = english.strip()
arabic = arabic.strip()
# Skip empty or invalid entries
if english and arabic and len(english) > 1 and len(arabic) > 1:
translations[english] = arabic
except Exception as e:
print(f"Error parsing {filename}: {e}")
return translations
def parse_current_django_po():
"""Parse the current django.po file and extract existing translations"""
po_file = 'locale/ar/LC_MESSAGES/django.po'
if not os.path.exists(po_file):
return {}, []
with open(po_file, 'r', encoding='utf-8') as f:
content = f.read()
# Extract msgid/msgstr pairs
pattern = r'msgid\s+"([^"]*?)"\s*\nmsgstr\s+"([^"]*?)"'
matches = re.findall(pattern, content)
existing_translations = {}
for msgid, msgstr in matches:
existing_translations[msgid] = msgstr
# Extract the header and footer
parts = re.split(r'(msgid\s+"[^"]*?"\s*\nmsgstr\s+"[^"]*?")', content)
return existing_translations, parts
def create_comprehensive_translation_dict():
"""Create a comprehensive translation dictionary from all batch files"""
all_translations = {}
# Get all batch files
batch_files = glob.glob('translation_batch_*.txt')
batch_files.sort() # Process in order
print(f"Found {len(batch_files)} batch files")
for batch_file in batch_files:
print(f"Processing {batch_file}...")
batch_translations = parse_batch_file(batch_file)
for english, arabic in batch_translations.items():
if english not in all_translations:
all_translations[english] = arabic
else:
# Keep the first translation found, but note duplicates
print(f" Duplicate found: '{english}' -> '{arabic}' (existing: '{all_translations[english]}')")
print(f"Total unique translations: {len(all_translations)}")
return all_translations
def update_django_po(translations):
"""Update the django.po file with new translations"""
po_file = 'locale/ar/LC_MESSAGES/django.po'
# Read current file
with open(po_file, 'r', encoding='utf-8') as f:
content = f.read()
lines = content.split('\n')
new_lines = []
i = 0
updated_count = 0
while i < len(lines):
line = lines[i]
if line.startswith('msgid '):
# Extract the msgid content
msgid_match = re.match(r'msgid\s+"([^"]*)"', line)
if msgid_match:
msgid = msgid_match.group(1)
# Look for the corresponding msgstr
if i + 1 < len(lines) and lines[i + 1].startswith('msgstr '):
msgstr_match = re.match(r'msgstr\s+"([^"]*)"', lines[i + 1])
current_msgstr = msgstr_match.group(1) if msgstr_match else ""
# Check if we have a translation for this msgid
if msgid in translations and (not current_msgstr or current_msgstr == ""):
# Update the translation
new_translation = translations[msgid]
new_lines.append(line) # Keep msgid line
new_lines.append(f'msgstr "{new_translation}"') # Update msgstr
updated_count += 1
print(f" Updated: '{msgid}' -> '{new_translation}'")
else:
# Keep existing translation
new_lines.append(line)
new_lines.append(lines[i + 1])
i += 2 # Skip both msgid and msgstr lines
continue
new_lines.append(line)
i += 1
# Write updated content
new_content = '\n'.join(new_lines)
# Create backup
backup_file = po_file + '.backup'
with open(backup_file, 'w', encoding='utf-8') as f:
f.write(content)
print(f"Created backup: {backup_file}")
# Write updated file
with open(po_file, 'w', encoding='utf-8') as f:
f.write(new_content)
print(f"Updated {updated_count} translations in {po_file}")
return updated_count
def add_missing_translations(translations):
"""Add completely missing translations to django.po"""
po_file = 'locale/ar/LC_MESSAGES/django.po'
with open(po_file, 'r', encoding='utf-8') as f:
content = f.read()
existing_translations, _ = parse_current_django_po()
# Find translations that don't exist in the .po file at all
missing_translations = {}
for english, arabic in translations.items():
if english not in existing_translations:
missing_translations[english] = arabic
if missing_translations:
print(f"Found {len(missing_translations)} completely missing translations")
# Add missing translations to the end of the file
with open(po_file, 'a', encoding='utf-8') as f:
f.write('\n\n# Auto-added missing translations\n')
for english, arabic in missing_translations.items():
f.write(f'\nmsgid "{english}"\n')
f.write(f'msgstr "{arabic}"\n')
print(f"Added {len(missing_translations)} missing translations")
else:
print("No missing translations found")
return len(missing_translations)
def main():
"""Main function to merge all translations"""
print("🚀 Starting Comprehensive Translation Merger")
print("=" * 50)
# Step 1: Create comprehensive translation dictionary
print("\n📚 Step 1: Building comprehensive translation dictionary...")
translations = create_comprehensive_translation_dict()
# Step 2: Update existing translations in django.po
print("\n🔄 Step 2: Updating existing translations in django.po...")
updated_count = update_django_po(translations)
# Step 3: Add completely missing translations
print("\n Step 3: Adding missing translations...")
added_count = add_missing_translations(translations)
# Step 4: Summary
print("\n📊 Summary:")
print(f" Total translations available: {len(translations)}")
print(f" Updated existing translations: {updated_count}")
print(f" Added missing translations: {added_count}")
print(f" Total translations processed: {updated_count + added_count}")
print("\n✅ Translation merge completed!")
print("\n📝 Next steps:")
print(" 1. Run: python manage.py compilemessages")
print(" 2. Test Arabic translations in the browser")
print(" 3. Verify language switching functionality")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,48 @@
EMPTY TRANSLATIONS SUMMARY REPORT
==================================================
Total empty translations: 843
UI Elements (Buttons, Links): 20
Form Fields & Inputs: 55
Messages (Error/Success/Warning): 27
Navigation & Pages: 7
Other: 734
SAMPLE ENTRIES:
------------------------------
UI Elements (showing first 5):
Line 1491: "Click Here to Reset Your Password"
Line 2685: "Email will be sent to all selected recipients"
Line 2743: "Click here to join meeting"
Line 2813: "Candidates to Schedule (Hold Ctrl/Cmd to select multiple)"
Line 4057: "Select the agency job assignment"
Form Fields (showing first 5):
Line 1658: "Enter your e-mail address to reset your password."
Line 1712: "Please enter your new password below."
Line 2077: "Form:"
Line 2099: "Field Property"
Line 2133: "Field Required"
Messages (showing first 5):
Line 1214: "Notification Message"
Line 2569: "Success"
Line 2776: "An unknown error occurred."
Line 2780: "An error occurred while processing your request."
Line 2872: "Your application has been submitted successfully"
Navigation (showing first 5):
Line 1295: "You don't have permission to view this page."
Line 2232: "Page"
Line 6253: "Admin Settings Dashboard"
Line 6716: "That page number is not an integer"
Line 6720: "That page number is less than 1"
Other (showing first 5):
Line 7: ""
Line 1041: "Number of candidates submitted so far"
Line 1052: "Deadline for agency to submit candidates"
Line 1068: "Original deadline before extensions"
Line 1078: "Agency Job Assignment"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -3,12 +3,14 @@ from django.utils.html import format_html
from django.urls import reverse
from django.utils import timezone
from .models import (
JobPosting, Candidate, TrainingMaterial, ZoomMeeting,
JobPosting, Application, TrainingMaterial, ZoomMeeting,
FormTemplate, FormStage, FormField, FormSubmission, FieldResponse,
SharedFormTemplate, Source, HiringAgency, IntegrationLog,InterviewSchedule,Profile,JobPostingImage,MeetingComment,
AgencyAccessLink, AgencyJobAssignment
)
from django.contrib.auth import get_user_model
User = get_user_model()
class FormFieldInline(admin.TabularInline):
model = FormField
extra = 1
@ -82,17 +84,10 @@ class HiringAgencyAdmin(admin.ModelAdmin):
readonly_fields = ['slug', 'created_at', 'updated_at']
fieldsets = (
('Basic Information', {
'fields': ('name', 'slug', 'contact_person', 'email', 'phone', 'website')
}),
('Location Details', {
'fields': ('country', 'city', 'address')
}),
('Additional Information', {
'fields': ('description', 'created_at', 'updated_at')
'fields': ('name','contact_person', 'email', 'phone', 'website','user')
}),
)
save_on_top = True
prepopulated_fields = {'slug': ('name',)}
@admin.register(JobPosting)
@ -143,43 +138,6 @@ class JobPostingAdmin(admin.ModelAdmin):
mark_as_closed.short_description = 'Mark selected jobs as closed'
@admin.register(Candidate)
class CandidateAdmin(admin.ModelAdmin):
list_display = ['full_name', 'job', 'email', 'phone', 'stage', 'applied','is_resume_parsed', 'created_at']
list_filter = ['stage', 'applied', 'created_at', 'job__department']
search_fields = ['first_name', 'last_name', 'email', 'phone']
readonly_fields = ['slug', 'created_at', 'updated_at']
fieldsets = (
('Personal Information', {
'fields': ('first_name', 'last_name', 'email', 'phone', 'resume')
}),
('Application Details', {
'fields': ('job', 'applied', 'stage','is_resume_parsed')
}),
('Interview Process', {
'fields': ('exam_date', 'exam_status', 'interview_date', 'interview_status', 'offer_date', 'offer_status', 'join_date')
}),
('Scoring', {
'fields': ('ai_analysis_data',)
}),
('Additional Information', {
'fields': ('submitted_by_agency', 'created_at', 'updated_at')
}),
)
save_on_top = True
actions = ['mark_as_applied', 'mark_as_not_applied']
def mark_as_applied(self, request, queryset):
updated = queryset.update(applied=True)
self.message_user(request, f'{updated} candidates marked as applied.')
mark_as_applied.short_description = 'Mark selected candidates as applied'
def mark_as_not_applied(self, request, queryset):
updated = queryset.update(applied=False)
self.message_user(request, f'{updated} candidates marked as not applied.')
mark_as_not_applied.short_description = 'Mark selected candidates as not applied'
@admin.register(TrainingMaterial)
class TrainingMaterialAdmin(admin.ModelAdmin):
list_display = ['title', 'created_by', 'created_at']
@ -280,6 +238,7 @@ class FormSubmissionAdmin(admin.ModelAdmin):
# Register other models
admin.site.register(FormStage)
admin.site.register(Application)
admin.site.register(FormField)
admin.site.register(FieldResponse)
admin.site.register(InterviewSchedule)
@ -290,3 +249,4 @@ admin.site.register(AgencyJobAssignment)
admin.site.register(JobPostingImage)
admin.site.register(User)

View File

@ -1,17 +1,163 @@
from functools import wraps
from datetime import date
from django.shortcuts import redirect, get_object_or_404
from django.http import HttpResponseNotFound
from django.http import HttpResponseNotFound, HttpResponseForbidden
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import AccessMixin
from django.core.exceptions import PermissionDenied
from django.contrib import messages
def job_not_expired(view_func):
@wraps(view_func)
def _wrapped_view(request, job_id, *args, **kwargs):
from .models import JobPosting
job = get_object_or_404(JobPosting, pk=job_id)
if job.expiration_date and job.application_deadline< date.today():
return redirect('expired_job_page')
return view_func(request, job_id, *args, **kwargs)
return _wrapped_view
return _wrapped_view
def user_type_required(allowed_types=None, login_url=None):
"""
Decorator to restrict view access based on user type.
Args:
allowed_types (list): List of allowed user types ['staff', 'agency', 'candidate']
login_url (str): URL to redirect to if user is not authenticated
"""
if allowed_types is None:
allowed_types = ['staff']
def decorator(view_func):
@wraps(view_func)
@login_required(login_url=login_url)
def _wrapped_view(request, *args, **kwargs):
user = request.user
# Check if user has user_type attribute
if not hasattr(user, 'user_type') or not user.user_type:
messages.error(request, "User type not specified. Please contact administrator.")
return redirect('portal_login')
# Check if user type is allowed
if user.user_type not in allowed_types:
# Log unauthorized access attempt
messages.error(
request,
f"Access denied. This page is restricted to {', '.join(allowed_types)} users."
)
# Redirect based on user type
if user.user_type == 'agency':
return redirect('agency_portal_dashboard')
elif user.user_type == 'candidate':
return redirect('candidate_portal_dashboard')
else:
return redirect('dashboard')
return view_func(request, *args, **kwargs)
return _wrapped_view
return decorator
class UserTypeRequiredMixin(AccessMixin):
"""
Mixin for class-based views to restrict access based on user type.
"""
allowed_user_types = ['staff'] # Default to staff only
login_url = '/login/'
def dispatch(self, request, *args, **kwargs):
if not request.user.is_authenticated:
return self.handle_no_permission()
# Check if user has user_type attribute
if not hasattr(request.user, 'user_type') or not request.user.user_type:
messages.error(request, "User type not specified. Please contact administrator.")
return redirect('portal_login')
# Check if user type is allowed
if request.user.user_type not in self.allowed_user_types:
# Log unauthorized access attempt
messages.error(
request,
f"Access denied. This page is restricted to {', '.join(self.allowed_user_types)} users."
)
# Redirect based on user type
if request.user.user_type == 'agency':
return redirect('agency_portal_dashboard')
elif request.user.user_type == 'candidate':
return redirect('candidate_portal_dashboard')
else:
return redirect('dashboard')
return super().dispatch(request, *args, **kwargs)
def handle_no_permission(self):
if self.request.user.is_authenticated:
# User is authenticated but doesn't have permission
messages.error(
self.request,
f"Access denied. This page is restricted to {', '.join(self.allowed_user_types)} users."
)
return redirect('dashboard')
else:
# User is not authenticated
return super().handle_no_permission()
class StaffRequiredMixin(UserTypeRequiredMixin):
"""Mixin to restrict access to staff users only."""
allowed_user_types = ['staff']
class AgencyRequiredMixin(UserTypeRequiredMixin):
"""Mixin to restrict access to agency users only."""
allowed_user_types = ['agency']
login_url = '/portal/login/'
class CandidateRequiredMixin(UserTypeRequiredMixin):
"""Mixin to restrict access to candidate users only."""
allowed_user_types = ['candidate']
login_url = '/portal/login/'
class StaffOrAgencyRequiredMixin(UserTypeRequiredMixin):
"""Mixin to restrict access to staff and agency users."""
allowed_user_types = ['staff', 'agency']
class StaffOrCandidateRequiredMixin(UserTypeRequiredMixin):
"""Mixin to restrict access to staff and candidate users."""
allowed_user_types = ['staff', 'candidate']
def agency_user_required(view_func):
"""Decorator to restrict view to agency users only."""
return user_type_required(['agency'], login_url='/portal/login/')(view_func)
def candidate_user_required(view_func):
"""Decorator to restrict view to candidate users only."""
return user_type_required(['candidate'], login_url='/portal/login/')(view_func)
def staff_user_required(view_func):
"""Decorator to restrict view to staff users only."""
return user_type_required(['staff'])(view_func)
def staff_or_agency_required(view_func):
"""Decorator to restrict view to staff and agency users."""
return user_type_required(['staff', 'agency'], login_url='/portal/login/')(view_func)
def staff_or_candidate_required(view_func):
"""Decorator to restrict view to staff and candidate users."""
return user_type_required(['staff', 'candidate'], login_url='/portal/login/')(view_func)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,71 @@
import os
from django.core.management.base import BaseCommand, CommandError
from django.conf import settings
from gpt_po_translator.main import translate_po_files
class Command(BaseCommand):
help = 'Translates PO files using gpt-po-translator configured with OpenRouter.'
def add_arguments(self, parser):
parser.add_argument(
'--folder',
type=str,
default=getattr(settings, 'LOCALE_PATHS', ['locale'])[0],
help='Path to the folder containing .po files (default is the first LOCALE_PATHS entry).',
)
parser.add_argument(
'--lang',
type=str,
help='Comma-separated target language codes (e.g., de,fr,es).',
required=True,
)
parser.add_argument(
'--model',
type=str,
default='mistralai/mistral-nemo', # Example OpenRouter model
help='The OpenRouter model to use (e.g., openai/gpt-4o, mistralai/mistral-nemo).',
)
parser.add_argument(
'--bulk',
action='store_true',
help='Enable bulk translation mode for efficiency.',
)
parser.add_argument(
'--bulksize',
type=int,
default=50,
help='Entries per batch in bulk mode (default: 50).',
)
def handle(self, *args, **options):
# --- OpenRouter Configuration ---
# 1. Get API Key from environment variable
api_key = os.environ.get('OPENROUTER_API_KEY')
if not api_key:
raise CommandError("The OPENROUTER_API_KEY environment variable is not set.")
# 2. Set the base URL for OpenRouter
openrouter_base_url = "https://openrouter.ai/api/v1"
# 3. Call the core translation function, passing OpenRouter specific config
try:
self.stdout.write(self.style.NOTICE(f"Starting translation with model: {options['model']} via OpenRouter..."))
translate_po_files(
folder=options['folder'],
lang_codes=options['lang'].split(','),
provider='openai', # gpt-po-translator uses 'openai' provider for OpenAI-compatible APIs
api_key=api_key,
model_name=options['model'],
bulk=options['bulk'],
bulk_size=options['bulksize'],
# Set the base_url for the OpenAI client to point to OpenRouter
base_url=openrouter_base_url,
# OpenRouter often requires a referrer for API usage
extra_headers={"HTTP-Referer": "http://your-django-app.com"},
)
self.stdout.write(self.style.SUCCESS(f"Successfully translated PO files for languages: {options['lang']}"))
except Exception as e:
raise CommandError(f"An error occurred during translation: {e}")

View File

@ -1,7 +1,10 @@
# Generated by Django 5.2.7 on 2025-11-05 13:05
# Generated by Django 5.2.6 on 2025-11-13 13:12
import django.contrib.auth.models
import django.contrib.auth.validators
import django.core.validators
import django.db.models.deletion
import django.utils.timezone
import django_ckeditor_5.fields
import django_countries.fields
import django_extensions.db.fields
@ -15,7 +18,8 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('auth', '0012_alter_user_first_name_max_length'),
('contenttypes', '0002_remove_content_type_name'),
]
operations = [
@ -45,25 +49,21 @@ class Migration(migrations.Migration):
},
),
migrations.CreateModel(
name='HiringAgency',
name='OnsiteMeeting',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('name', models.CharField(max_length=200, unique=True, verbose_name='Agency Name')),
('contact_person', models.CharField(blank=True, max_length=150, verbose_name='Contact Person')),
('email', models.EmailField(blank=True, max_length=254)),
('phone', models.CharField(blank=True, max_length=20)),
('website', models.URLField(blank=True)),
('notes', models.TextField(blank=True, help_text='Internal notes about the agency')),
('country', django_countries.fields.CountryField(blank=True, max_length=2, null=True)),
('address', models.TextField(blank=True, null=True)),
('topic', models.CharField(max_length=255, verbose_name='Topic')),
('start_time', models.DateTimeField(db_index=True, verbose_name='Start Time')),
('duration', models.PositiveIntegerField(verbose_name='Duration')),
('timezone', models.CharField(max_length=50, verbose_name='Timezone')),
('location', models.CharField(blank=True, null=True)),
('status', models.CharField(blank=True, db_index=True, default='waiting', max_length=20, null=True, verbose_name='Status')),
],
options={
'verbose_name': 'Hiring Agency',
'verbose_name_plural': 'Hiring Agencies',
'ordering': ['name'],
'abstract': False,
},
),
migrations.CreateModel(
@ -137,6 +137,33 @@ class Migration(migrations.Migration):
'abstract': False,
},
),
migrations.CreateModel(
name='CustomUser',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('user_type', models.CharField(choices=[('staff', 'Staff'), ('agency', 'Agency'), ('candidate', 'Candidate')], default='staff', max_length=20, verbose_name='User Type')),
('phone', models.CharField(blank=True, max_length=20, null=True, verbose_name='Phone')),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
'verbose_name': 'User',
'verbose_name_plural': 'Users',
},
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
migrations.CreateModel(
name='FormField',
fields=[
@ -206,40 +233,58 @@ class Migration(migrations.Migration):
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stages', to='recruitment.formtemplate'),
),
migrations.CreateModel(
name='Candidate',
name='HiringAgency',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('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(db_index=True, max_length=254, verbose_name='Email')),
('phone', models.CharField(max_length=20, verbose_name='Phone')),
('address', models.TextField(max_length=200, verbose_name='Address')),
('resume', models.FileField(upload_to='resumes/', verbose_name='Resume')),
('is_resume_parsed', models.BooleanField(default=False, verbose_name='Resume Parsed')),
('is_potential_candidate', models.BooleanField(default=False, verbose_name='Potential Candidate')),
('parsed_summary', models.TextField(blank=True, verbose_name='Parsed Summary')),
('applied', models.BooleanField(default=False, verbose_name='Applied')),
('stage', models.CharField(choices=[('Applied', 'Applied'), ('Exam', 'Exam'), ('Interview', 'Interview'), ('Offer', 'Offer'), ('Hired', 'Hired')], db_index=True, default='Applied', max_length=100, verbose_name='Stage')),
('applicant_status', models.CharField(blank=True, choices=[('Applicant', 'Applicant'), ('Candidate', 'Candidate')], default='Applicant', max_length=100, null=True, verbose_name='Applicant Status')),
('exam_date', models.DateTimeField(blank=True, null=True, verbose_name='Exam Date')),
('exam_status', models.CharField(blank=True, choices=[('Passed', 'Passed'), ('Failed', 'Failed')], max_length=100, null=True, verbose_name='Exam Status')),
('interview_date', models.DateTimeField(blank=True, null=True, verbose_name='Interview Date')),
('interview_status', models.CharField(blank=True, choices=[('Passed', 'Passed'), ('Failed', 'Failed')], max_length=100, null=True, verbose_name='Interview Status')),
('offer_date', models.DateField(blank=True, null=True, verbose_name='Offer Date')),
('offer_status', models.CharField(blank=True, choices=[('Accepted', 'Accepted'), ('Rejected', 'Rejected')], max_length=100, null=True, verbose_name='Offer Status')),
('hired_date', models.DateField(blank=True, null=True, verbose_name='Hired Date')),
('join_date', models.DateField(blank=True, null=True, verbose_name='Join Date')),
('ai_analysis_data', models.JSONField(default=dict, help_text='Full JSON output from the resume scoring model.', verbose_name='AI Analysis Data')),
('retry', models.SmallIntegerField(default=3, verbose_name='Resume Parsing Retry')),
('hiring_source', models.CharField(blank=True, choices=[('Public', 'Public'), ('Internal', 'Internal'), ('Agency', 'Agency')], default='Public', max_length=255, null=True, verbose_name='Hiring Source')),
('hiring_agency', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='candidates', to='recruitment.hiringagency', verbose_name='Hiring Agency')),
('name', models.CharField(max_length=200, unique=True, verbose_name='Agency Name')),
('contact_person', models.CharField(blank=True, max_length=150, verbose_name='Contact Person')),
('email', models.EmailField(blank=True, max_length=254)),
('phone', models.CharField(blank=True, max_length=20)),
('website', models.URLField(blank=True)),
('notes', models.TextField(blank=True, help_text='Internal notes about the agency')),
('country', django_countries.fields.CountryField(blank=True, max_length=2, null=True)),
('address', models.TextField(blank=True, null=True)),
('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='agency_profile', to=settings.AUTH_USER_MODEL, verbose_name='User')),
],
options={
'verbose_name': 'Candidate',
'verbose_name_plural': 'Candidates',
'verbose_name': 'Hiring Agency',
'verbose_name_plural': 'Hiring Agencies',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Application',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('resume', models.FileField(upload_to='resumes/', verbose_name='Resume')),
('cover_letter', models.FileField(blank=True, null=True, upload_to='cover_letters/', verbose_name='Cover Letter')),
('is_resume_parsed', models.BooleanField(default=False, verbose_name='Resume Parsed')),
('parsed_summary', models.TextField(blank=True, verbose_name='Parsed Summary')),
('applied', models.BooleanField(default=False, verbose_name='Applied')),
('stage', models.CharField(choices=[('Applied', 'Applied'), ('Exam', 'Exam'), ('Interview', 'Interview'), ('Offer', 'Offer'), ('Hired', 'Hired'), ('Rejected', 'Rejected')], db_index=True, default='Applied', max_length=20, verbose_name='Stage')),
('applicant_status', models.CharField(blank=True, choices=[('Applicant', 'Applicant'), ('Candidate', 'Candidate')], default='Applicant', max_length=20, null=True, verbose_name='Applicant Status')),
('exam_date', models.DateTimeField(blank=True, null=True, verbose_name='Exam Date')),
('exam_status', models.CharField(blank=True, choices=[('Passed', 'Passed'), ('Failed', 'Failed')], max_length=20, null=True, verbose_name='Exam Status')),
('interview_date', models.DateTimeField(blank=True, null=True, verbose_name='Interview Date')),
('interview_status', models.CharField(blank=True, choices=[('Passed', 'Passed'), ('Failed', 'Failed')], max_length=20, null=True, verbose_name='Interview Status')),
('offer_date', models.DateField(blank=True, null=True, verbose_name='Offer Date')),
('offer_status', models.CharField(blank=True, choices=[('Accepted', 'Accepted'), ('Rejected', 'Rejected'), ('Pending', 'Pending')], max_length=20, null=True, verbose_name='Offer Status')),
('hired_date', models.DateField(blank=True, null=True, verbose_name='Hired Date')),
('join_date', models.DateField(blank=True, null=True, verbose_name='Join Date')),
('ai_analysis_data', models.JSONField(blank=True, default=dict, help_text='Full JSON output from the resume scoring model.', null=True, verbose_name='AI Analysis Data')),
('retry', models.SmallIntegerField(default=3, verbose_name='Resume Parsing Retry')),
('hiring_source', models.CharField(blank=True, choices=[('Public', 'Public'), ('Internal', 'Internal'), ('Agency', 'Agency')], default='Public', max_length=255, null=True, verbose_name='Hiring Source')),
('hiring_agency', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='applications', to='recruitment.hiringagency', verbose_name='Hiring Agency')),
],
options={
'verbose_name': 'Application',
'verbose_name_plural': 'Applications',
},
),
migrations.CreateModel(
@ -251,8 +296,8 @@ class Migration(migrations.Migration):
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('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)),
('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='Saudia Arabia', max_length=100)),
@ -281,9 +326,8 @@ class Migration(migrations.Migration):
('cancel_reason', models.TextField(blank=True, help_text='Reason for canceling the job posting', verbose_name='Cancel Reason')),
('cancelled_by', models.CharField(blank=True, help_text='Name of person who cancelled this job', max_length=100, verbose_name='Cancelled By')),
('cancelled_at', models.DateTimeField(blank=True, null=True)),
('assigned_to', models.ForeignKey(blank=True, help_text='The user who has been assigned to this job', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_jobs', to=settings.AUTH_USER_MODEL, verbose_name='Assigned To')),
('hiring_agency', models.ManyToManyField(blank=True, help_text='External agency responsible for sourcing candidates for this role', related_name='jobs', to='recruitment.hiringagency', verbose_name='Hiring Agency')),
('users', models.ManyToManyField(blank=True, help_text='Internal staff involved in the recruitment process for this job', related_name='jobs_assigned', to=settings.AUTH_USER_MODEL, verbose_name='Internal Participant')),
('participants', models.ManyToManyField(blank=True, help_text='External participants involved in the recruitment process for this job', related_name='jobs_participating', to='recruitment.participants', verbose_name='External Participant')),
('source', models.ForeignKey(blank=True, help_text='The system or channel from which this job posting originated or was first published.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='job_postings', to='recruitment.source')),
],
options={
@ -299,6 +343,7 @@ class Migration(migrations.Migration):
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('interview_type', models.CharField(choices=[('Remote', 'Remote Interview'), ('Onsite', 'In-Person Interview')], default='Remote', max_length=10, verbose_name='Interview Meeting Type')),
('start_date', models.DateField(db_index=True, verbose_name='Start Date')),
('end_date', models.DateField(db_index=True, verbose_name='End Date')),
('working_days', models.JSONField(verbose_name='Working Days')),
@ -308,7 +353,7 @@ class Migration(migrations.Migration):
('break_end_time', models.TimeField(blank=True, null=True, verbose_name='Break End Time')),
('interview_duration', models.PositiveIntegerField(verbose_name='Interview Duration (minutes)')),
('buffer_time', models.PositiveIntegerField(default=0, verbose_name='Buffer Time (minutes)')),
('candidates', models.ManyToManyField(blank=True, null=True, related_name='interview_schedules', to='recruitment.candidate')),
('applications', models.ManyToManyField(blank=True, null=True, related_name='interview_schedules', to='recruitment.application')),
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interview_schedules', to='recruitment.jobposting')),
],
@ -319,9 +364,9 @@ class Migration(migrations.Migration):
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='form_template', to='recruitment.jobposting'),
),
migrations.AddField(
model_name='candidate',
model_name='application',
name='job',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='candidates', to='recruitment.jobposting', verbose_name='Job'),
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='applications', to='recruitment.jobposting', verbose_name='Job'),
),
migrations.CreateModel(
name='AgencyJobAssignment',
@ -356,6 +401,59 @@ class Migration(migrations.Migration):
('job', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='post_images', to='recruitment.jobposting')),
],
),
migrations.CreateModel(
name='Message',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('subject', models.CharField(max_length=200, verbose_name='Subject')),
('content', models.TextField(verbose_name='Message Content')),
('message_type', models.CharField(choices=[('direct', 'Direct Message'), ('job_related', 'Job Related'), ('system', 'System Notification')], default='direct', max_length=20, verbose_name='Message Type')),
('is_read', models.BooleanField(default=False, verbose_name='Is Read')),
('read_at', models.DateTimeField(blank=True, null=True, verbose_name='Read At')),
('job', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='recruitment.jobposting', verbose_name='Related Job')),
('recipient', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='received_messages', to=settings.AUTH_USER_MODEL, verbose_name='Recipient')),
('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_messages', to=settings.AUTH_USER_MODEL, verbose_name='Sender')),
],
options={
'verbose_name': 'Message',
'verbose_name_plural': 'Messages',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='Person',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('first_name', models.CharField(max_length=255, verbose_name='First Name')),
('last_name', models.CharField(max_length=255, verbose_name='Last Name')),
('middle_name', models.CharField(blank=True, max_length=255, null=True, verbose_name='Middle Name')),
('email', models.EmailField(db_index=True, help_text='Unique email address for the person', max_length=254, unique=True, verbose_name='Email')),
('phone', models.CharField(blank=True, max_length=20, null=True, verbose_name='Phone')),
('date_of_birth', models.DateField(blank=True, null=True, verbose_name='Date of Birth')),
('gender', models.CharField(blank=True, choices=[('M', 'Male'), ('F', 'Female'), ('O', 'Other'), ('P', 'Prefer not to say')], max_length=1, null=True, verbose_name='Gender')),
('nationality', django_countries.fields.CountryField(blank=True, max_length=2, null=True, verbose_name='Nationality')),
('address', models.TextField(blank=True, null=True, verbose_name='Address')),
('profile_image', models.ImageField(blank=True, null=True, upload_to='profile_pic/', validators=[recruitment.validators.validate_image_size], verbose_name='Profile Image')),
('linkedin_profile', models.URLField(blank=True, null=True, verbose_name='LinkedIn Profile URL')),
('agency', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='recruitment.hiringagency', verbose_name='Hiring Agency')),
('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='person_profile', to=settings.AUTH_USER_MODEL, verbose_name='User Account')),
],
options={
'verbose_name': 'Person',
'verbose_name_plural': 'People',
},
),
migrations.AddField(
model_name='application',
name='person',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='applications', to='recruitment.person', verbose_name='Person'),
),
migrations.CreateModel(
name='Profile',
fields=[
@ -435,10 +533,13 @@ class Migration(migrations.Migration):
('status', models.CharField(choices=[('scheduled', 'Scheduled'), ('confirmed', 'Confirmed'), ('cancelled', 'Cancelled'), ('completed', 'Completed')], db_index=True, default='scheduled', max_length=20)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('candidate', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.candidate')),
('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.application')),
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.jobposting')),
('onsite_meeting', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='onsite_interview', to='recruitment.onsitemeeting')),
('participants', models.ManyToManyField(blank=True, to='recruitment.participants')),
('schedule', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interviews', to='recruitment.interviewschedule')),
('zoom_meeting', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='interview', to='recruitment.zoommeeting')),
('system_users', models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL)),
('zoom_meeting', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interview', to='recruitment.zoommeeting')),
],
),
migrations.CreateModel(
@ -501,6 +602,27 @@ class Migration(migrations.Migration):
'indexes': [models.Index(fields=['unique_token'], name='recruitment_unique__f91e76_idx'), models.Index(fields=['expires_at'], name='recruitment_expires_954ed9_idx'), models.Index(fields=['is_active'], name='recruitment_is_acti_4b0804_idx')],
},
),
migrations.CreateModel(
name='Document',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('object_id', models.PositiveIntegerField(verbose_name='Object ID')),
('file', models.FileField(upload_to='documents/%Y/%m/', validators=[recruitment.validators.validate_image_size], verbose_name='Document File')),
('document_type', models.CharField(choices=[('resume', 'Resume'), ('cover_letter', 'Cover Letter'), ('certificate', 'Certificate'), ('id_document', 'ID Document'), ('passport', 'Passport'), ('education', 'Education Document'), ('experience', 'Experience Letter'), ('other', 'Other')], default='other', max_length=20, verbose_name='Document Type')),
('description', models.CharField(blank=True, max_length=200, verbose_name='Description')),
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype', verbose_name='Content Type')),
('uploaded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Uploaded By')),
],
options={
'verbose_name': 'Document',
'verbose_name_plural': 'Documents',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['content_type', 'object_id', 'document_type', 'created_at'], name='recruitment_content_547650_idx')],
},
),
migrations.CreateModel(
name='FieldResponse',
fields=[
@ -543,14 +665,6 @@ class Migration(migrations.Migration):
model_name='formtemplate',
index=models.Index(fields=['is_active'], name='recruitment_is_acti_ae5efb_idx'),
),
migrations.AddIndex(
model_name='candidate',
index=models.Index(fields=['stage'], name='recruitment_stage_f1c6eb_idx'),
),
migrations.AddIndex(
model_name='candidate',
index=models.Index(fields=['created_at'], name='recruitment_created_73590f_idx'),
),
migrations.AddIndex(
model_name='agencyjobassignment',
index=models.Index(fields=['agency', 'status'], name='recruitment_agency__491a54_idx'),
@ -571,6 +685,54 @@ class Migration(migrations.Migration):
name='agencyjobassignment',
unique_together={('agency', 'job')},
),
migrations.AddIndex(
model_name='message',
index=models.Index(fields=['sender', 'created_at'], name='recruitment_sender__49d984_idx'),
),
migrations.AddIndex(
model_name='message',
index=models.Index(fields=['recipient', 'is_read', 'created_at'], name='recruitment_recipie_af0e6d_idx'),
),
migrations.AddIndex(
model_name='message',
index=models.Index(fields=['job', 'created_at'], name='recruitment_job_id_18f813_idx'),
),
migrations.AddIndex(
model_name='message',
index=models.Index(fields=['message_type', 'created_at'], name='recruitment_message_f25659_idx'),
),
migrations.AddIndex(
model_name='person',
index=models.Index(fields=['email'], name='recruitment_email_0b1ab1_idx'),
),
migrations.AddIndex(
model_name='person',
index=models.Index(fields=['first_name', 'last_name'], name='recruitment_first_n_739de5_idx'),
),
migrations.AddIndex(
model_name='person',
index=models.Index(fields=['created_at'], name='recruitment_created_33495a_idx'),
),
migrations.AddIndex(
model_name='application',
index=models.Index(fields=['person', 'job'], name='recruitment_person__34355c_idx'),
),
migrations.AddIndex(
model_name='application',
index=models.Index(fields=['stage'], name='recruitment_stage_52c2d1_idx'),
),
migrations.AddIndex(
model_name='application',
index=models.Index(fields=['created_at'], name='recruitment_created_80633f_idx'),
),
migrations.AddIndex(
model_name='application',
index=models.Index(fields=['person', 'stage', 'created_at'], name='recruitment_person__8715ec_idx'),
),
migrations.AlterUniqueTogether(
name='application',
unique_together={('person', 'job')},
),
migrations.AddIndex(
model_name='jobposting',
index=models.Index(fields=['status', 'created_at', 'title'], name='recruitment_status_8b77aa_idx'),
@ -589,7 +751,7 @@ class Migration(migrations.Migration):
),
migrations.AddIndex(
model_name='scheduledinterview',
index=models.Index(fields=['candidate', 'job'], name='recruitment_candida_43d5b0_idx'),
index=models.Index(fields=['application', 'job'], name='recruitment_applica_927561_idx'),
),
migrations.AddIndex(
model_name='notification',

View File

@ -1,18 +0,0 @@
# Generated by Django 5.2.7 on 2025-11-06 15:19
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='scheduledinterview',
name='participants',
field=models.ManyToManyField(blank=True, to='recruitment.participants'),
),
]

View File

@ -1,20 +0,0 @@
# Generated by Django 5.2.7 on 2025-11-06 15:37
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0002_scheduledinterview_participants'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='scheduledinterview',
name='system_users',
field=models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL),
),
]

View File

@ -1,21 +0,0 @@
# Generated by Django 5.2.7 on 2025-11-06 15:44
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0003_scheduledinterview_system_users'),
]
operations = [
migrations.RemoveField(
model_name='jobposting',
name='participants',
),
migrations.RemoveField(
model_name='jobposting',
name='users',
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.2.7 on 2025-11-09 11:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0004_remove_jobposting_participants_and_more'),
]
operations = [
migrations.AddField(
model_name='scheduledinterview',
name='meeting_type',
field=models.CharField(choices=[('Remote', 'Remote Interview'), ('Onsite', 'In-Person Interview')], default='Remote', max_length=10, verbose_name='Interview Meeting Type'),
),
]

View File

@ -1,22 +0,0 @@
# Generated by Django 5.2.7 on 2025-11-09 11:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0005_scheduledinterview_meeting_type'),
]
operations = [
migrations.RemoveField(
model_name='scheduledinterview',
name='meeting_type',
),
migrations.AddField(
model_name='interviewschedule',
name='meeting_type',
field=models.CharField(choices=[('Remote', 'Remote Interview'), ('Onsite', 'In-Person Interview')], default='Remote', max_length=10, verbose_name='Interview Meeting Type'),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.2.7 on 2025-11-09 11:30
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0006_remove_scheduledinterview_meeting_type_and_more'),
]
operations = [
migrations.RenameField(
model_name='interviewschedule',
old_name='meeting_type',
new_name='interview_type',
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.2.7 on 2025-11-09 12:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0007_rename_meeting_type_interviewschedule_interview_type'),
]
operations = [
migrations.AddField(
model_name='interviewschedule',
name='location',
field=models.CharField(blank=True, default='Remote', null=True),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.2.7 on 2025-11-09 13:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0008_interviewschedule_location'),
]
operations = [
migrations.AlterField(
model_name='zoommeeting',
name='meeting_id',
field=models.CharField(blank=True, db_index=True, max_length=20, null=True, unique=True, verbose_name='Meeting ID'),
),
]

View File

@ -1,19 +0,0 @@
# Generated by Django 5.2.7 on 2025-11-09 13:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0009_alter_zoommeeting_meeting_id'),
]
operations = [
migrations.AlterField(
model_name='zoommeeting',
name='meeting_id',
field=models.CharField(db_index=True, default=1, max_length=20, unique=True, verbose_name='Meeting ID'),
preserve_default=False,
),
]

View File

@ -1,19 +0,0 @@
# Generated by Django 5.2.7 on 2025-11-09 13:50
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0010_alter_zoommeeting_meeting_id'),
]
operations = [
migrations.AlterField(
model_name='scheduledinterview',
name='zoom_meeting',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interview', to='recruitment.zoommeeting'),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.2.7 on 2025-11-10 09:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0011_alter_scheduledinterview_zoom_meeting'),
]
operations = [
migrations.AddField(
model_name='interviewschedule',
name='interview_topic',
field=models.CharField(blank=True, null=True),
),
]

View File

@ -1,45 +0,0 @@
# Generated by Django 5.2.7 on 2025-11-10 13:00
import django.db.models.deletion
import django_extensions.db.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0012_interviewschedule_interview_topic'),
]
operations = [
migrations.CreateModel(
name='OnsiteMeeting',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('topic', models.CharField(max_length=255, verbose_name='Topic')),
('start_time', models.DateTimeField(db_index=True, verbose_name='Start Time')),
('duration', models.PositiveIntegerField(verbose_name='Duration')),
('timezone', models.CharField(max_length=50, verbose_name='Timezone')),
('location', models.CharField(blank=True, null=True)),
],
options={
'abstract': False,
},
),
migrations.RemoveField(
model_name='interviewschedule',
name='interview_topic',
),
migrations.RemoveField(
model_name='interviewschedule',
name='location',
),
migrations.AddField(
model_name='scheduledinterview',
name='onsite_meeting',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interview', to='recruitment.onsitemeeting'),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.2.7 on 2025-11-10 13:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0013_onsitemeeting_and_more'),
]
operations = [
migrations.AddField(
model_name='onsitemeeting',
name='status',
field=models.CharField(blank=True, db_index=True, default='waiting', max_length=20, null=True, verbose_name='Status'),
),
]

View File

@ -1,19 +0,0 @@
# Generated by Django 5.2.7 on 2025-11-10 13:55
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0014_onsitemeeting_status'),
]
operations = [
migrations.AlterField(
model_name='scheduledinterview',
name='onsite_meeting',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='onsite_interview', to='recruitment.onsitemeeting'),
),
]

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,18 +1,20 @@
import logging
import random
from datetime import datetime, timedelta
from django.db import transaction
from django_q.models import Schedule
from django_q.tasks import schedule
from django.dispatch import receiver
from django_q.tasks import async_task
from django.db.models.signals import post_save
from django.contrib.auth.models import User
from django.utils import timezone
from .models import FormField,FormStage,FormTemplate,Candidate,JobPosting,Notification,AgencyJobAssignment,AgencyAccessLink
from .models import FormField,FormStage,FormTemplate,Application,JobPosting,Notification,HiringAgency,Person
from django.contrib.auth import get_user_model
logger = logging.getLogger(__name__)
User = get_user_model()
@receiver(post_save, sender=JobPosting)
def format_job(sender, instance, created, **kwargs):
if created:
@ -57,9 +59,9 @@ def format_job(sender, instance, created, **kwargs):
# instance.form_template.is_active = False
# instance.save()
@receiver(post_save, sender=Candidate)
@receiver(post_save, sender=Application)
def score_candidate_resume(sender, instance, created, **kwargs):
if not instance.is_resume_parsed:
if instance.resume and not instance.is_resume_parsed:
logger.info(f"Scoring resume for candidate {instance.pk}")
async_task(
'recruitment.tasks.handle_reume_parsing_and_scoring',
@ -399,11 +401,33 @@ def notification_created(sender, instance, created, **kwargs):
logger.info(f"Notification cached for SSE: {notification_data}")
@receiver(post_save,sender=AgencyJobAssignment)
def create_access_link(sender,instance,created,**kwargs):
def generate_random_password():
import string
return ''.join(random.choices(string.ascii_letters + string.digits, k=12))
@receiver(post_save, sender=HiringAgency)
def hiring_agency_created(sender, instance, created, **kwargs):
if created:
link=AgencyAccessLink(assignment=instance)
link.access_password = link.generate_password()
link.unique_token = link.generate_token()
link.expires_at = datetime.now() + timedelta(days=4)
link.save()
logger.info(f"New hiring agency created: {instance.pk} - {instance.name}")
user = User.objects.create_user(
username=instance.name,
email=instance.email,
user_type="agency"
)
user.set_password(generate_random_password())
user.save()
instance.user = user
instance.save()
@receiver(post_save, sender=Person)
def person_created(sender, instance, created, **kwargs):
if created:
logger.info(f"New Person created: {instance.pk} - {instance.email}")
user = User.objects.create_user(
username=instance.slug,
first_name=instance.first_name,
last_name=instance.last_name,
email=instance.email,
phone=instance.phone,
user_type="candidate"
)
instance.user = user
instance.save()

View File

@ -7,7 +7,7 @@ from PyPDF2 import PdfReader
from datetime import datetime
from django.db import transaction
from .utils import create_zoom_meeting
from recruitment.models import Candidate
from recruitment.models import Application
from . linkedin_service import LinkedInService
from django.shortcuts import get_object_or_404
from . models import JobPosting
@ -244,8 +244,8 @@ def handle_reume_parsing_and_scoring(pk):
# --- 1. Robust Object Retrieval (Prevents looping on DoesNotExist) ---
try:
instance = Candidate.objects.get(pk=pk)
except Candidate.DoesNotExist:
instance = Application.objects.get(pk=pk)
except Application.DoesNotExist:
# Exit gracefully if the candidate was deleted after the task was queued
logger.warning(f"Candidate matching query does not exist for pk={pk}. Exiting task.")
print(f"Candidate matching query does not exist for pk={pk}. Exiting task.")
@ -453,7 +453,7 @@ def create_interview_and_meeting(
Synchronous task for a single interview slot, dispatched by django-q.
"""
try:
candidate = Candidate.objects.get(pk=candidate_id)
candidate = Application.objects.get(pk=candidate_id)
job = JobPosting.objects.get(pk=job_id)
schedule = InterviewSchedule.objects.get(pk=schedule_id)
@ -477,7 +477,7 @@ def create_interview_and_meeting(
password=result["meeting_details"]["password"]
)
ScheduledInterview.objects.create(
candidate=candidate,
application=Application,
job=job,
zoom_meeting=zoom_meeting,
schedule=schedule,
@ -485,11 +485,11 @@ def create_interview_and_meeting(
interview_time=slot_time
)
# Log success or use Django-Q result system for monitoring
logger.info(f"Successfully scheduled interview for {candidate.name}")
logger.info(f"Successfully scheduled interview for {Application.name}")
return True # Task succeeded
else:
# Handle Zoom API failure (e.g., log it or notify administrator)
logger.error(f"Zoom API failed for {candidate.name}: {result['message']}")
logger.error(f"Zoom API failed for {Application.name}: {result['message']}")
return False # Task failed
except Exception as e:
@ -704,14 +704,14 @@ def sync_candidate_to_source_task(candidate_id, source_id):
try:
# Get the candidate and source
candidate = Candidate.objects.get(pk=candidate_id)
application = Application.objects.get(pk=candidate_id)
source = Source.objects.get(pk=source_id)
# Initialize sync service
sync_service = CandidateSyncService()
# Perform the sync operation
result = sync_service.sync_candidate_to_source(candidate, source)
result = sync_service.sync_candidate_to_source(application, source)
# Log the operation
IntegrationLog.objects.create(
@ -719,7 +719,7 @@ def sync_candidate_to_source_task(candidate_id, source_id):
action=IntegrationLog.ActionChoices.SYNC,
endpoint=source.sync_endpoint or "unknown",
method=source.sync_method or "POST",
request_data={"candidate_id": candidate_id, "candidate_name": candidate.name},
request_data={"candidate_id": candidate_id, "application_name": application.name},
response_data=result,
status_code="SUCCESS" if result.get('success') else "ERROR",
error_message=result.get('error') if not result.get('success') else None,
@ -731,8 +731,8 @@ def sync_candidate_to_source_task(candidate_id, source_id):
logger.info(f"Sync completed for candidate {candidate_id} to source {source_id}: {result}")
return result
except Candidate.DoesNotExist:
error_msg = f"Candidate not found: {candidate_id}"
except Application.DoesNotExist:
error_msg = f"Application not found: {candidate_id}"
logger.error(error_msg)
return {"success": False, "error": error_msg}

View File

@ -0,0 +1,27 @@
from django import template
register = template.Library()
@register.filter
def filename(value):
"""
Extract just the filename from a file path.
Example: 'documents/resume.pdf' -> 'resume.pdf'
"""
if not value:
return ''
# Convert to string and split by path separators
import os
return os.path.basename(str(value))
@register.filter
def split(value, delimiter):
"""
Split a string by a delimiter and return a list.
This is a custom implementation of the split functionality.
"""
if not value:
return []
return str(value).split(delimiter)

View File

@ -1,5 +1,5 @@
from django.test import TestCase, Client
from django.contrib.auth.models import User
from django.contrib.auth import get_user_model
from django.urls import reverse
from django.utils import timezone
from django.core.files.uploadedfile import SimpleUploadedFile
@ -7,6 +7,8 @@ from datetime import datetime, time, timedelta
import json
from unittest.mock import patch, MagicMock
User = get_user_model()
from .models import (
JobPosting, Candidate, ZoomMeeting, FormTemplate, FormStage, FormField,
FormSubmission, FieldResponse, InterviewSchedule, ScheduledInterview,
@ -14,11 +16,11 @@ from .models import (
)
from .forms import (
JobPostingForm, CandidateForm, ZoomMeetingForm, MeetingCommentForm,
CandidateStageForm, InterviewScheduleForm
CandidateStageForm, InterviewScheduleForm, CandidateSignupForm
)
from .views import (
ZoomMeetingListView, ZoomMeetingCreateView, job_detail, candidate_screening_view,
candidate_exam_view, candidate_interview_view, submit_form, api_schedule_candidate_meeting
candidate_exam_view, candidate_interview_view, api_schedule_candidate_meeting
)
from .views_frontend import CandidateListView, JobListView
from .utils import create_zoom_meeting, get_candidates_from_request
@ -46,14 +48,21 @@ class BaseTestCase(TestCase):
location_country='Saudi Arabia',
description='Job description',
qualifications='Job qualifications',
application_deadline=timezone.now() + timedelta(days=30),
created_by=self.user
)
self.candidate = Candidate.objects.create(
# Create a person first
from .models import Person
person = Person.objects.create(
first_name='John',
last_name='Doe',
email='john@example.com',
phone='1234567890',
phone='1234567890'
)
self.candidate = Candidate.objects.create(
person=person,
resume=SimpleUploadedFile('resume.pdf', b'file_content', content_type='application/pdf'),
job=self.job,
stage='Applied'
@ -231,28 +240,6 @@ class ViewTests(BaseTestCase):
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'success')
def test_submit_form(self):
"""Test submit_form view"""
# Create a form template first
template = FormTemplate.objects.create(
job=self.job,
name='Test Template',
created_by=self.user,
is_active=True
)
data = {
'field_1': 'John', # Assuming field ID 1 corresponds to First Name
'field_2': 'Doe', # Assuming field ID 2 corresponds to Last Name
'field_3': 'john@example.com', # Email
}
response = self.client.post(
reverse('application_submit', kwargs={'template_id': template.id}),
data
)
# After successful submission, should redirect to success page
self.assertEqual(response.status_code, 302)
class FormTests(BaseTestCase):
@ -268,13 +255,13 @@ class FormTests(BaseTestCase):
'location_city': 'Riyadh',
'location_state': 'Riyadh',
'location_country': 'Saudi Arabia',
'description': 'Job description',
'description': 'Job description with at least 20 characters to meet validation requirements',
'qualifications': 'Job qualifications',
'salary_range': '5000-7000',
'application_deadline': '2025-12-31',
'max_applications': '100',
'open_positions': '2',
'hash_tags': '#hiring, #jobopening'
'hash_tags': '#hiring,#jobopening'
}
form = JobPostingForm(data=form_data)
self.assertTrue(form.is_valid())
@ -315,24 +302,51 @@ class FormTests(BaseTestCase):
form_data = {
'stage': 'Exam'
}
form = CandidateStageForm(data=form_data, candidate=self.candidate)
form = CandidateStageForm(data=form_data, instance=self.candidate)
self.assertTrue(form.is_valid())
def test_interview_schedule_form(self):
"""Test InterviewScheduleForm"""
# Update candidate to Interview stage first
self.candidate.stage = 'Interview'
self.candidate.save()
form_data = {
'candidates': [self.candidate.id],
'start_date': (timezone.now() + timedelta(days=1)).date(),
'end_date': (timezone.now() + timedelta(days=7)).date(),
'working_days': [0, 1, 2, 3, 4], # Monday to Friday
'start_time': '09:00',
'end_time': '17:00',
'interview_duration': 60,
'buffer_time': 15
}
form = InterviewScheduleForm(slug=self.job.slug, data=form_data)
self.assertTrue(form.is_valid())
def test_candidate_signup_form_valid(self):
"""Test CandidateSignupForm with valid data"""
form_data = {
'first_name': 'John',
'last_name': 'Doe',
'email': 'john.doe@example.com',
'phone': '+1234567890',
'password': 'SecurePass123',
'confirm_password': 'SecurePass123'
}
form = CandidateSignupForm(data=form_data)
self.assertTrue(form.is_valid())
def test_candidate_signup_form_password_mismatch(self):
"""Test CandidateSignupForm with password mismatch"""
form_data = {
'first_name': 'John',
'last_name': 'Doe',
'email': 'john.doe@example.com',
'phone': '+1234567890',
'password': 'SecurePass123',
'confirm_password': 'DifferentPass123'
}
form = CandidateSignupForm(data=form_data)
self.assertFalse(form.is_valid())
self.assertIn('Passwords do not match', str(form.errors))
class IntegrationTests(BaseTestCase):
"""Integration tests for multiple components"""
@ -340,11 +354,14 @@ class IntegrationTests(BaseTestCase):
def test_candidate_journey(self):
"""Test the complete candidate journey from application to interview"""
# 1. Create candidate
candidate = Candidate.objects.create(
person = Person.objects.create(
first_name='Jane',
last_name='Smith',
email='jane@example.com',
phone='9876543210',
phone='9876543210'
)
candidate = Candidate.objects.create(
person=person,
resume=SimpleUploadedFile('resume.pdf', b'file_content', content_type='application/pdf'),
job=self.job,
stage='Applied'
@ -449,11 +466,15 @@ class PerformanceTests(BaseTestCase):
"""Test pagination with large datasets"""
# Create many candidates
for i in range(100):
Candidate.objects.create(
person = Person.objects.create(
first_name=f'Candidate{i}',
last_name=f'Test{i}',
email=f'candidate{i}@example.com',
phone=f'123456789{i}',
phone=f'123456789{i}'
)
Candidate.objects.create(
person=person,
resume=SimpleUploadedFile(f'resume{i}.pdf', b'file_content', content_type='application/pdf'),
job=self.job,
stage='Applied'
)
@ -594,13 +615,17 @@ class TestFactories:
@staticmethod
def create_candidate(**kwargs):
job = TestFactories.create_job_posting()
person = Person.objects.create(
first_name='Test',
last_name='Candidate',
email='test@example.com',
phone='1234567890'
)
defaults = {
'first_name': 'Test',
'last_name': 'Candidate',
'email': 'test@example.com',
'phone': '1234567890',
'person': person,
'job': job,
'stage': 'Applied'
'stage': 'Applied',
'resume': SimpleUploadedFile('resume.pdf', b'file_content', content_type='application/pdf')
}
defaults.update(kwargs)
return Candidate.objects.create(**defaults)

View File

@ -5,13 +5,22 @@ from . import views_integration
from . import views_source
urlpatterns = [
path('', views_frontend.dashboard_view, name='dashboard'),
path("", 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('job/<slug:slug>/upload_image_simple/', views.job_image_upload, name='job_image_upload'),
path('jobs/<slug:slug>/update/', views.edit_job, name='job_update'),
path("persons/", views.PersonListView.as_view(), name="person_list"),
path("persons/create/", views.PersonCreateView.as_view(), name="person_create"),
path("persons/<slug:slug>/", views.PersonDetailView.as_view(), name="person_detail"),
path("persons/<slug:slug>/update/", views.PersonUpdateView.as_view(), name="person_update"),
path("persons/<slug:slug>/delete/", views.PersonDeleteView.as_view(), name="person_delete"),
path("jobs/", views_frontend.JobListView.as_view(), name="job_list"),
path("jobs/create/", views.create_job, name="job_create"),
path(
"job/<slug:slug>/upload_image_simple/",
views.job_image_upload,
name="job_image_upload",
),
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'),
path('jobs/<slug:slug>/download/cvs/', views.job_cvs_download, name='job_cvs_download'),
@ -19,85 +28,250 @@ urlpatterns = [
path('careers/',views.kaauh_career,name='kaauh_career'),
# 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'),
path('jobs/<slug:slug>/schedule-interviews/', views.schedule_interviews_view, name='schedule_interviews'),
path('jobs/<slug:slug>/confirm-schedule-interviews/', views.confirm_schedule_interviews_view, name='confirm_schedule_interviews_view'),
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"),
path(
"jobs/<slug:slug>/schedule-interviews/",
views.schedule_interviews_view,
name="schedule_interviews",
),
path(
"jobs/<slug:slug>/confirm-schedule-interviews/",
views.confirm_schedule_interviews_view,
name="confirm_schedule_interviews_view",
),
# 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>/resume-template/', views_frontend.candidate_resume_template_view, name='candidate_resume_template'),
path('candidate/<slug:slug>/update-stage/', views_frontend.candidate_update_stage, name='candidate_update_stage'),
path('candidate/<slug:slug>/retry-scoring/', views_frontend.retry_scoring_view, name='candidate_retry_scoring'),
path(
"candidates/", views_frontend.ApplicationListView.as_view(), name="candidate_list"
),
path(
"candidates/create/",
views_frontend.ApplicationCreateView.as_view(),
name="candidate_create",
),
path(
"candidates/create/<slug:slug>/",
views_frontend.ApplicationCreateView.as_view(),
name="candidate_create_for_job",
),
path(
"jobs/<slug:slug>/candidates/",
views_frontend.JobApplicationListView.as_view(),
name="job_candidates_list",
),
path(
"candidates/<slug:slug>/update/",
views_frontend.ApplicationUpdateView.as_view(),
name="candidate_update",
),
path(
"candidates/<slug:slug>/delete/",
views_frontend.ApplicationDeleteView.as_view(),
name="candidate_delete",
),
path(
"candidate/<slug:slug>/view/",
views_frontend.candidate_detail,
name="candidate_detail",
),
path(
"candidate/<slug:slug>/resume-template/",
views_frontend.candidate_resume_template_view,
name="candidate_resume_template",
),
path(
"candidate/<slug:slug>/update-stage/",
views_frontend.candidate_update_stage,
name="candidate_update_stage",
),
path(
"candidate/<slug:slug>/retry-scoring/",
views_frontend.retry_scoring_view,
name="candidate_retry_scoring",
),
# 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'),
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('meetings/', views.ZoomMeetingListView.as_view(), name='list_meetings'),
path('meetings/create-meeting/', views.ZoomMeetingCreateView.as_view(), name='create_meeting'),
path('meetings/meeting-details/<slug:slug>/', views.ZoomMeetingDetailsView.as_view(), name='meeting_details'),
path('meetings/update-meeting/<slug:slug>/', views.ZoomMeetingUpdateView.as_view(), name='update_meeting'),
path('meetings/delete-meeting/<slug:slug>/', views.ZoomMeetingDeleteView, name='delete_meeting'),
path("meetings/", views.ZoomMeetingListView.as_view(), name="list_meetings"),
path(
"meetings/create-meeting/",
views.ZoomMeetingCreateView.as_view(),
name="create_meeting",
),
path(
"meetings/meeting-details/<slug:slug>/",
views.ZoomMeetingDetailsView.as_view(),
name="meeting_details",
),
path(
"meetings/update-meeting/<slug:slug>/",
views.ZoomMeetingUpdateView.as_view(),
name="update_meeting",
),
path(
"meetings/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("api/create/", views.create_job, name="create_job_api"),
path("api/<slug:slug>/edit/", views.edit_job, name="edit_job_api"),
# ERP Integration URLs
path('integration/erp/', views_integration.ERPIntegrationView.as_view(), name='erp_integration'),
path('integration/erp/create-job/', views_integration.erp_create_job_view, name='erp_create_job'),
path('integration/erp/update-job/', views_integration.erp_update_job_view, name='erp_update_job'),
path('integration/erp/health/', views_integration.erp_integration_health, name='erp_integration_health'),
path(
"integration/erp/",
views_integration.ERPIntegrationView.as_view(),
name="erp_integration",
),
path(
"integration/erp/create-job/",
views_integration.erp_create_job_view,
name="erp_create_job",
),
path(
"integration/erp/update-job/",
views_integration.erp_update_job_view,
name="erp_update_job",
),
path(
"integration/erp/health/",
views_integration.erp_integration_health,
name="erp_integration_health",
),
# Form Preview URLs
# path('forms/', views.form_list, name='form_list'),
path('forms/builder/', views.form_builder, name='form_builder'),
path('forms/builder/<slug:template_slug>/', views.form_builder, name='form_builder'),
path('forms/', views.form_templates_list, name='form_templates_list'),
path('forms/create-template/', views.create_form_template, name='create_form_template'),
path('jobs/<slug:slug>/edit_linkedin_post_content/',views.edit_linkedin_post_content,name='edit_linkedin_post_content'),
path('jobs/<slug:slug>/candidate_screening_view/', views.candidate_screening_view, name='candidate_screening_view'),
path('jobs/<slug:slug>/candidate_exam_view/', views.candidate_exam_view, name='candidate_exam_view'),
path('jobs/<slug:slug>/candidate_interview_view/', views.candidate_interview_view, name='candidate_interview_view'),
path('jobs/<slug:slug>/candidate_offer_view/', views_frontend.candidate_offer_view, name='candidate_offer_view'),
path('jobs/<slug:slug>/candidate_hired_view/', views_frontend.candidate_hired_view, name='candidate_hired_view'),
path('jobs/<slug:job_slug>/export/<str:stage>/csv/', views_frontend.export_candidates_csv, name='export_candidates_csv'),
path('jobs/<slug:job_slug>/candidates/<slug:candidate_slug>/update_status/<str:stage_type>/<str:status>/', views_frontend.update_candidate_status, name='update_candidate_status'),
path("forms/builder/", views.form_builder, name="form_builder"),
path(
"forms/builder/<slug:template_slug>/", views.form_builder, name="form_builder"
),
path("forms/", views.form_templates_list, name="form_templates_list"),
path(
"forms/create-template/",
views.create_form_template,
name="create_form_template",
),
path(
"jobs/<slug:slug>/edit_linkedin_post_content/",
views.edit_linkedin_post_content,
name="edit_linkedin_post_content",
),
path(
"jobs/<slug:slug>/candidate_screening_view/",
views.candidate_screening_view,
name="candidate_screening_view",
),
path(
"jobs/<slug:slug>/candidate_exam_view/",
views.candidate_exam_view,
name="candidate_exam_view",
),
path(
"jobs/<slug:slug>/candidate_interview_view/",
views.candidate_interview_view,
name="candidate_interview_view",
),
path(
"jobs/<slug:slug>/candidate_offer_view/",
views_frontend.candidate_offer_view,
name="candidate_offer_view",
),
path(
"jobs/<slug:slug>/candidate_hired_view/",
views_frontend.candidate_hired_view,
name="candidate_hired_view",
),
path(
"jobs/<slug:job_slug>/export/<str:stage>/csv/",
views_frontend.export_candidates_csv,
name="export_candidates_csv",
),
path(
"jobs/<slug:job_slug>/candidates/<slug:candidate_slug>/update_status/<str:stage_type>/<str:status>/",
views_frontend.update_candidate_status,
name="update_candidate_status",
),
# Sync URLs
path('jobs/<slug:job_slug>/sync-hired-candidates/', views_frontend.sync_hired_candidates, name='sync_hired_candidates'),
path('sources/<int:source_id>/test-connection/', views_frontend.test_source_connection, name='test_source_connection'),
path('jobs/<slug:slug>/<int:candidate_id>/reschedule_meeting_for_candidate/<int:meeting_id>/', views.reschedule_meeting_for_candidate, name='reschedule_meeting_for_candidate'),
path('jobs/<slug:slug>/update_candidate_exam_status/', views.update_candidate_exam_status, name='update_candidate_exam_status'),
path('jobs/<slug:slug>/bulk_update_candidate_exam_status/', views.bulk_update_candidate_exam_status, name='bulk_update_candidate_exam_status'),
path('htmx/<int:pk>/candidate_criteria_view/', views.candidate_criteria_view_htmx, name='candidate_criteria_view_htmx'),
path('htmx/<slug:slug>/candidate_set_exam_date/', views.candidate_set_exam_date, name='candidate_set_exam_date'),
path('htmx/<slug:slug>/candidate_update_status/', views.candidate_update_status, name='candidate_update_status'),
path(
"jobs/<slug:job_slug>/sync-hired-candidates/",
views_frontend.sync_hired_candidates,
name="sync_hired_candidates",
),
path(
"sources/<int:source_id>/test-connection/",
views_frontend.test_source_connection,
name="test_source_connection",
),
path(
"jobs/<slug:slug>/<int:candidate_id>/reschedule_meeting_for_candidate/<int:meeting_id>/",
views.reschedule_meeting_for_candidate,
name="reschedule_meeting_for_candidate",
),
path(
"jobs/<slug:slug>/update_candidate_exam_status/",
views.update_candidate_exam_status,
name="update_candidate_exam_status",
),
path(
"jobs/<slug:slug>/bulk_update_candidate_exam_status/",
views.bulk_update_candidate_exam_status,
name="bulk_update_candidate_exam_status",
),
path(
"htmx/<int:pk>/candidate_criteria_view/",
views.candidate_criteria_view_htmx,
name="candidate_criteria_view_htmx",
),
path(
"htmx/<slug:slug>/candidate_set_exam_date/",
views.candidate_set_exam_date,
name="candidate_set_exam_date",
),
path(
"htmx/<slug:slug>/candidate_update_status/",
views.candidate_update_status,
name="candidate_update_status",
),
# path('forms/form/<slug:template_slug>/submit/', views.submit_form, name='submit_form'),
# path('forms/form/<slug:template_slug>/', views.form_wizard_view, name='form_wizard'),
path('forms/<int:template_id>/submissions/<slug:slug>/', views.form_submission_details, name='form_submission_details'),
path('forms/template/<slug:slug>/submissions/', views.form_template_submissions_list, name='form_template_submissions_list'),
path('forms/template/<int:template_id>/all-submissions/', views.form_template_all_submissions, name='form_template_all_submissions'),
path(
"forms/<int:template_id>/submissions/<slug:slug>/",
views.form_submission_details,
name="form_submission_details",
),
path(
"forms/template/<slug:slug>/submissions/",
views.form_template_submissions_list,
name="form_template_submissions_list",
),
path(
"forms/template/<int:template_id>/all-submissions/",
views.form_template_all_submissions,
name="form_template_all_submissions",
),
# 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'),
@ -110,74 +284,188 @@ urlpatterns = [
# path('api/templates/save/', views.save_form_template, name='save_form_template'),
# path('api/templates/<slug:template_slug>/', views.load_form_template, name='load_form_template'),
# path('api/templates/<slug:template_slug>/delete/', views.delete_form_template, name='delete_form_template'),
path('jobs/<slug:slug>/calendar/', views.interview_calendar_view, name='interview_calendar'),
path('jobs/<slug:slug>/calendar/interview/<int:interview_id>/', views.interview_detail_view, name='interview_detail'),
path(
"jobs/<slug:slug>/calendar/",
views.interview_calendar_view,
name="interview_calendar",
),
path(
"jobs/<slug:slug>/calendar/interview/<int:interview_id>/",
views.interview_detail_view,
name="interview_detail",
),
# Candidate Meeting Scheduling/Rescheduling URLs
path('jobs/<slug:job_slug>/candidates/<int:candidate_pk>/schedule-meeting/', views.schedule_candidate_meeting, name='schedule_candidate_meeting'),
path('api/jobs/<slug:job_slug>/candidates/<int:candidate_pk>/schedule-meeting/', views.api_schedule_candidate_meeting, name='api_schedule_candidate_meeting'),
path('jobs/<slug:job_slug>/candidates/<int:candidate_pk>/reschedule-meeting/<int:interview_pk>/', views.reschedule_candidate_meeting, name='reschedule_candidate_meeting'),
path('api/jobs/<slug:job_slug>/candidates/<int:candidate_pk>/reschedule-meeting/<int:interview_pk>/', views.api_reschedule_candidate_meeting, name='api_reschedule_candidate_meeting'),
path(
"jobs/<slug:job_slug>/candidates/<int:candidate_pk>/schedule-meeting/",
views.schedule_candidate_meeting,
name="schedule_candidate_meeting",
),
path(
"api/jobs/<slug:job_slug>/candidates/<int:candidate_pk>/schedule-meeting/",
views.api_schedule_candidate_meeting,
name="api_schedule_candidate_meeting",
),
path(
"jobs/<slug:job_slug>/candidates/<int:candidate_pk>/reschedule-meeting/<int:interview_pk>/",
views.reschedule_candidate_meeting,
name="reschedule_candidate_meeting",
),
path(
"api/jobs/<slug:job_slug>/candidates/<int:candidate_pk>/reschedule-meeting/<int:interview_pk>/",
views.api_reschedule_candidate_meeting,
name="api_reschedule_candidate_meeting",
),
# New URL for simple page-based meeting scheduling
path('jobs/<slug:slug>/candidates/<int:candidate_pk>/schedule-meeting-page/', views.schedule_meeting_for_candidate, name='schedule_meeting_for_candidate'),
path('jobs/<slug:slug>/candidates/<int:candidate_pk>/delete_meeting_for_candidate/<int:meeting_id>/', views.delete_meeting_for_candidate, name='delete_meeting_for_candidate'),
path(
"jobs/<slug:slug>/candidates/<int:candidate_pk>/schedule-meeting-page/",
views.schedule_meeting_for_candidate,
name="schedule_meeting_for_candidate",
),
path(
"jobs/<slug:slug>/candidates/<int:candidate_pk>/delete_meeting_for_candidate/<int:meeting_id>/",
views.delete_meeting_for_candidate,
name="delete_meeting_for_candidate",
),
# users urls
path('user/<int:pk>',views.user_detail,name='user_detail'),
path('user/user_profile_image_update/<int:pk>',views.user_profile_image_update,name='user_profile_image_update'),
path('easy_logs/',views.easy_logs,name='easy_logs'),
path('settings/',views.admin_settings,name='admin_settings'),
path('staff/create',views.create_staff_user,name='create_staff_user'),
path('set_staff_password/<int:pk>/',views.set_staff_password,name='set_staff_password'),
path('account_toggle_status/<int:pk>',views.account_toggle_status,name='account_toggle_status'),
path("user/<int:pk>", views.user_detail, name="user_detail"),
path(
"user/user_profile_image_update/<int:pk>",
views.user_profile_image_update,
name="user_profile_image_update",
),
path("easy_logs/", views.easy_logs, name="easy_logs"),
path("settings/", views.admin_settings, name="admin_settings"),
path("staff/create", views.create_staff_user, name="create_staff_user"),
path(
"set_staff_password/<int:pk>/",
views.set_staff_password,
name="set_staff_password",
),
path(
"account_toggle_status/<int:pk>",
views.account_toggle_status,
name="account_toggle_status",
),
# Source URLs
path('sources/', views_source.SourceListView.as_view(), name='source_list'),
path('sources/create/', views_source.SourceCreateView.as_view(), name='source_create'),
path('sources/<int:pk>/', views_source.SourceDetailView.as_view(), name='source_detail'),
path('sources/<int:pk>/update/', views_source.SourceUpdateView.as_view(), name='source_update'),
path('sources/<int:pk>/delete/', views_source.SourceDeleteView.as_view(), name='source_delete'),
path('sources/<int:pk>/generate-keys/', views_source.generate_api_keys_view, name='generate_api_keys'),
path('sources/<int:pk>/toggle-status/', views_source.toggle_source_status_view, name='toggle_source_status'),
path('sources/api/copy-to-clipboard/', views_source.copy_to_clipboard_view, name='copy_to_clipboard'),
path("sources/", views_source.SourceListView.as_view(), name="source_list"),
path(
"sources/create/", views_source.SourceCreateView.as_view(), name="source_create"
),
path(
"sources/<int:pk>/",
views_source.SourceDetailView.as_view(),
name="source_detail",
),
path(
"sources/<int:pk>/update/",
views_source.SourceUpdateView.as_view(),
name="source_update",
),
path(
"sources/<int:pk>/delete/",
views_source.SourceDeleteView.as_view(),
name="source_delete",
),
path(
"sources/<int:pk>/generate-keys/",
views_source.generate_api_keys_view,
name="generate_api_keys",
),
path(
"sources/<int:pk>/toggle-status/",
views_source.toggle_source_status_view,
name="toggle_source_status",
),
path(
"sources/api/copy-to-clipboard/",
views_source.copy_to_clipboard_view,
name="copy_to_clipboard",
),
# Meeting Comments URLs
path('meetings/<slug:slug>/comments/add/', views.add_meeting_comment, name='add_meeting_comment'),
path('meetings/<slug:slug>/comments/<int:comment_id>/edit/', views.edit_meeting_comment, name='edit_meeting_comment'),
path('meetings/<slug:slug>/comments/<int:comment_id>/delete/', views.delete_meeting_comment, name='delete_meeting_comment'),
path('meetings/<slug:slug>/set_meeting_candidate/', views.set_meeting_candidate, name='set_meeting_candidate'),
path(
"meetings/<slug:slug>/comments/add/",
views.add_meeting_comment,
name="add_meeting_comment",
),
path(
"meetings/<slug:slug>/comments/<int:comment_id>/edit/",
views.edit_meeting_comment,
name="edit_meeting_comment",
),
path(
"meetings/<slug:slug>/comments/<int:comment_id>/delete/",
views.delete_meeting_comment,
name="delete_meeting_comment",
),
path(
"meetings/<slug:slug>/set_meeting_candidate/",
views.set_meeting_candidate,
name="set_meeting_candidate",
),
# Hiring Agency URLs
path('agencies/', views.agency_list, name='agency_list'),
path('agencies/create/', views.agency_create, name='agency_create'),
path('agencies/<slug:slug>/', views.agency_detail, name='agency_detail'),
path('agencies/<slug:slug>/update/', views.agency_update, name='agency_update'),
path('agencies/<slug:slug>/delete/', views.agency_delete, name='agency_delete'),
path('agencies/<slug:slug>/candidates/', views.agency_candidates, name='agency_candidates'),
path("agencies/", views.agency_list, name="agency_list"),
path("agencies/create/", views.agency_create, name="agency_create"),
path("agencies/<slug:slug>/", views.agency_detail, name="agency_detail"),
path("agencies/<slug:slug>/update/", views.agency_update, name="agency_update"),
path("agencies/<slug:slug>/delete/", views.agency_delete, name="agency_delete"),
path(
"agencies/<slug:slug>/candidates/",
views.agency_candidates,
name="agency_candidates",
),
# path('agencies/<slug:slug>/send-message/', views.agency_detail_send_message, name='agency_detail_send_message'),
# Agency Assignment Management URLs
path('agency-assignments/', views.agency_assignment_list, name='agency_assignment_list'),
path('agency-assignments/create/', views.agency_assignment_create, name='agency_assignment_create'),
path('agency-assignments/<slug:slug>/create/', views.agency_assignment_create, name='agency_assignment_create'),
path('agency-assignments/<slug:slug>/', views.agency_assignment_detail, name='agency_assignment_detail'),
path('agency-assignments/<slug:slug>/update/', views.agency_assignment_update, name='agency_assignment_update'),
path('agency-assignments/<slug:slug>/extend-deadline/', views.agency_assignment_extend_deadline, name='agency_assignment_extend_deadline'),
path(
"agency-assignments/",
views.agency_assignment_list,
name="agency_assignment_list",
),
path(
"agency-assignments/create/",
views.agency_assignment_create,
name="agency_assignment_create",
),
path(
"agency-assignments/<slug:slug>/create/",
views.agency_assignment_create,
name="agency_assignment_create",
),
path(
"agency-assignments/<slug:slug>/",
views.agency_assignment_detail,
name="agency_assignment_detail",
),
path(
"agency-assignments/<slug:slug>/update/",
views.agency_assignment_update,
name="agency_assignment_update",
),
path(
"agency-assignments/<slug:slug>/extend-deadline/",
views.agency_assignment_extend_deadline,
name="agency_assignment_extend_deadline",
),
# Agency Access Link URLs
path('agency-access-links/create/', views.agency_access_link_create, name='agency_access_link_create'),
path('agency-access-links/<slug:slug>/', views.agency_access_link_detail, name='agency_access_link_detail'),
path('agency-access-links/<slug:slug>/deactivate/', views.agency_access_link_deactivate, name='agency_access_link_deactivate'),
path('agency-access-links/<slug:slug>/reactivate/', views.agency_access_link_reactivate, name='agency_access_link_reactivate'),
path(
"agency-access-links/create/",
views.agency_access_link_create,
name="agency_access_link_create",
),
path(
"agency-access-links/<slug:slug>/",
views.agency_access_link_detail,
name="agency_access_link_detail",
),
path(
"agency-access-links/<slug:slug>/deactivate/",
views.agency_access_link_deactivate,
name="agency_access_link_deactivate",
),
path(
"agency-access-links/<slug:slug>/reactivate/",
views.agency_access_link_reactivate,
name="agency_access_link_reactivate",
),
# Admin Message Center URLs (messaging functionality removed)
# path('admin/messages/', views.admin_message_center, name='admin_message_center'),
# path('admin/messages/compose/', views.admin_compose_message, name='admin_compose_message'),
@ -185,35 +473,72 @@ urlpatterns = [
# path('admin/messages/<int:message_id>/reply/', views.admin_message_reply, name='admin_message_reply'),
# path('admin/messages/<int:message_id>/mark-read/', views.admin_mark_message_read, name='admin_mark_message_read'),
# path('admin/messages/<int:message_id>/delete/', views.admin_delete_message, name='admin_delete_message'),
# Agency Portal URLs (for external agencies)
path('portal/login/', views.agency_portal_login, name='agency_portal_login'),
path('portal/dashboard/', views.agency_portal_dashboard, name='agency_portal_dashboard'),
path('portal/assignment/<slug:slug>/', views.agency_portal_assignment_detail, name='agency_portal_assignment_detail'),
path('portal/assignment/<slug:slug>/submit-candidate/', views.agency_portal_submit_candidate_page, name='agency_portal_submit_candidate_page'),
path('portal/submit-candidate/', views.agency_portal_submit_candidate, name='agency_portal_submit_candidate'),
path('portal/logout/', views.agency_portal_logout, name='agency_portal_logout'),
path("portal/login/", views.agency_portal_login, name="agency_portal_login"),
path(
"portal/dashboard/",
views.agency_portal_dashboard,
name="agency_portal_dashboard",
),
# Unified Portal URLs
path("login/", views.portal_login, name="portal_login"),
path(
"candidate/dashboard/",
views.candidate_portal_dashboard,
name="candidate_portal_dashboard",
),
path(
"portal/dashboard/",
views.agency_portal_dashboard,
name="agency_portal_dashboard",
),
path(
"portal/persons/",
views.agency_portal_persons_list,
name="agency_portal_persons_list",
),
path(
"portal/assignment/<slug:slug>/",
views.agency_portal_assignment_detail,
name="agency_portal_assignment_detail",
),
path(
"portal/assignment/<slug:slug>/submit-candidate/",
views.agency_portal_submit_candidate_page,
name="agency_portal_submit_candidate_page",
),
path(
"portal/submit-candidate/",
views.agency_portal_submit_candidate,
name="agency_portal_submit_candidate",
),
path("portal/logout/", views.portal_logout, name="portal_logout"),
# Agency Portal Candidate Management URLs
path('portal/candidates/<int:candidate_id>/edit/', views.agency_portal_edit_candidate, name='agency_portal_edit_candidate'),
path('portal/candidates/<int:candidate_id>/delete/', views.agency_portal_delete_candidate, name='agency_portal_delete_candidate'),
path(
"portal/candidates/<int:candidate_id>/edit/",
views.agency_portal_edit_candidate,
name="agency_portal_edit_candidate",
),
path(
"portal/candidates/<int:candidate_id>/delete/",
views.agency_portal_delete_candidate,
name="agency_portal_delete_candidate",
),
# API URLs for messaging (removed)
# path('api/agency/messages/<int:message_id>/', views.api_agency_message_detail, name='api_agency_message_detail'),
# path('api/agency/messages/<int:message_id>/mark-read/', views.api_agency_mark_message_read, name='api_agency_mark_message_read'),
# API URLs for candidate management
path('api/candidate/<int:candidate_id>/', views.api_candidate_detail, name='api_candidate_detail'),
path(
"api/candidate/<int:candidate_id>/",
views.api_candidate_detail,
name="api_candidate_detail",
),
# # Admin Notification API
# path('api/admin/notification-count/', views.api_notification_count, name='admin_notification_count'),
# # Agency Notification API
# path('api/agency/notification-count/', views.api_notification_count, name='api_agency_notification_count'),
# # SSE Notification Stream
# path('api/notifications/stream/', views.notification_stream, name='notification_stream'),
# # Notification URLs
# path('notifications/', views.notification_list, name='notification_list'),
# path('notifications/<int:notification_id>/', views.notification_detail, name='notification_detail'),
@ -222,27 +547,63 @@ urlpatterns = [
# path('notifications/<int:notification_id>/delete/', views.notification_delete, name='notification_delete'),
# path('notifications/mark-all-read/', views.notification_mark_all_read, name='notification_mark_all_read'),
# path('api/notification-count/', views.api_notification_count, name='api_notification_count'),
#participants urls
path('participants/', views_frontend.ParticipantsListView.as_view(), name='participants_list'),
path('participants/create/', views_frontend.ParticipantsCreateView.as_view(), name='participants_create'),
path('participants/<slug:slug>/', views_frontend.ParticipantsDetailView.as_view(), name='participants_detail'),
path('participants/<slug:slug>/update/', views_frontend.ParticipantsUpdateView.as_view(), name='participants_update'),
path('participants/<slug:slug>/delete/', views_frontend.ParticipantsDeleteView.as_view(), name='participants_delete'),
# participants urls
path(
"participants/",
views_frontend.ParticipantsListView.as_view(),
name="participants_list",
),
path(
"participants/create/",
views_frontend.ParticipantsCreateView.as_view(),
name="participants_create",
),
path(
"participants/<slug:slug>/",
views_frontend.ParticipantsDetailView.as_view(),
name="participants_detail",
),
path(
"participants/<slug:slug>/update/",
views_frontend.ParticipantsUpdateView.as_view(),
name="participants_update",
),
path(
"participants/<slug:slug>/delete/",
views_frontend.ParticipantsDeleteView.as_view(),
name="participants_delete",
),
# Email composition URLs
path(
"jobs/<slug:job_slug>/candidates/<slug:candidate_slug>/compose-email/",
views.compose_candidate_email,
name="compose_candidate_email",
),
# Message URLs
path("messages/", views.message_list, name="message_list"),
path("messages/create/", views.message_create, name="message_create"),
path("messages/<int:message_id>/", views.message_detail, name="message_detail"),
path("messages/<int:message_id>/reply/", views.message_reply, name="message_reply"),
path("messages/<int:message_id>/mark-read/", views.message_mark_read, name="message_mark_read"),
path("messages/<int:message_id>/mark-unread/", views.message_mark_unread, name="message_mark_unread"),
path("messages/<int:message_id>/delete/", views.message_delete, name="message_delete"),
path("api/unread-count/", views.api_unread_count, name="api_unread_count"),
# Documents
path("documents/upload/<int:application_id>/", views.document_upload, name="document_upload"),
path("documents/<int:document_id>/delete/", views.document_delete, name="document_delete"),
path("documents/<int:document_id>/download/", views.document_download, name="document_download"),
path('jobs/<slug:job_slug>/candidates/compose_email/', views.compose_candidate_email, name='compose_candidate_email'),
path('interview/partcipants/<slug:slug>/',views.create_interview_participants,name='create_interview_participants'),
path('interview/email/<slug:slug>/',views.send_interview_email,name='send_interview_email'),
# # --- SCHEDULED INTERVIEW URLS (New Centralized Management) ---
# path('interview/list/', views.InterviewListView.as_view(), name='interview_list'),
# path('interviews/<slug:slug>/', views.ScheduledInterviewDetailView.as_view(), name='scheduled_interview_detail'),
# path('interviews/<slug:slug>/update/', views.ScheduledInterviewUpdateView.as_view(), name='update_scheduled_interview'),
# path('interviews/<slug:slug>/delete/', views.ScheduledInterviewDeleteView.as_view(), name='delete_scheduled_interview'),
]

File diff suppressed because it is too large Load Diff

View File

@ -30,6 +30,9 @@ from django.utils import timezone
from datetime import timedelta
import json
# Add imports for user type restrictions
from recruitment.decorators import StaffRequiredMixin, staff_user_required
from datastar_py.django import (
DatastarResponse,
@ -39,7 +42,7 @@ from datastar_py.django import (
# from rich import print
from rich.markdown import CodeBlock
class JobListView(LoginRequiredMixin, ListView):
class JobListView(LoginRequiredMixin, StaffRequiredMixin, ListView):
model = models.JobPosting
template_name = 'jobs/job_list.html'
context_object_name = 'jobs'
@ -47,7 +50,6 @@ class JobListView(LoginRequiredMixin, ListView):
def get_queryset(self):
queryset = super().get_queryset().order_by('-created_at')
# Handle search
search_query = self.request.GET.get('search', '')
if search_query:
@ -58,24 +60,23 @@ class JobListView(LoginRequiredMixin, ListView):
)
# Filter for non-staff users
if not self.request.user.is_staff:
queryset = queryset.filter(status='Published')
# if not self.request.user.is_staff:
# queryset = queryset.filter(status='Published')
status=self.request.GET.get('status')
status = self.request.GET.get('status')
if status:
queryset=queryset.filter(status=status)
queryset = queryset.filter(status=status)
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):
class JobCreateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, CreateView):
model = models.JobPosting
form_class = forms.JobPostingForm
template_name = 'jobs/create_job.html'
@ -83,7 +84,7 @@ class JobCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView):
success_message = 'Job created successfully.'
class JobUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView):
class JobUpdateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, UpdateView):
model = models.JobPosting
form_class = forms.JobPostingForm
template_name = 'jobs/edit_job.html'
@ -92,27 +93,25 @@ class JobUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView):
slug_url_kwarg = 'slug'
class JobDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView):
class JobDeleteView(LoginRequiredMixin, StaffRequiredMixin, 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
class JobApplicationListView(LoginRequiredMixin, StaffRequiredMixin, ListView):
model = models.Application
template_name = 'jobs/job_candidates_list.html'
context_object_name = 'candidates'
context_object_name = 'applications'
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)
queryset = models.Application.objects.filter(job=self.job)
if self.request.GET.get('stage'):
stage=self.request.GET.get('stage')
@ -132,7 +131,7 @@ class JobCandidatesListView(LoginRequiredMixin, ListView):
# Filter for non-staff users
if not self.request.user.is_staff:
return models.Candidate.objects.none() # Restrict for non-staff
return models.Application.objects.none() # Restrict for non-staff
return queryset.order_by('-created_at')
@ -143,10 +142,10 @@ class JobCandidatesListView(LoginRequiredMixin, ListView):
return context
class CandidateListView(LoginRequiredMixin, ListView):
model = models.Candidate
class ApplicationListView(LoginRequiredMixin, StaffRequiredMixin, ListView):
model = models.Application
template_name = 'recruitment/candidate_list.html'
context_object_name = 'candidates'
context_object_name = 'applications'
paginate_by = 100
def get_queryset(self):
@ -156,22 +155,22 @@ class CandidateListView(LoginRequiredMixin, ListView):
search_query = self.request.GET.get('search', '')
job = self.request.GET.get('job', '')
stage = self.request.GET.get('stage', '')
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)
)
# 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)
# )
if job:
queryset = queryset.filter(job__slug=job)
if stage:
queryset = queryset.filter(stage=stage)
# Filter for non-staff users
if not self.request.user.is_staff:
return models.Candidate.objects.none() # Restrict for non-staff
# if not self.request.user.is_staff:
# return models.Application.objects.none() # Restrict for non-staff
return queryset.order_by('-created_at')
@ -184,9 +183,9 @@ class CandidateListView(LoginRequiredMixin, ListView):
return context
class CandidateCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView):
model = models.Candidate
form_class = forms.CandidateForm
class ApplicationCreateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, CreateView):
model = models.Application
form_class = forms.ApplicationForm
template_name = 'recruitment/candidate_create.html'
success_url = reverse_lazy('candidate_list')
success_message = 'Candidate created successfully.'
@ -204,18 +203,23 @@ class CandidateCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView):
form.instance.job = job
return super().form_valid(form)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
if self.request.method == 'GET':
context['person_form'] = forms.PersonForm()
return context
class CandidateUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView):
model = models.Candidate
form_class = forms.CandidateForm
class ApplicationUpdateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, UpdateView):
model = models.Application
form_class = forms.ApplicationForm
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
class ApplicationDeleteView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, DeleteView):
model = models.Application
template_name = 'recruitment/candidate_delete.html'
success_url = reverse_lazy('candidate_list')
success_message = 'Candidate deleted successfully.'
@ -225,28 +229,30 @@ class CandidateDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView):
def retry_scoring_view(request,slug):
from django_q.tasks import async_task
candidate = get_object_or_404(models.Candidate, slug=slug)
application = get_object_or_404(models.Application, slug=slug)
async_task(
'recruitment.tasks.handle_reume_parsing_and_scoring',
candidate.pk,
application.pk,
hook='recruitment.hooks.callback_ai_parsing',
sync=True,
)
return redirect('candidate_detail', slug=candidate.slug)
)
return redirect('candidate_detail', slug=application.slug)
@login_required
@staff_user_required
def training_list(request):
materials = models.TrainingMaterial.objects.all().order_by('-created_at')
return render(request, 'recruitment/training_list.html', {'materials': materials})
@login_required
@staff_user_required
def candidate_detail(request, slug):
from rich.json import JSON
candidate = get_object_or_404(models.Candidate, slug=slug)
candidate = get_object_or_404(models.Application, slug=slug)
try:
parsed = ast.literal_eval(candidate.parsed_summary)
except:
@ -255,9 +261,10 @@ def candidate_detail(request, slug):
# Create stage update form for staff users
stage_form = None
if request.user.is_staff:
stage_form = forms.CandidateStageForm()
stage_form = forms.ApplicationStageForm()
# parsed = JSON(json.dumps(parsed), indent=2, highlight=True, skip_keys=False, ensure_ascii=False, check_circular=True, allow_nan=True, default=None, sort_keys=False)
# parsed = json_to_markdown_table([parsed])
return render(request, 'recruitment/candidate_detail.html', {
@ -268,31 +275,33 @@ def candidate_detail(request, slug):
@login_required
@staff_user_required
def candidate_resume_template_view(request, slug):
"""Display formatted resume template for a candidate"""
candidate = get_object_or_404(models.Candidate, slug=slug)
application = get_object_or_404(models.Application, slug=slug)
if not request.user.is_staff:
messages.error(request, _("You don't have permission to view this page."))
return redirect('candidate_list')
return render(request, 'recruitment/candidate_resume_template.html', {
'candidate': candidate
'application': application
})
@login_required
@staff_user_required
def candidate_update_stage(request, slug):
"""Handle HTMX stage update requests"""
candidate = get_object_or_404(models.Candidate, slug=slug)
form = forms.CandidateStageForm(request.POST, instance=candidate)
application = get_object_or_404(models.Application, slug=slug)
form = forms.ApplicationStageForm(request.POST, instance=application)
if form.is_valid():
stage_value = form.cleaned_data['stage']
candidate.stage = stage_value
candidate.save(update_fields=['stage'])
messages.success(request,"Candidate Stage Updated")
return redirect("candidate_detail",slug=candidate.slug)
application.stage = stage_value
application.save(update_fields=['stage'])
messages.success(request,"application Stage Updated")
return redirect("candidate_detail",slug=application.slug)
class TrainingListView(LoginRequiredMixin, ListView):
class TrainingListView(LoginRequiredMixin, StaffRequiredMixin, ListView):
model = models.TrainingMaterial
template_name = 'recruitment/training_list.html'
context_object_name = 'materials'
@ -320,7 +329,7 @@ class TrainingListView(LoginRequiredMixin, ListView):
return context
class TrainingCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView):
class TrainingCreateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, CreateView):
model = models.TrainingMaterial
form_class = forms.TrainingMaterialForm
template_name = 'recruitment/training_create.html'
@ -332,7 +341,7 @@ class TrainingCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView):
return super().form_valid(form)
class TrainingUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView):
class TrainingUpdateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, UpdateView):
model = models.TrainingMaterial
form_class = forms.TrainingMaterialForm
template_name = 'recruitment/training_update.html'
@ -341,13 +350,13 @@ class TrainingUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView):
slug_url_kwarg = 'slug'
class TrainingDetailView(LoginRequiredMixin, DetailView):
class TrainingDetailView(LoginRequiredMixin, StaffRequiredMixin, DetailView):
model = models.TrainingMaterial
template_name = 'recruitment/training_detail.html'
context_object_name = 'material'
slug_url_kwarg = 'slug'
class TrainingDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView):
class TrainingDeleteView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, DeleteView):
model = models.TrainingMaterial
template_name = 'recruitment/training_delete.html'
success_url = reverse_lazy('training_list')
@ -355,7 +364,7 @@ class TrainingDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView):
# IMPORTANT: Ensure 'models' correctly refers to your Django models file
# Example: from . import models
# Example: from . import models
# --- Constants ---
SCORE_PATH = 'ai_analysis_data__analysis_data__match_score'
@ -365,27 +374,28 @@ TARGET_TIME_TO_HIRE_DAYS = 45 # Used for the template visualization
@login_required
@staff_user_required
def dashboard_view(request):
selected_job_pk = request.GET.get('selected_job_pk')
today = timezone.now().date()
# --- 1. BASE QUERYSETS & GLOBAL METRICS (UNFILTERED) ---
all_jobs_queryset = models.JobPosting.objects.all().order_by('-created_at')
all_candidates_queryset = models.Candidate.objects.all()
all_candidates_queryset = models.Application.objects.all()
# Global KPI Card Metrics
total_jobs_global = all_jobs_queryset.count()
total_participants = models.Participants.objects.count()
total_jobs_posted_linkedin = all_jobs_queryset.filter(linkedin_post_id__isnull=False).count()
# Data for Job App Count Chart (always for ALL jobs)
job_titles = [job.title for job in all_jobs_queryset]
job_app_counts = [job.candidates.count() for job in all_jobs_queryset]
job_app_counts = [job.applications.count() for job in all_jobs_queryset]
# --- 2. TIME SERIES: GLOBAL DAILY APPLICANTS ---
# Group ALL candidates by creation date
global_daily_applications_qs = all_candidates_queryset.annotate(
date=TruncDate('created_at')
@ -398,22 +408,22 @@ def dashboard_view(request):
# --- 3. FILTERING LOGIC: Determine the scope for scoped metrics ---
candidate_queryset = all_candidates_queryset
job_scope_queryset = all_jobs_queryset
interview_queryset = models.ScheduledInterview.objects.all()
current_job = None
if selected_job_pk:
# Filter all base querysets
candidate_queryset = candidate_queryset.filter(job__pk=selected_job_pk)
interview_queryset = interview_queryset.filter(job__pk=selected_job_pk)
try:
current_job = all_jobs_queryset.get(pk=selected_job_pk)
job_scope_queryset = models.JobPosting.objects.filter(pk=selected_job_pk)
except models.JobPosting.DoesNotExist:
pass
pass
# --- 4. TIME SERIES: SCOPED DAILY APPLICANTS ---
@ -426,15 +436,15 @@ def dashboard_view(request):
).values('date').annotate(
count=Count('pk')
).order_by('date')
scoped_dates = [item['date'].strftime('%Y-%m-%d') for item in scoped_daily_applications_qs]
scoped_counts = [item['count'] for item in scoped_daily_applications_qs]
# --- 5. SCOPED CORE AGGREGATIONS (FILTERED OR ALL) ---
total_candidates = candidate_queryset.count()
candidates_with_score_query = candidate_queryset.filter(
is_resume_parsed=True
).annotate(
@ -448,11 +458,11 @@ def dashboard_view(request):
total_active_jobs = job_scope_queryset.filter(status="ACTIVE").count()
last_week = timezone.now() - timedelta(days=7)
new_candidates_7days = candidate_queryset.filter(created_at__gte=last_week).count()
open_positions_agg = job_scope_queryset.filter(status="ACTIVE").aggregate(total_open=Sum('open_positions'))
total_open_positions = open_positions_agg['total_open'] or 0
average_applications_result = job_scope_queryset.annotate(
candidate_count=Count('candidates', distinct=True)
candidate_count=Count('applications', distinct=True)
).aggregate(avg_apps=Avg('candidate_count'))['avg_apps']
average_applications = round(average_applications_result or 0, 2)
@ -463,21 +473,24 @@ def dashboard_view(request):
)
lst=[c.time_to_hire_days for c in hired_candidates]
time_to_hire_query = hired_candidates.annotate(
time_diff=ExpressionWrapper(
F('hired_date') - F('created_at__date'),
F('join_date') - F('created_at__date'),
output_field=fields.DurationField()
)
).aggregate(avg_time_to_hire=Avg('time_diff'))
print(time_to_hire_query)
avg_time_to_hire_days = (
time_to_hire_query.get('avg_time_to_hire').days
time_to_hire_query.get('avg_time_to_hire').days
if time_to_hire_query.get('avg_time_to_hire') else 0
)
print(avg_time_to_hire_days)
applied_count = candidate_queryset.filter(stage='Applied').count()
advanced_count = candidate_queryset.filter(stage__in=['Exam', 'Interview', 'Offer']).count()
screening_pass_rate = round( (advanced_count / applied_count) * 100, 1 ) if applied_count > 0 else 0
@ -493,8 +506,8 @@ def dashboard_view(request):
meetings_scheduled_this_week = interview_queryset.filter(
interview_date__week=current_week, interview_date__year=current_year
).count()
avg_match_score_result = candidates_with_score_query.aggregate(avg_score=Avg('annotated_match_score'))['avg_score']
avg_match_score = round(avg_match_score_result or 0, 1)
avg_match_score_result = candidates_with_score_query.aggregate(avg_score=Avg('annotated_match_score'))['avg_score']
avg_match_score = round(avg_match_score_result or 0, 1)
high_potential_count = candidates_with_score_query.filter(annotated_match_score__gte=HIGH_POTENTIAL_THRESHOLD).count()
high_potential_ratio = round( (high_potential_count / total_candidates) * 100, 1 ) if total_candidates > 0 else 0
total_scored_candidates = candidates_with_score_query.count()
@ -506,15 +519,15 @@ def dashboard_view(request):
# A. Pipeline Funnel (Scoped)
stage_counts = candidate_queryset.values('stage').annotate(count=Count('stage'))
stage_map = {item['stage']: item['count'] for item in stage_counts}
candidate_stage = ['Applied', 'Exam', 'Interview', 'Offer', 'Hired']
candidate_stage = ['Applied', 'Exam', 'Interview', 'Offer', 'Hired']
candidates_count = [
stage_map.get('Applied', 0), stage_map.get('Exam', 0), stage_map.get('Interview', 0),
stage_map.get('Applied', 0), stage_map.get('Exam', 0), stage_map.get('Interview', 0),
stage_map.get('Offer', 0), stage_map.get('Hired',0)
]
# --- 7. GAUGE CHART CALCULATION (Time-to-Hire) ---
current_days = avg_time_to_hire_days
rotation_percent = current_days / MAX_TIME_TO_HIRE_DAYS if MAX_TIME_TO_HIRE_DAYS > 0 else 0
rotation_degrees = rotation_percent * 180
@ -524,20 +537,20 @@ def dashboard_view(request):
hiring_source_counts = candidate_queryset.values('hiring_source').annotate(count=Count('stage'))
source_map= {item['hiring_source']: item['count'] for item in hiring_source_counts}
candidates_count_in_each_source = [
source_map.get('Public', 0), source_map.get('Internal', 0), source_map.get('Agency', 0),
source_map.get('Public', 0), source_map.get('Internal', 0), source_map.get('Agency', 0),
]
all_hiring_sources=["Public", "Internal", "Agency"]
all_hiring_sources=["Public", "Internal", "Agency"]
# --- 8. CONTEXT RETURN ---
context = {
# Global KPIs
'total_jobs_global': total_jobs_global,
'total_participants': total_participants,
'total_jobs_posted_linkedin': total_jobs_posted_linkedin,
# Scoped KPIs
'total_active_jobs': total_active_jobs,
'total_candidates': total_candidates,
@ -549,16 +562,16 @@ def dashboard_view(request):
'offers_accepted_rate': offers_accepted_rate,
'vacancy_fill_rate': vacancy_fill_rate,
'meetings_scheduled_this_week': meetings_scheduled_this_week,
'avg_match_score': avg_match_score,
'avg_match_score': avg_match_score,
'high_potential_count': high_potential_count,
'high_potential_ratio': high_potential_ratio,
'scored_ratio': scored_ratio,
# Chart Data
'candidate_stage': json.dumps(candidate_stage),
'candidates_count': json.dumps(candidates_count),
'job_titles': json.dumps(job_titles),
'job_app_counts': json.dumps(job_app_counts),
'job_titles': json.dumps(job_titles),
'job_app_counts': json.dumps(job_app_counts),
# 'source_volume_chart_data' is intentionally REMOVED
# Time Series Data
@ -572,7 +585,7 @@ def dashboard_view(request):
'gauge_max_days': MAX_TIME_TO_HIRE_DAYS,
'gauge_target_days': TARGET_TIME_TO_HIRE_DAYS,
'gauge_rotation_degrees': rotation_degrees_final,
# UI Control
'jobs': all_jobs_queryset,
'current_job_id': selected_job_pk,
@ -582,11 +595,12 @@ def dashboard_view(request):
'candidates_count_in_each_source': json.dumps(candidates_count_in_each_source),
'all_hiring_sources': json.dumps(all_hiring_sources),
}
return render(request, 'recruitment/dashboard.html', context)
@login_required
@staff_user_required
def candidate_offer_view(request, slug):
"""View for candidates in the Offer stage"""
job = get_object_or_404(models.JobPosting, slug=slug)
@ -616,6 +630,7 @@ def candidate_offer_view(request, slug):
@login_required
@staff_user_required
def candidate_hired_view(request, slug):
"""View for hired candidates"""
job = get_object_or_404(models.JobPosting, slug=slug)
@ -645,13 +660,16 @@ def candidate_hired_view(request, slug):
@login_required
@staff_user_required
def update_candidate_status(request, job_slug, candidate_slug, stage_type, status):
"""Handle exam/interview/offer status updates"""
from django.utils import timezone
job = get_object_or_404(models.JobPosting, slug=job_slug)
candidate = get_object_or_404(models.Candidate, slug=candidate_slug, job=job)
candidate = get_object_or_404(models.Application, slug=candidate_slug, job=job)
print(stage_type)
print(status)
print(request.method)
if request.method == "POST":
if stage_type == 'exam':
candidate.exam_status = status
@ -709,6 +727,7 @@ STAGE_CONFIG = {
@login_required
@staff_user_required
def export_candidates_csv(request, job_slug, stage):
"""Export candidates for a specific stage as CSV"""
job = get_object_or_404(models.JobPosting, slug=job_slug)
@ -722,9 +741,9 @@ def export_candidates_csv(request, job_slug, stage):
# Filter candidates based on stage
if stage == 'hired':
candidates = job.candidates.filter(**config['filter'])
candidates = job.applications.filter(**config['filter'])
else:
candidates = job.candidates.filter(**config['filter'])
candidates = job.applications.filter(**config['filter'])
# Handle search if provided
search_query = request.GET.get('search', '')
@ -848,6 +867,7 @@ def export_candidates_csv(request, job_slug, stage):
@login_required
@staff_user_required
def sync_hired_candidates(request, job_slug):
"""Sync hired candidates to external sources using Django-Q"""
from django_q.tasks import async_task
@ -886,6 +906,7 @@ def sync_hired_candidates(request, job_slug):
@login_required
@staff_user_required
def test_source_connection(request, source_id):
"""Test connection to an external source"""
from .candidate_sync_service import CandidateSyncService
@ -920,6 +941,7 @@ def test_source_connection(request, source_id):
@login_required
@staff_user_required
def sync_task_status(request, task_id):
"""Check the status of a sync task"""
from django_q.models import Task
@ -971,6 +993,7 @@ def sync_task_status(request, task_id):
@login_required
@staff_user_required
def sync_history(request, job_slug=None):
"""View sync history and logs"""
from .models import IntegrationLog
@ -1005,7 +1028,7 @@ def sync_history(request, job_slug=None):
#participants views
class ParticipantsListView(LoginRequiredMixin, ListView):
class ParticipantsListView(LoginRequiredMixin, StaffRequiredMixin, ListView):
model = models.Participants
template_name = 'participants/participants_list.html'
context_object_name = 'participants'
@ -1034,13 +1057,13 @@ class ParticipantsListView(LoginRequiredMixin, ListView):
context = super().get_context_data(**kwargs)
context['search_query'] = self.request.GET.get('search', '')
return context
class ParticipantsDetailView(LoginRequiredMixin, DetailView):
class ParticipantsDetailView(LoginRequiredMixin, StaffRequiredMixin, DetailView):
model = models.Participants
template_name = 'participants/participants_detail.html'
context_object_name = 'participant'
slug_url_kwarg = 'slug'
class ParticipantsCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView):
class ParticipantsCreateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, CreateView):
model = models.Participants
form_class = forms.ParticipantsForm
template_name = 'participants/participants_create.html'
@ -1054,9 +1077,9 @@ class ParticipantsCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateVie
# initial['jobs'] = [job]
# return initial
class ParticipantsUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView):
class ParticipantsUpdateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, UpdateView):
model = models.Participants
form_class = forms.ParticipantsForm
template_name = 'participants/participants_create.html'
@ -1064,9 +1087,9 @@ class ParticipantsUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateVie
success_message = 'Participant updated successfully.'
slug_url_kwarg = 'slug'
class ParticipantsDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView):
class ParticipantsDeleteView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, DeleteView):
model = models.Participants
success_url = reverse_lazy('participants_list') # Redirect to the participants list after success
success_message = 'Participant deleted successfully.'
slug_url_kwarg = 'slug'
slug_url_kwarg = 'slug'

View File

@ -122,6 +122,11 @@
</ul>
</li>
{% endif %} {% endcomment %}
<li class="nav-item me-2">
<a class="nav-link" href="{% url 'message_list' %}">
<i class="fas fa-envelope"></i>
</a>
</li>
<li class="nav-item dropdown">
<button
@ -237,7 +242,15 @@
<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" %}
{% trans "Applicants" %}
{% trans "Applications" %}
</span>
</a>
</li>
<li class="nav-item me-lg-4">
<a class="nav-link {% if request.resolver_match.url_name == 'person_list' %}active{% endif %}" href="{% url 'person_list' %}">
<span class="d-flex align-items-center gap-2">
{% include "icons/users.html" %}
{% trans "Person" %}
</span>
</a>
</li>
@ -340,6 +353,7 @@
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js"></script>
<script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.6/bundles/datastar.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js" integrity="sha512-v2CJ7UaYy4JwqLDIrZUI/4hqeoQieOmAZNXBeQyjo21dadnwR+8ZaIJVT8EE2iyI61OV8e6M8PP2/4hpQINQ/g==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Navbar collapse auto-close on link click (Standard Mobile UX)
@ -404,6 +418,23 @@
</script>
<!-- Message Count JavaScript -->
<script>
document.addEventListener('DOMContentLoaded', function() {
// Update unread message count on page load
fetch('/api/unread-count/')
.then(response => response.json())
.then(data => {
const badge = document.getElementById('unread-messages-badge');
if (badge && data.unread_count > 0) {
badge.textContent = data.unread_count;
badge.style.display = 'inline-block';
}
})
.catch(error => console.error('Error fetching unread count:', error));
});
</script>
<!-- Notification JavaScript for Admin Users -->
{% comment %} {% if request.user.is_authenticated and request.user.is_staff %}
<script>

View File

@ -0,0 +1,149 @@
{% load static %}
{% load file_filters %}
<div class="card shadow-sm">
<div class="card-header bg-white border-bottom d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0 text-primary">Documents</h5>
<button
type="button"
class="btn bg-primary-theme text-white btn-sm"
data-bs-toggle="modal"
data-bs-target="#documentUploadModal"
>
<i class="fas fa-plus me-2"></i>Upload Document
</button>
</div>
<!-- Document Upload Modal -->
<div class="modal fade" id="documentUploadModal" tabindex="-1" aria-labelledby="documentUploadModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="documentUploadModalLabel">Upload Document</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form
method="post"
enctype="multipart/form-data"
hx-post="{% url 'document_upload' candidate.id %}"
hx-target="#documents-pane"
hx-select="#documents-pane"
hx-swap="outerHTML"
hx-on::after-request="bootstrap.Modal.getInstance(document.getElementById('documentUploadModal')).hide()"
>
{% csrf_token %}
<div class="modal-body">
<div class="mb-3">
<label for="documentType" class="form-label">Document Type</label>
<select name="document_type" id="documentType" class="form-select">
<option value="resume">Resume</option>
<option value="cover_letter">Cover Letter</option>
<option value="portfolio">Portfolio</option>
<option value="certificate">Certificate</option>
<option value="id_proof">ID Proof</option>
<option value="other">Other</option>
</select>
</div>
<div class="mb-3">
<label for="documentFile" class="form-label">File</label>
<input
type="file"
name="file"
id="documentFile"
class="form-control"
required
>
</div>
<div class="mb-3">
<label for="documentDescription" class="form-label">Description</label>
<textarea
name="description"
id="documentDescription"
rows="3"
class="form-control"
placeholder="Optional description..."
></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">
<i class="fas fa-upload me-2"></i>Upload
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Documents List -->
<div class="card-body" id="document-list-container">
{% if documents %}
{% for document in documents %}
<div class="d-flex justify-content-between align-items-center p-3 border-bottom hover-bg-light">
<div class="d-flex align-items-center">
<i class="fas fa-file text-primary me-3"></i>
<div>
<div class="fw-medium text-dark">{{ document.get_document_type_display }}</div>
<div class="small text-muted">{{ document.file.name|filename }}</div>
{% if document.description %}
<div class="small text-muted">{{ document.description }}</div>
{% endif %}
<div class="small text-muted">
Uploaded by {{ document.uploaded_by.get_full_name|default:document.uploaded_by.username }} on {{ document.created_at|date:"M d, Y" }}
</div>
</div>
</div>
<div class="d-flex align-items-center">
<a
href="{% url 'document_download' document.id %}"
class="btn btn-sm btn-outline-primary me-2"
title="Download"
>
<i class="fas fa-download"></i>
</a>
{% if user.is_superuser or candidate.job.assigned_to == user %}
<button
type="button"
class="btn btn-sm btn-outline-danger"
onclick="confirmDelete({{ document.id }}, '{{ document.file.name|filename|default:"Document" }}')"
title="Delete"
>
<i class="fas fa-trash"></i>
</button>
{% endif %}
</div>
</div>
{% endfor %}
{% else %}
<div class="text-center py-5 text-muted">
<i class="fas fa-file-alt fa-3x mb-3"></i>
<p class="mb-2">No documents uploaded yet.</p>
<p class="small">Click "Upload Document" to add files for this candidate.</p>
</div>
{% endif %}
</div>
</div>
<style>
.hover-bg-light:hover {
background-color: #f8f9fa;
transition: background-color 0.2s ease;
}
</style>
<script>
function confirmDelete(documentId, fileName) {
if (confirm(`Are you sure you want to delete "${fileName}"?`)) {
htmx.ajax('POST', `{% url 'document_delete' 0 %}`.replace('0', documentId), {
target: '#document-list-container',
swap: 'innerHTML'
});
}
}
</script>

View File

@ -4,24 +4,24 @@
{# Helper to build the query string while excluding the 'page' parameter #}
{% load url_extras %}
{# Build a string of all current filters (e.g., &department=IT&type=FULL_TIME) #}
{% add_get_params request.GET as filter_params %}
{% with filter_params=filter_params %}
{% if page_obj.has_previous %}
{# First Page Link #}
<li class="page-item">
<a class="page-link text-primary-theme"
<a class="page-link text-primary-theme"
href="?page=1{{ filter_params }}">
First
</a>
</li>
{# Previous Page Link #}
<li class="page-item">
<a class="page-link text-primary-theme"
<a class="page-link text-primary-theme"
href="?page={{ page_obj.previous_page_number }}{{ filter_params }}">
Previous
</a>
@ -36,26 +36,26 @@
</li>
{% if page_obj.has_next %}
{# Next Page Link #}
<li class="page-item">
<a class="page-link text-primary-theme"
<a class="page-link text-primary-theme"
href="?page={{ page_obj.next_page_number }}{{ filter_params }}">
Next
</a>
</li>
{# Last Page Link #}
<li class="page-item">
<a class="page-link text-primary-theme"
<a class="page-link text-primary-theme"
href="?page={{ page_obj.paginator.num_pages }}{{ filter_params }}">
Last
</a>
</li>
{% endif %}
{% endwith %}
</ul>
</nav>
{% endif %}

View File

@ -65,7 +65,7 @@
padding: 10px 15px;
background-color: var(--kaauh-teal-light);
}
/* FullCalendar Customization */
#calendar {
font-size: 0.9em;
@ -87,7 +87,7 @@
{% block content %}
<div class="container py-5">
<div class="d-flex justify-content-between align-items-center mb-5">
<h1 class="h3 page-header">
<i class="fas fa-calendar-alt me-2 text-primary-theme"></i> Interview Schedule Preview: **{{ job.title }}**
@ -98,13 +98,13 @@
<div class="card-body p-4 p-lg-5">
<h4 class="card-title-border">{% trans "Schedule Parameters" %}</h4>
<div class="row g-4">
<div class="col-md-6">
<p class="mb-2"><strong><i class="fas fa-clock me-2 text-primary-theme"></i> Working Hours:</strong> {{ start_time|time:"g:i A" }} to {{ end_time|time:"g:i A" }}</p>
<p class="mb-2"><strong><i class="fas fa-hourglass-half me-2 text-primary-theme"></i> Interview Duration:</strong> {{ interview_duration }} minutes</p>
<p class="mb-2"><strong><i class="fas fa-shield-alt me-2 text-primary-theme"></i> Buffer Time:</strong> {{ buffer_time }} minutes</p>
</div>
<div class="col-md-6">
<p class="mb-2"><strong><i class="fas fa-calendar-day me-2 text-primary-theme"></i> Interview Period:</strong> {{ start_date|date:"F j, Y" }} &mdash; {{ end_date|date:"F j, Y" }}</p>
<p class="mb-2"><strong><i class="fas fa-list-check me-2 text-primary-theme"></i> Active Days:</strong>
@ -122,7 +122,7 @@
<p class="mb-2"><strong><i class="fas fa-calendar-day me-2 text-primary-theme"></i> Interview Type:</strong> {{interview_type}}</p>
</div>
</div>
<h5 class="mt-4 pt-3 border-top">{% trans "Daily Break Times" %}</h5>
{% if breaks %}
<div class="d-flex flex-wrap gap-3 mt-3">
@ -162,9 +162,9 @@
{% for item in schedule %}
<tr>
<td>{{ item.date|date:"F j, Y" }}</td>
<td class="fw-bold text-primary-theme">{{ item.time|time:"g:i A" }}</td>
<td>{{ item.candidate.name }}</td>
<td>{{ item.candidate.email }}</td>
<td>{{ item.time|time:"g:i A" }}</td>
<td>{{ item.applications.name }}</td>
<td>{{ item.applications.email }}</td>
</tr>
{% endfor %}
</tbody>
@ -204,7 +204,7 @@ document.addEventListener('DOMContentLoaded', function() {
start: '{{ item.date|date:"Y-m-d" }}T{{ item.time|time:"H:i:s" }}',
url: '#',
// Use the theme color for candidate events
color: 'var(--kaauh-teal-dark)',
color: 'var(--kaauh-teal-dark)',
extendedProps: {
email: '{{ item.candidate.email }}',
time: '{{ item.time|time:"g:i A" }}'
@ -214,7 +214,7 @@ document.addEventListener('DOMContentLoaded', function() {
{% for break in breaks %}
{
title: 'Break',
// FullCalendar requires a specific date for breaks, using start_date as a placeholder for daily breaks.
// FullCalendar requires a specific date for breaks, using start_date as a placeholder for daily breaks.
// Note: Breaks displayed on the monthly grid will only show on start_date, but weekly/daily view should reflect it daily if implemented correctly in the backend or using recurring events.
start: '{{ start_date|date:"Y-m-d" }}T{{ break.start_time|time:"H:i:s" }}',
end: '{{ start_date|date:"Y-m-d" }}T{{ break.end_time|time:"H:i:s" }}',

View File

@ -130,9 +130,9 @@
<label for="{{ form.candidates.id_for_label }}">
{% trans "Candidates to Schedule (Hold Ctrl/Cmd to select multiple)" %}
</label>
{{ form.candidates }}
{% if form.candidates.errors %}
<div class="text-danger small mt-1">{{ form.candidates.errors }}</div>
{{ form.applications }}
{% if form.applications.errors %}
<div class="text-danger small mt-1">{{ form.applications.errors }}</div>
{% endif %}
</div>
</div>

View File

@ -285,7 +285,7 @@
<th style="width: calc(50% / 7);">{% trans "Offer" %}</th>
</tr>
</thead>
<tbody>
{% for job in jobs %}
<tr>

View File

@ -318,7 +318,7 @@
<td><strong class="text-primary"><a href="{% url 'meeting_details' meeting.slug %}" class="text-decoration-none text-secondary">{{ meeting.topic }}<a></strong></td>
<td>
{% if meeting.interview %}
<a class="text-primary text-decoration-none" href="{% url 'candidate_detail' meeting.interview.candidate.slug %}">{{ meeting.interview.candidate.name }} <i class="fas fa-link"></i></a>
<a class="text-primary text-decoration-none" href="{% url 'candidate_detail' meeting.interview.application.slug %}">{{ meeting.interview.candidate.name }} <i class="fas fa-link"></i></a>
{% else %}
<button data-bs-toggle="modal"
data-bs-target="#meetingModal"

View File

@ -249,9 +249,9 @@ body {
<div class="p-3 bg-white rounded shadow-sm h-100 d-flex flex-column">
<h2 class="text-start"><i class="fas fa-briefcase me-2"></i> {% trans "Interview Detail" %}</h2>
<div class="detail-row-group flex-grow-1">
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Job Title" %}:</div><div class="detail-value-simple"><a class="text-decoration-none text-dark" href="{% url 'job_detail' meeting.get_job.slug %}">{{ meeting.get_job.title|default:"N/A" }}</a></div></div>
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Candidate Name" %}:</div><div class="detail-value-simple"><a class="text-decoration-none text-dark" href="{% url 'candidate_detail' meeting.get_candidate.slug %}">{{ meeting.get_candidate.name|default:"N/A" }}</a></div></div>
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Candidate Email" %}:</div><div class="detail-value-simple"><a class="text-decoration-none text-dark" href="{% url 'candidate_detail' meeting.get_candidate.slug %}">{{ meeting.get_candidate.email|default:"N/A" }}</a></div></div>
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Job Title" %}:</div><div class="detail-value-simple">{{ meeting.get_job.title|default:"N/A" }}</div></div>
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Candidate Name" %}:</div><div class="detail-value-simple"><a href="">{{ meeting.candidate_full_name|default:"N/A" }}</a></div></div>
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Candidate Email" %}:</div><div class="detail-value-simple"><a href="">{{ meeting.get_candidate.email|default:"N/A" }}</a></div></div>
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Job Type" %}:</div><div class="detail-value-simple">{{ meeting.get_job.job_type|default:"N/A" }}</div></div>
{% if meeting.get_candidate.belong_to_agency %}
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Agency" %}:</div><div class="detail-value-simple"><a href="">{{ meeting.get_candidate.hiring_agency.name|default:"N/A" }}</a></div></div>
@ -471,15 +471,15 @@ body {
<form method="post" action="{% url 'create_interview_participants' meeting.interview.slug %}">
{% csrf_token %}
<div class="modal-body table-responsive">
{{ meeting.name }}
<hr>
<table class="table tab table-bordered mt-3">
<thead>
<th class="col">👥 {% trans "Participants" %}</th>
@ -487,7 +487,7 @@ body {
</thead>
<tbody>
<tr>
<td>
{{ form.participants.errors }}
@ -498,7 +498,7 @@ body {
</td>
</tr>
</table>
</div>
@ -525,7 +525,7 @@ body {
<form method="post" action="{% url 'send_interview_email' meeting.interview.slug %}">
{% csrf_token %}
<div class="modal-body">
<div class="mb-3">
<label for="{{ email_form.subject.id_for_label }}" class="form-label fw-bold">Subject</label>
{{ email_form.subject | add_class:"form-control" }}
@ -551,18 +551,18 @@ body {
</ul>
<div class="tab-content border border-top-0 p-3 bg-light-subtle">
{# --- Candidate/Agency Pane --- #}
<div class="tab-pane fade show active" id="candidate-pane" role="tabpanel" aria-labelledby="candidate-tab">
<p class="text-muted small">{% trans "This email will be sent to the candidate or their hiring agency." %}</p>
{% if not candidate.belong_to_an_agency %}
<div class="form-group">
<label for="{{ email_form.message_for_candidate.id_for_label }}" class="form-label d-none">{% trans "Candidate Message" %}</label>
{{ email_form.message_for_candidate | add_class:"form-control" }}
</div>
{% endif %}
{% if candidate.belong_to_an_agency %}
<div class="form-group">
<label for="{{ email_form.message_for_agency.id_for_label }}" class="form-label d-none">{% trans "Agency Message" %}</label>

View File

@ -0,0 +1,179 @@
{% extends "base.html" %}
{% load static %}
{% block title %}{{ message.subject }}{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row">
<div class="col-12">
<!-- Message Header -->
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">
{{ message.subject }}
{% if message.parent_message %}
<span class="badge bg-secondary ms-2">Reply</span>
{% endif %}
</h5>
<div class="btn-group" role="group">
<a href="{% url 'message_reply' message.id %}" class="btn btn-outline-info">
<i class="fas fa-reply"></i> Reply
</a>
{% if message.recipient == request.user %}
<a href="{% url 'message_mark_unread' message.id %}"
class="btn btn-outline-warning"
hx-post="{% url 'message_mark_unread' message.id %}">
<i class="fas fa-envelope"></i> Mark Unread
</a>
{% endif %}
<a href="{% url 'message_delete' message.id %}"
class="btn btn-outline-danger"
hx-get="{% url 'message_delete' message.id %}"
hx-confirm="Are you sure you want to delete this message?">
<i class="fas fa-trash"></i> Delete
</a>
<a href="{% url 'message_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left"></i> Back to Messages
</a>
</div>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-6">
<strong>From:</strong>
<span class="text-primary">{{ message.sender.get_full_name|default:message.sender.username }}</span>
</div>
<div class="col-md-6">
<strong>To:</strong>
<span class="text-primary">{{ message.recipient.get_full_name|default:message.recipient.username }}</span>
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<strong>Type:</strong>
<span class="badge bg-{{ message.message_type|lower }}">
{{ message.get_message_type_display }}
</span>
</div>
<div class="col-md-6">
<strong>Status:</strong>
{% if message.is_read %}
<span class="badge bg-success">Read</span>
{% if message.read_at %}
<small class="text-muted">({{ message.read_at|date:"M d, Y H:i" }})</small>
{% endif %}
{% else %}
<span class="badge bg-warning">Unread</span>
{% endif %}
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<strong>Created:</strong>
<span>{{ message.created_at|date:"M d, Y H:i" }}</span>
</div>
{% if message.job %}
<div class="col-md-6">
<strong>Related Job:</strong>
<a href="{% url 'job_detail' message.job.slug %}" class="text-primary">
{{ message.job.title }}
</a>
</div>
{% endif %}
</div>
{% if message.parent_message %}
<div class="alert alert-info">
<strong>In reply to:</strong>
<a href="{% url 'message_detail' message.parent_message.id %}">
{{ message.parent_message.subject }}
</a>
<small class="text-muted d-block">
From {{ message.parent_message.sender.get_full_name|default:message.parent_message.sender.username }}
on {{ message.parent_message.created_at|date:"M d, Y H:i" }}
</small>
</div>
{% endif %}
</div>
</div>
<!-- Message Content -->
<div class="card">
<div class="card-header">
<h6 class="mb-0">Message</h6>
</div>
<div class="card-body">
<div class="message-content">
{{ message.content|linebreaks }}
</div>
</div>
</div>
<!-- Message Thread (if this is a reply and has replies) -->
{% if message.replies.all %}
<div class="card mt-4">
<div class="card-header">
<h6 class="mb-0">
<i class="fas fa-comments"></i> Replies ({{ message.replies.count }})
</h6>
</div>
<div class="card-body">
{% for reply in message.replies.all %}
<div class="border-start ps-3 mb-3">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<strong>{{ reply.sender.get_full_name|default:reply.sender.username }}</strong>
<small class="text-muted ms-2">
{{ reply.created_at|date:"M d, Y H:i" }}
</small>
</div>
<span class="badge bg-{{ reply.message_type|lower }}">
{{ reply.get_message_type_display }}
</span>
</div>
<div class="reply-content">
{{ reply.content|linebreaks }}
</div>
<div class="mt-2">
<a href="{% url 'message_reply' reply.id %}" class="btn btn-sm btn-outline-info">
<i class="fas fa-reply"></i> Reply to this
</a>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}
{% block extra_css %}
<style>
.message-content {
white-space: pre-wrap;
word-wrap: break-word;
line-height: 1.6;
padding: 1rem;
background-color: #f8f9fa;
border-radius: 0.375rem;
border: 1px solid #dee2e6;
}
.reply-content {
white-space: pre-wrap;
word-wrap: break-word;
line-height: 1.5;
font-size: 0.9rem;
}
.border-start {
border-left: 3px solid #0d6efd;
}
.ps-3 {
padding-left: 1rem;
}
</style>
{% endblock %}

View File

@ -0,0 +1,237 @@
{% extends "base.html" %}
{% load static %}
{% block title %}{% if form.instance.pk %}Reply to Message{% else %}Compose Message{% endif %}{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
{% if form.instance.pk %}
<i class="fas fa-reply"></i> Reply to Message
{% else %}
<i class="fas fa-envelope"></i> Compose Message
{% endif %}
</h5>
</div>
<div class="card-body">
{% if form.instance.parent_message %}
<div class="alert alert-info mb-4">
<strong>Replying to:</strong> {{ form.instance.parent_message.subject }}
<br>
<small class="text-muted">
From {{ form.instance.parent_message.sender.get_full_name|default:form.instance.parent_message.sender.username }}
on {{ form.instance.parent_message.created_at|date:"M d, Y H:i" }}
</small>
<div class="mt-2">
<strong>Original message:</strong>
<div class="border-start ps-3 mt-2">
{{ form.instance.parent_message.content|linebreaks }}
</div>
</div>
</div>
{% endif %}
<form method="post" id="messageForm">
{% csrf_token %}
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="{{ form.recipient.id_for_label }}" class="form-label">
Recipient <span class="text-danger">*</span>
</label>
{{ form.recipient }}
{% if form.recipient.errors %}
<div class="text-danger small mt-1">
{{ form.recipient.errors.0 }}
</div>
{% endif %}
<div class="form-text">
Select the user who will receive this message
</div>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="{{ form.message_type.id_for_label }}" class="form-label">
Message Type <span class="text-danger">*</span>
</label>
{{ form.message_type }}
{% if form.message_type.errors %}
<div class="text-danger small mt-1">
{{ form.message_type.errors.0 }}
</div>
{% endif %}
<div class="form-text">
Select the type of message you're sending
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="{{ form.subject.id_for_label }}" class="form-label">
Subject <span class="text-danger">*</span>
</label>
{{ form.subject }}
{% if form.subject.errors %}
<div class="text-danger small mt-1">
{{ form.subject.errors.0 }}
</div>
{% endif %}
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="{{ form.job.id_for_label }}" class="form-label">
Related Job
</label>
{{ form.job }}
{% if form.job.errors %}
<div class="text-danger small mt-1">
{{ form.job.errors.0 }}
</div>
{% endif %}
<div class="form-text">
Optional: Select a job if this message is related to a specific position
</div>
</div>
</div>
</div>
<div class="mb-3">
<label for="{{ form.content.id_for_label }}" class="form-label">
Message <span class="text-danger">*</span>
</label>
{{ form.content }}
{% if form.content.errors %}
<div class="text-danger small mt-1">
{{ form.content.errors.0 }}
</div>
{% endif %}
<div class="form-text">
Write your message here. You can use line breaks and basic formatting.
</div>
</div>
<div class="d-flex justify-content-between">
<a href="{% url 'message_list' %}" class="btn btn-secondary">
<i class="fas fa-times"></i> Cancel
</a>
<button type="submit" class="btn btn-primary">
<i class="fas fa-paper-plane"></i>
{% if form.instance.pk %}
Send Reply
{% else %}
Send Message
{% endif %}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_css %}
<style>
#id_content {
min-height: 200px;
resize: vertical;
}
.form-select {
{% if form.recipient.field.widget.attrs.disabled %}
background-color: #f8f9fa;
{% endif %}
}
.border-start {
border-left: 3px solid #0d6efd;
}
.ps-3 {
padding-left: 1rem;
}
</style>
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Auto-resize textarea based on content
const textarea = document.getElementById('id_content');
if (textarea) {
textarea.addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = (this.scrollHeight) + 'px';
});
// Set initial height
textarea.style.height = 'auto';
textarea.style.height = (textarea.scrollHeight) + 'px';
}
// Character counter for subject
const subjectField = document.getElementById('id_subject');
const maxLength = 200;
if (subjectField) {
// Add character counter display
const counter = document.createElement('small');
counter.className = 'text-muted';
counter.style.float = 'right';
subjectField.parentNode.appendChild(counter);
function updateCounter() {
const remaining = maxLength - subjectField.value.length;
counter.textContent = `${subjectField.value.length}/${maxLength} characters`;
if (remaining < 20) {
counter.className = 'text-warning';
} else {
counter.className = 'text-muted';
}
}
subjectField.addEventListener('input', updateCounter);
updateCounter();
}
// Form validation before submit
const form = document.getElementById('messageForm');
if (form) {
form.addEventListener('submit', function(e) {
const content = document.getElementById('id_content').value.trim();
const subject = document.getElementById('id_subject').value.trim();
const recipient = document.getElementById('id_recipient').value;
if (!recipient) {
e.preventDefault();
alert('Please select a recipient.');
return false;
}
if (!subject) {
e.preventDefault();
alert('Please enter a subject.');
return false;
}
if (!content) {
e.preventDefault();
alert('Please enter a message.');
return false;
}
});
}
});
</script>
{% endblock %}

View File

@ -0,0 +1,230 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Messages{% 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">
<h4 class="mb-0">Messages</h4>
<a href="{% url 'message_create' %}" class="btn btn-main-action">
<i class="fas fa-plus"></i> Compose Message
</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 Status</option>
<option value="read" {% if status_filter == 'read' %}selected{% endif %}>Read</option>
<option value="unread" {% if status_filter == 'unread' %}selected{% endif %}>Unread</option>
</select>
</div>
<div class="col-md-3">
<label for="type" class="form-label">Type</label>
<select name="type" id="type" class="form-select">
<option value="">All Types</option>
<option value="GENERAL" {% if type_filter == 'GENERAL' %}selected{% endif %}>General</option>
<option value="JOB_RELATED" {% if type_filter == 'JOB_RELATED' %}selected{% endif %}>Job Related</option>
<option value="INTERVIEW" {% if type_filter == 'INTERVIEW' %}selected{% endif %}>Interview</option>
<option value="OFFER" {% if type_filter == 'OFFER' %}selected{% endif %}>Offer</option>
</select>
</div>
<div class="col-md-4">
<label for="q" class="form-label">Search</label>
<div class="input-group">
<input type="text" name="q" id="q" class="form-control"
value="{{ search_query }}" placeholder="Search messages...">
<button class="btn btn-outline-secondary" type="submit">
<i class="fas fa-search"></i>
</button>
</div>
</div>
<div class="col-md-2">
<label class="form-label">&nbsp;</label>
<button type="submit" class="btn btn-secondary w-100">Filter</button>
</div>
</form>
</div>
</div>
<!-- Statistics -->
<div class="row mb-3">
<div class="col-md-6">
<div class="card bg-light">
<div class="card-body">
<h6 class="card-title">Total Messages</h6>
<h3 class="text-primary">{{ total_messages }}</h3>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card bg-light">
<div class="card-body">
<h6 class="card-title">Unread Messages</h6>
<h3 class="text-warning">{{ unread_messages }}</h3>
</div>
</div>
</div>
</div>
<!-- Messages List -->
<div class="card">
<div class="card-body">
{% if page_obj %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Subject</th>
<th>Sender</th>
<th>Recipient</th>
<th>Type</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for message in page_obj %}
<tr class="{% if not message.is_read %}table-warning{% endif %}">
<td>
<a href="{% url 'message_detail' message.id %}"
class="{% if not message.is_read %}fw-bold{% endif %}">
{{ message.subject }}
</a>
{% if message.parent_message %}
<span class="badge bg-secondary ms-2">Reply</span>
{% endif %}
</td>
<td>{{ message.sender.get_full_name|default:message.sender.username }}</td>
<td>{{ message.recipient.get_full_name|default:message.recipient.username }}</td>
<td>
<span>
{{ message.get_message_type_display }}
</span>
</td>
<td>
{% if message.is_read %}
<span class="badge bg-primary-theme">Read</span>
{% else %}
<span class="badge bg-warning">Unread</span>
{% endif %}
</td>
<td>{{ message.created_at|date:"M d, Y H:i" }}</td>
<td>
<div class="btn-group" role="group">
<a href="{% url 'message_detail' message.id %}"
class="btn btn-sm btn-outline-primary" title="View">
<i class="fas fa-eye"></i>
</a>
{% if not message.is_read and message.recipient == request.user %}
<a href="{% url 'message_mark_read' message.id %}"
class="btn btn-sm btn-outline-success"
hx-post="{% url 'message_mark_read' message.id %}"
title="Mark as Read">
<i class="fas fa-check"></i>
</a>
{% endif %}
<a href="{% url 'message_reply' message.id %}"
class="btn btn-sm btn-outline-primary" title="Reply">
<i class="fas fa-reply"></i>
</a>
<a href="{% url 'message_delete' message.id %}"
class="btn btn-sm btn-outline-danger"
hx-get="{% url 'message_delete' message.id %}"
hx-confirm="Are you sure you want to delete this message?"
title="Delete">
<i class="fas fa-trash"></i>
</a>
</div>
</td>
</tr>
{% empty %}
<tr>
<td colspan="7" class="text-center text-muted">
<i class="fas fa-inbox fa-3x mb-3"></i>
<p class="mb-0">No messages found.</p>
<p class="small">Try adjusting your filters or compose a new message.</p>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if page_obj.has_other_pages %}
<nav aria-label="Message pagination">
<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 }}&status={{ status_filter }}&type={{ type_filter }}&q={{ search_query }}">
<i class="fas fa-chevron-left"></i>
</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>
{% else %}
<li class="page-item">
<a class="page-link" href="?page={{ num }}&status={{ status_filter }}&type={{ type_filter }}&q={{ 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 }}&status={{ status_filter }}&type={{ type_filter }}&q={{ search_query }}">
<i class="fas fa-chevron-right"></i>
</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
<div class="text-center text-muted py-5">
<i class="fas fa-inbox fa-3x mb-3"></i>
<p class="mb-0">No messages found.</p>
<p class="small">Try adjusting your filters or compose a new message.</p>
<a href="{% url 'message_create' %}" class="btn btn-main-action">
<i class="fas fa-plus"></i> Compose Message
</a>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
// Auto-refresh unread count every 30 seconds
setInterval(() => {
fetch('/api/unread-count/')
.then(response => response.json())
.then(data => {
// Update unread count in navigation if it exists
const unreadBadge = document.querySelector('.unread-messages-count');
if (unreadBadge) {
unreadBadge.textContent = data.unread_count;
unreadBadge.style.display = data.unread_count > 0 ? 'inline-block' : 'none';
}
})
.catch(error => console.error('Error fetching unread count:', error));
}, 30000);
</script>
{% endblock %}

View File

@ -0,0 +1,444 @@
{% extends "base.html" %}
{% load static i18n crispy_forms_tags %}
{% block title %}Create Person - {{ block.super }}{% endblock %}
{% block customCSS %}
<style>
/* UI Variables for the KAAT-S Theme */
:root {
--kaauh-teal: #00636e;
--kaauh-teal-dark: #004a53;
--kaauh-border: #eaeff3;
--kaauh-gray-light: #f8f9fa;
}
/* Form Container Styling */
.form-container {
max-width: 800px;
margin: 0 auto;
}
/* Card Styling */
.card {
border: 1px solid var(--kaauh-border);
border-radius: 0.75rem;
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
}
/* Main Action Button Style */
.btn-main-action {
background-color: var(--kaauh-teal);
border-color: var(--kaauh-teal);
color: white;
font-weight: 600;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 1.5rem;
}
.btn-main-action:hover {
background-color: var(--kaauh-teal-dark);
border-color: var(--kaauh-teal-dark);
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
/* Secondary Button Style */
.btn-outline-secondary {
color: var(--kaauh-teal-dark);
border-color: var(--kaauh-teal);
}
.btn-outline-secondary:hover {
background-color: var(--kaauh-teal-dark);
color: white;
border-color: var(--kaauh-teal-dark);
}
/* Form Field Styling */
.form-control:focus {
border-color: var(--kaauh-teal);
box-shadow: 0 0 0 0.2rem rgba(0, 99, 110, 0.25);
}
.form-select:focus {
border-color: var(--kaauh-teal);
box-shadow: 0 0 0 0.2rem rgba(0, 99, 110, 0.25);
}
/* Profile Image Upload Styling */
.profile-image-upload {
border: 2px dashed var(--kaauh-border);
border-radius: 0.5rem;
padding: 2rem;
text-align: center;
transition: all 0.3s ease;
cursor: pointer;
}
.profile-image-upload:hover {
border-color: var(--kaauh-teal);
background-color: var(--kaauh-gray-light);
}
.profile-image-preview {
width: 120px;
height: 120px;
object-fit: cover;
border-radius: 50%;
border: 3px solid var(--kaauh-teal);
margin: 0 auto 1rem;
}
/* Breadcrumb Styling */
.breadcrumb {
background-color: transparent;
padding: 0;
margin-bottom: 1rem;
}
.breadcrumb-item + .breadcrumb-item::before {
content: ">";
color: var(--kaauh-teal);
}
/* Alert Styling */
.alert {
border-radius: 0.5rem;
border: none;
}
/* Loading State */
.btn.loading {
position: relative;
pointer-events: none;
opacity: 0.8;
}
.btn.loading::after {
content: "";
position: absolute;
width: 16px;
height: 16px;
margin: auto;
border: 2px solid transparent;
border-top-color: #ffffff;
border-radius: 50%;
animation: spin 1s linear infinite;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="form-container">
<!-- Breadcrumb Navigation -->
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a href="{% url 'person_list' %}" class="text-decoration-none">
<i class="fas fa-user-friends me-1"></i> {% trans "People" %}
</a>
</li>
<li class="breadcrumb-item active" aria-current="page">{% trans "Create Person" %}</li>
</ol>
</nav>
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 style="color: var(--kaauh-teal-dark); font-weight: 700;">
<i class="fas fa-user-plus me-2"></i> {% trans "Create New Person" %}
</h1>
<a href="{% url 'person_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to List" %}
</a>
</div>
<!-- Form Card -->
<div class="card shadow-sm">
<div class="card-body p-4">
{% if form.non_field_errors %}
<div class="alert alert-danger" role="alert">
<h5 class="alert-heading">
<i class="fas fa-exclamation-triangle me-2"></i>{% trans "Error" %}
</h5>
{% for error in form.non_field_errors %}
<p class="mb-0">{{ error }}</p>
{% endfor %}
</div>
{% endif %}
<form method="post" enctype="multipart/form-data" id="person-form">
{% csrf_token %}
<!-- Profile Image Section -->
<div class="row mb-4">
<div class="col-12">
<div class="profile-image-upload" onclick="document.getElementById('id_profile_image').click()">
<div id="image-preview-container">
<i class="fas fa-camera fa-3x text-muted mb-3"></i>
<h5 class="text-muted">{% trans "Upload Profile Photo" %}</h5>
<p class="text-muted small">{% trans "Click to browse or drag and drop" %}</p>
</div>
<input type="file" name="profile_image" id="id_profile_image"
class="d-none" accept="image/*">
</div>
</div>
</div>
<!-- Personal Information Section -->
<div class="row mb-4">
<div class="col-12">
<h5 class="mb-3" style="color: var(--kaauh-teal-dark); border-bottom: 2px solid var(--kaauh-teal); padding-bottom: 0.5rem;">
<i class="fas fa-user me-2"></i> {% trans "Personal Information" %}
</h5>
</div>
<div class="col-md-4">
{{ form.first_name|as_crispy_field }}
</div>
<div class="col-md-4">
{{ form.middle_name|as_crispy_field }}
</div>
<div class="col-md-4">
{{ form.last_name|as_crispy_field }}
</div>
</div>
<!-- Contact Information Section -->
<div class="row mb-4">
<div class="col-12">
<h5 class="mb-3" style="color: var(--kaauh-teal-dark); border-bottom: 2px solid var(--kaauh-teal); padding-bottom: 0.5rem;">
<i class="fas fa-envelope me-2"></i> {% trans "Contact Information" %}
</h5>
</div>
<div class="col-md-6">
{{ form.email|as_crispy_field }}
</div>
<div class="col-md-6">
{{ form.phone|as_crispy_field }}
</div>
</div>
<!-- Additional Information Section -->
<div class="row mb-4">
<div class="col-12">
<h5 class="mb-3" style="color: var(--kaauh-teal-dark); border-bottom: 2px solid var(--kaauh-teal); padding-bottom: 0.5rem;">
<i class="fas fa-info-circle me-2"></i> {% trans "Additional Information" %}
</h5>
</div>
<div class="col-md-4">
{{ form.date_of_birth|as_crispy_field }}
</div>
<div class="col-md-4">
{{ form.nationality|as_crispy_field }}
</div>
<div class="col-md-4">
{{ form.gender|as_crispy_field }}
</div>
</div>
<!-- Address Section -->
<div class="row mb-4">
<div class="col-12">
<h5 class="mb-3" style="color: var(--kaauh-teal-dark); border-bottom: 2px solid var(--kaauh-teal); padding-bottom: 0.5rem;">
<i class="fas fa-map-marker-alt me-2"></i> {% trans "Address" %}
</h5>
</div>
<div class="col-12">
{{ form.address }}
</div>
</div>
<!-- LinkedIn Profile Section -->
{% comment %} <div class="row mb-4">
<div class="col-12">
<h5 class="mb-3" style="color: var(--kaauh-teal-dark); border-bottom: 2px solid var(--kaauh-teal); padding-bottom: 0.5rem;">
<i class="fab fa-linkedin me-2"></i> {% trans "Professional Profile" %}
</h5>
</div>
<div class="col-12">
<div class="form-group mb-3">
<label for="id_linkedin_profile" class="form-label">
{% trans "LinkedIn Profile URL" %}
</label>
<input type="url" name="linkedin_profile" id="id_linkedin_profile"
class="form-control" placeholder="https://linkedin.com/in/username">
<small class="form-text text-muted">
{% trans "Optional: Add LinkedIn profile URL" %}
</small>
</div>
</div>
</div> {% endcomment %}
<!-- Form Actions -->
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<a href="{% url 'person_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-times me-1"></i> {% trans "Cancel" %}
</a>
<div class="d-flex gap-2">
<button type="reset" class="btn btn-outline-secondary">
<i class="fas fa-undo me-1"></i> {% trans "Reset" %}
</button>
<button type="submit" class="btn btn-main-action">
<i class="fas fa-save me-1"></i> {% trans "Create Person" %}
</button>
</div>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% block customJS %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Profile Image Preview
const profileImageInput = document.getElementById('id_profile_image');
const imagePreviewContainer = document.getElementById('image-preview-container');
profileImageInput.addEventListener('change', function(e) {
const file = e.target.files[0];
if (file && file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = function(e) {
imagePreviewContainer.innerHTML = `
<img src="${e.target.result}" alt="Profile Preview" class="profile-image-preview">
<h5 class="text-muted mt-3">${file.name}</h5>
<p class="text-muted small">{% trans "Click to change photo" %}</p>
`;
};
reader.readAsDataURL(file);
}
});
// Form Validation
const form = document.getElementById('person-form');
form.addEventListener('submit', function(e) {
const submitBtn = form.querySelector('button[type="submit"]');
submitBtn.classList.add('loading');
submitBtn.disabled = true;
// Basic validation
const firstName = document.getElementById('id_first_name').value.trim();
const lastName = document.getElementById('id_last_name').value.trim();
const email = document.getElementById('id_email').value.trim();
if (!firstName || !lastName) {
e.preventDefault();
submitBtn.classList.remove('loading');
submitBtn.disabled = false;
alert('{% trans "First name and last name are required." %}');
return;
}
if (email && !isValidEmail(email)) {
e.preventDefault();
submitBtn.classList.remove('loading');
submitBtn.disabled = false;
alert('{% trans "Please enter a valid email address." %}');
return;
}
});
// Email validation helper
function isValidEmail(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
// LinkedIn URL validation
const linkedinInput = document.getElementById('id_linkedin_profile');
linkedinInput.addEventListener('blur', function() {
const value = this.value.trim();
if (value && !isValidLinkedInURL(value)) {
this.classList.add('is-invalid');
if (!this.nextElementSibling || !this.nextElementSibling.classList.contains('invalid-feedback')) {
const feedback = document.createElement('div');
feedback.className = 'invalid-feedback';
feedback.textContent = '{% trans "Please enter a valid LinkedIn URL" %}';
this.parentNode.appendChild(feedback);
}
} else {
this.classList.remove('is-invalid');
const feedback = this.parentNode.querySelector('.invalid-feedback');
if (feedback) feedback.remove();
}
});
function isValidLinkedInURL(url) {
const linkedinRegex = /^https?:\/\/(www\.)?linkedin\.com\/.+/i;
return linkedinRegex.test(url);
}
// Drag and Drop functionality
const uploadArea = document.querySelector('.profile-image-upload');
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
uploadArea.addEventListener(eventName, preventDefaults, false);
});
function preventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
['dragenter', 'dragover'].forEach(eventName => {
uploadArea.addEventListener(eventName, highlight, false);
});
['dragleave', 'drop'].forEach(eventName => {
uploadArea.addEventListener(eventName, unhighlight, false);
});
function highlight(e) {
uploadArea.style.borderColor = 'var(--kaauh-teal)';
uploadArea.style.backgroundColor = 'var(--kaauh-gray-light)';
}
function unhighlight(e) {
uploadArea.style.borderColor = 'var(--kaauh-border)';
uploadArea.style.backgroundColor = 'transparent';
}
uploadArea.addEventListener('drop', handleDrop, false);
function handleDrop(e) {
const dt = e.dataTransfer;
const files = dt.files;
if (files.length > 0) {
profileImageInput.files = files;
const event = new Event('change', { bubbles: true });
profileImageInput.dispatchEvent(event);
}
}
});
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js" integrity="sha512-v2CJ7UaYy4JwqLDIrZUI/4hqeoQieOmAZNXBeQyjo21dadnwR+8ZaIJVT8EE2iyI61OV8e6M8PP2/4hpQINQ/g==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/select2-bootstrap-5-theme@1.3.0/dist/select2-bootstrap-5-theme.min.css" />
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
<script>
$(document).ready(function() {
$('.select2').select2();
});
</script>
{% endblock %}

View File

@ -0,0 +1,607 @@
{% extends "base.html" %}
{% load static i18n %}
{% block title %}{{ person.get_full_name }} - {{ block.super }}{% endblock %}
{% block customCSS %}
<style>
/* UI Variables for the KAAT-S Theme */
:root {
--kaauh-teal: #00636e;
--kaauh-teal-dark: #004a53;
--kaauh-border: #eaeff3;
--kaauh-gray-light: #f8f9fa;
}
/* Profile Header Styling */
.profile-header {
background: linear-gradient(135deg, var(--kaauh-teal) 0%, var(--kaauh-teal-dark) 100%);
color: white;
border-radius: 0.75rem;
padding: 2rem;
margin-bottom: 2rem;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.profile-image-large {
width: 150px;
height: 150px;
object-fit: cover;
border-radius: 50%;
border: 4px solid white;
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
}
/* Card Styling */
.card {
border: 1px solid var(--kaauh-border);
border-radius: 0.75rem;
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
transition: transform 0.2s, box-shadow 0.2s;
background-color: white;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0,0,0,0.1);
}
/* Main Action Button Style */
.btn-main-action {
background-color: var(--kaauh-teal);
border-color: var(--kaauh-teal);
color: white;
font-weight: 600;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 1rem;
}
.btn-main-action:hover {
background-color: var(--kaauh-teal-dark);
border-color: var(--kaauh-teal-dark);
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
/* Secondary Button Style */
.btn-outline-secondary {
color: var(--kaauh-teal-dark);
border-color: var(--kaauh-teal);
}
.btn-outline-secondary:hover {
background-color: var(--kaauh-teal-dark);
color: white;
border-color: var(--kaauh-teal-dark);
}
/* Info Section Styling */
.info-section {
margin-bottom: 1.5rem;
}
.info-section h5 {
color: var(--kaauh-teal-dark);
border-bottom: 2px solid var(--kaauh-teal);
padding-bottom: 0.5rem;
margin-bottom: 1rem;
font-weight: 600;
}
.info-item {
display: flex;
align-items: center;
margin-bottom: 0.75rem;
padding: 0.5rem;
border-radius: 0.25rem;
transition: background-color 0.2s;
}
.info-item:hover {
background-color: var(--kaauh-gray-light);
}
.info-item i {
color: var(--kaauh-teal);
width: 20px;
margin-right: 1rem;
font-size: 1.1rem;
}
.info-label {
font-weight: 600;
color: var(--kaauh-teal-dark);
min-width: 120px;
}
.info-value {
color: #495057;
flex-grow: 1;
}
/* Badge Styling */
.badge {
font-weight: 600;
padding: 0.4em 0.7em;
border-radius: 0.3rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Related Items Styling */
.related-item {
border-left: 3px solid var(--kaauh-teal);
padding-left: 1rem;
margin-bottom: 1rem;
transition: all 0.2s;
}
.related-item:hover {
border-left-color: var(--kaauh-teal-dark);
background-color: var(--kaauh-gray-light);
border-radius: 0.25rem;
}
/* Breadcrumb Styling */
.breadcrumb {
background-color: transparent;
padding: 0;
margin-bottom: 1rem;
}
.breadcrumb-item + .breadcrumb-item::before {
content: ">";
color: var(--kaauh-teal);
}
/* Empty State Styling */
.empty-state {
text-align: center;
padding: 2rem;
color: #6c757d;
}
.empty-state i {
font-size: 3rem;
color: var(--kaauh-teal);
opacity: 0.5;
margin-bottom: 1rem;
}
/* Status Indicator */
.status-indicator {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 0.5rem;
}
.status-active {
background-color: #28a745;
}
.status-inactive {
background-color: #dc3545;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.profile-header {
text-align: center;
}
.profile-image-large {
margin: 0 auto 1rem;
}
.info-item {
flex-direction: column;
align-items: flex-start;
}
.info-label {
min-width: auto;
margin-bottom: 0.25rem;
}
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<!-- Breadcrumb Navigation -->
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a href="{% url 'person_list' %}" class="text-decoration-none">
<i class="fas fa-user-friends me-1"></i> {% trans "People" %}
</a>
</li>
<li class="breadcrumb-item active" aria-current="page">{{ person.get_full_name }}</li>
</ol>
</nav>
<!-- Profile Header -->
<div class="profile-header">
<div class="row align-items-center">
<div class="col-md-3 text-center">
{% if person.profile_image %}
<img src="{{ person.profile_image.url }}" alt="{{ person.get_full_name }}"
class="profile-image-large">
{% else %}
<div class="profile-image-large d-flex align-items-center justify-content-center bg-white">
<i class="fas fa-user text-muted fa-3x"></i>
</div>
{% endif %}
</div>
<div class="col-md-9">
<h1 class="display-5 fw-bold mb-2">{{ person.get_full_name }}</h1>
{% if person.email %}
<p class="lead mb-3">
<i class="fas fa-envelope me-2"></i>{{ person.email }}
</p>
{% endif %}
<div class="d-flex flex-wrap gap-2 mb-3">
{% if person.nationality %}
<span class="badge bg-light text-dark">
<i class="fas fa-globe me-1"></i>{{ person.nationality }}
</span>
{% endif %}
{% if person.gender %}
<span class="badge bg-info">
{% if person.gender == 'M' %}{% trans "Male" %}{% else %}{% trans "Female" %}{% endif %}
</span>
{% endif %}
{% if person.user %}
<span class="badge bg-success">
<i class="fas fa-user-check me-1"></i>{% trans "User Account" %}
</span>
{% endif %}
</div>
{% if user.is_staff %}
<div class="d-flex gap-2">
<a href="{% url 'person_update' person.slug %}" class="btn btn-light">
<i class="fas fa-edit me-1"></i> {% trans "Edit Person" %}
</a>
<button type="button" class="btn btn-outline-light"
data-bs-toggle="modal" data-bs-target="#deleteModal"
data-delete-url="{% url 'person_delete' person.slug %}"
data-item-name="{{ person.get_full_name }}">
<i class="fas fa-trash-alt me-1"></i> {% trans "Delete" %}
</button>
</div>
{% endif %}
</div>
</div>
</div>
<div class="row">
<!-- Personal Information Column -->
<div class="col-lg-6 mb-4">
<div class="card h-100">
<div class="card-body">
<div class="info-section">
<h5><i class="fas fa-user me-2"></i>{% trans "Personal Information" %}</h5>
<div class="info-item">
<i class="fas fa-signature"></i>
<span class="info-label">{% trans "Full Name" %}:</span>
<span class="info-value">{{ person.get_full_name }}</span>
</div>
{% if person.first_name %}
<div class="info-item">
<i class="fas fa-user"></i>
<span class="info-label">{% trans "First Name" %}:</span>
<span class="info-value">{{ person.first_name }}</span>
</div>
{% endif %}
{% if person.middle_name %}
<div class="info-item">
<i class="fas fa-user"></i>
<span class="info-label">{% trans "Middle Name" %}:</span>
<span class="info-value">{{ person.middle_name }}</span>
</div>
{% endif %}
{% if person.last_name %}
<div class="info-item">
<i class="fas fa-user"></i>
<span class="info-label">{% trans "Last Name" %}:</span>
<span class="info-value">{{ person.last_name }}</span>
</div>
{% endif %}
{% if person.date_of_birth %}
<div class="info-item">
<i class="fas fa-birthday-cake"></i>
<span class="info-label">{% trans "Date of Birth" %}:</span>
<span class="info-value">{{ person.date_of_birth }}</span>
</div>
{% endif %}
{% if person.gender %}
<div class="info-item">
<i class="fas fa-venus-mars"></i>
<span class="info-label">{% trans "Gender" %}:</span>
<span class="info-value">
{% if person.gender == 'M' %}{% trans "Male" %}{% else %}{% trans "Female" %}{% endif %}
</span>
</div>
{% endif %}
{% if person.nationality %}
<div class="info-item">
<i class="fas fa-globe"></i>
<span class="info-label">{% trans "Nationality" %}:</span>
<span class="info-value">{{ person.nationality }}</span>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Contact Information Column -->
<div class="col-lg-6 mb-4">
<div class="card h-100">
<div class="card-body">
<div class="info-section">
<h5><i class="fas fa-address-book me-2"></i>{% trans "Contact Information" %}</h5>
{% if person.email %}
<div class="info-item">
<i class="fas fa-envelope"></i>
<span class="info-label">{% trans "Email" %}:</span>
<span class="info-value">
<a href="mailto:{{ person.email }}" class="text-decoration-none">
{{ person.email }}
</a>
</span>
</div>
{% endif %}
{% if person.phone %}
<div class="info-item">
<i class="fas fa-phone"></i>
<span class="info-label">{% trans "Phone" %}:</span>
<span class="info-value">
<a href="tel:{{ person.phone }}" class="text-decoration-none">
{{ person.phone }}
</a>
</span>
</div>
{% endif %}
{% if person.address %}
<div class="info-item">
<i class="fas fa-map-marker-alt"></i>
<span class="info-label">{% trans "Address" %}:</span>
<span class="info-value">{{ person.address|linebreaksbr }}</span>
</div>
{% endif %}
{% if person.linkedin_profile %}
<div class="info-item">
<i class="fab fa-linkedin"></i>
<span class="info-label">{% trans "LinkedIn" %}:</span>
<span class="info-value">
<a href="{{ person.linkedin_profile }}" target="_blank"
class="text-decoration-none">
{% trans "View Profile" %}
<i class="fas fa-external-link-alt ms-1"></i>
</a>
</span>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<!-- Related Information -->
<div class="row">
<!-- Applications -->
<div class="col-lg-6 mb-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">
<i class="fas fa-briefcase me-2"></i>{% trans "Applications" %}
<span class="badge bg-primary ms-2">{{ person.applications.count }}</span>
</h5>
{% if person.applications %}
{% for application in person.applications.all %}
<div class="related-item">
<div class="d-flex justify-content-between align-items-start">
<div>
<h6 class="mb-1">
<a href="{% url 'candidate_detail' application.slug %}"
class="text-decoration-none">
{{ application.job.title }}
</a>
</h6>
<small class="text-muted">
{% trans "Applied" %}: {{ application.created_at|date:"d M Y" }}
</small>
</div>
<span class="badge bg-primary">{{ application.stage }}</span>
</div>
</div>
{% endfor %}
{% else %}
<div class="empty-state">
<i class="fas fa-briefcase"></i>
<p>{% trans "No applications found" %}</p>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Documents -->
<div class="col-lg-6 mb-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">
<i class="fas fa-file-alt me-2"></i>{% trans "Documents" %}
<span class="badge bg-primary ms-2">{{ person.documents.count }}</span>
</h5>
{% if person.documents %}
{% for document in person.documents %}
<div class="related-item">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="mb-1">
<a href="{{ document.file.url }}" target="_blank"
class="text-decoration-none">
{{ document.filename }}
</a>
</h6>
<small class="text-muted">
{{ document.file_size|filesizeformat }} •
{{ document.uploaded_at|date:"d M Y" }}
</small>
</div>
<a href="{{ document.file.url }}" download="{{ document.filename }}"
class="btn btn-sm btn-outline-secondary">
<i class="fas fa-download"></i>
</a>
</div>
</div>
{% endfor %}
{% else %}
<div class="empty-state">
<i class="fas fa-file-alt"></i>
<p>{% trans "No documents found" %}</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- System Information -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-body">
<h5 class="card-title">
<i class="fas fa-info-circle me-2"></i>{% trans "System Information" %}
</h5>
<div class="row">
<div class="col-md-6">
<div class="info-item">
<i class="fas fa-calendar-plus"></i>
<span class="info-label">{% trans "Created" %}:</span>
<span class="info-value">{{ person.created_at|date:"d M Y H:i" }}</span>
</div>
</div>
<div class="col-md-6">
<div class="info-item">
<i class="fas fa-calendar-edit"></i>
<span class="info-label">{% trans "Last Updated" %}:</span>
<span class="info-value">{{ person.updated_at|date:"d M Y H:i" }}</span>
</div>
</div>
{% if person.user %}
<div class="col-md-6">
<div class="info-item">
<i class="fas fa-user-shield"></i>
<span class="info-label">{% trans "User Account" %}:</span>
<span class="info-value">
<a href="{% url 'user_detail' person.user.pk %}"
class="text-decoration-none">
{{ person.user.username }}
</a>
</span>
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="row mt-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<a href="{% url 'person_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to People" %}
</a>
{% if user.is_staff %}
<div class="d-flex gap-2">
<a href="{% url 'person_update' person.slug %}" class="btn btn-main-action">
<i class="fas fa-edit me-1"></i> {% trans "Edit Person" %}
</a>
<button type="button" class="btn btn-outline-danger"
data-bs-toggle="modal" data-bs-target="#deleteModal"
data-delete-url="{% url 'person_delete' person.slug %}"
data-item-name="{{ person.get_full_name }}">
<i class="fas fa-trash-alt me-1"></i> {% trans "Delete" %}
</button>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}
{% block customJS %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Add smooth scrolling for internal links
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
anchor.addEventListener('click', function (e) {
e.preventDefault();
const target = document.querySelector(this.getAttribute('href'));
if (target) {
target.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
});
});
// Add copy to clipboard functionality for email and phone
const copyElements = document.querySelectorAll('[data-copy]');
copyElements.forEach(element => {
element.addEventListener('click', function() {
const textToCopy = this.getAttribute('data-copy');
navigator.clipboard.writeText(textToCopy).then(() => {
// Show temporary feedback
const originalText = this.innerHTML;
this.innerHTML = '<i class="fas fa-check me-1"></i>Copied!';
this.classList.add('text-success');
setTimeout(() => {
this.innerHTML = originalText;
this.classList.remove('text-success');
}, 2000);
});
});
});
// Add hover effects for cards
const cards = document.querySelectorAll('.card');
cards.forEach(card => {
card.addEventListener('mouseenter', function() {
this.style.transform = 'translateY(-2px)';
});
card.addEventListener('mouseleave', function() {
this.style.transform = 'translateY(0)';
});
});
});
</script>
{% endblock %}

View File

@ -0,0 +1,411 @@
{% extends "base.html" %}
{% load static i18n %}
{% block title %}People - {{ block.super }}{% endblock %}
{% block customCSS %}
<style>
/* UI Variables for the KAAT-S Theme (Consistent with Reference) */
:root {
--kaauh-teal: #00636e;
--kaauh-teal-dark: #004a53;
--kaauh-border: #eaeff3;
--kaauh-primary-text: #343a40;
--kaauh-gray-light: #f8f9fa;
}
/* Primary Color Overrides */
.text-primary-theme { color: var(--kaauh-teal) !important; }
.bg-primary-theme { background-color: var(--kaauh-teal) !important; }
.text-success { color: var(--kaauh-success) !important; }
.text-danger { color: var(--kaauh-danger) !important; }
.text-info { color: #17a2b8 !important; }
/* Enhanced Card Styling */
.card {
border: 1px solid var(--kaauh-border);
border-radius: 0.75rem;
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
transition: transform 0.2s, box-shadow 0.2s;
background-color: white;
}
.card:not(.no-hover):hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0,0,0,0.1);
}
.card.no-hover:hover {
transform: none;
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
}
/* Main Action Button Style */
.btn-main-action {
background-color: var(--kaauh-teal);
border-color: var(--kaauh-teal);
color: white;
font-weight: 600;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 1rem;
}
.btn-main-action:hover {
background-color: var(--kaauh-teal-dark);
border-color: var(--kaauh-teal-dark);
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
/* Secondary Button Style */
.btn-outline-secondary {
color: var(--kaauh-teal-dark);
border-color: var(--kaauh-teal);
}
.btn-outline-secondary:hover {
background-color: var(--kaauh-teal-dark);
color: white;
border-color: var(--kaauh-teal-dark);
}
/* Person Card Specifics */
.person-card .card-title {
color: var(--kaauh-teal-dark);
font-weight: 600;
font-size: 1.15rem;
}
.person-card .card-text i {
color: var(--kaauh-teal);
width: 1.25rem;
}
/* Badge Styling */
.badge {
font-weight: 600;
padding: 0.4em 0.7em;
border-radius: 0.3rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Table Styling */
.table-view .table thead th {
background-color: var(--kaauh-teal-dark);
color: white;
font-weight: 600;
border-color: var(--kaauh-border);
text-transform: uppercase;
font-size: 0.8rem;
letter-spacing: 0.5px;
padding: 1rem;
}
.table-view .table tbody td {
vertical-align: middle;
padding: 1rem;
border-color: var(--kaauh-border);
}
.table-view .table tbody tr:hover {
background-color: var(--kaauh-gray-light);
}
/* Pagination Link Styling */
.pagination .page-item .page-link {
color: var(--kaauh-teal-dark);
border-color: var(--kaauh-border);
}
.pagination .page-item.active .page-link {
background-color: var(--kaauh-teal);
border-color: var(--kaauh-teal);
color: white;
}
.pagination .page-item:hover .page-link:not(.active) {
background-color: #e9ecef;
}
/* Profile Image Styling */
.profile-image-small {
width: 40px;
height: 40px;
object-fit: cover;
border-radius: 50%;
border: 2px solid var(--kaauh-border);
}
.profile-image-medium {
width: 60px;
height: 60px;
object-fit: cover;
border-radius: 50%;
border: 3px solid var(--kaauh-teal);
}
/* Filter & Search Layout */
.filter-buttons {
display: flex;
gap: 0.5rem;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 style="color: var(--kaauh-teal-dark); font-weight: 700;">
<i class="fas fa-user-friends me-2"></i> {% trans "People Directory" %}
</h1>
<a href="{% url 'person_create' %}" class="btn btn-main-action">
<i class="fas fa-plus me-1"></i> {% trans "Add New Person" %}
</a>
</div>
<!-- Search and Filters -->
<div class="card mb-4 shadow-sm no-hover">
<div class="card-body">
<div class="row g-4">
<div class="col-md-6">
<label for="search" class="form-label small text-muted">{% trans "Search by Name or Email" %}</label>
<form method="get" action="" class="w-100">
<div class="input-group input-group-lg">
<input type="text" name="q" class="form-control" id="search"
placeholder="{% trans 'Search people...' %}"
value="{{ request.GET.q }}">
<button class="btn btn-main-action" type="submit">
<i class="fas fa-search"></i>
</button>
</div>
</form>
</div>
<div class="col-md-6">
<form method="GET" class="row g-3 align-items-end h-100">
{% if request.GET.q %}<input type="hidden" name="q" value="{{ request.GET.q }}">{% endif %}
<div class="col-md-4">
<label for="nationality_filter" class="form-label small text-muted">{% trans "Filter by Nationality" %}</label>
<select name="nationality" id="nationality_filter" class="form-select form-select-sm">
<option value="">{% trans "All Nationalities" %}</option>
{% for nationality in nationalities %}
<option value="{{ nationality }}" {% if request.GET.nationality == nationality %}selected{% endif %}>{{ nationality }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-4">
<label for="gender_filter" class="form-label small text-muted">{% trans "Filter by Gender" %}</label>
<select name="gender" id="gender_filter" class="form-select form-select-sm">
<option value="">{% trans "All Genders" %}</option>
<option value="M" {% if request.GET.gender == 'M' %}selected{% endif %}>{% trans "Male" %}</option>
<option value="F" {% if request.GET.gender == 'F' %}selected{% endif %}>{% trans "Female" %}</option>
</select>
</div>
<div class="col-md-4 d-flex justify-content-end align-self-end">
<div class="filter-buttons">
<button type="submit" class="btn btn-main-action btn-sm">
<i class="fas fa-filter me-1"></i> {% trans "Apply" %}
</button>
{% if request.GET.q or request.GET.nationality or request.GET.gender %}
<a href="{% url 'person_list' %}" class="btn btn-outline-secondary btn-sm">
<i class="fas fa-times me-1"></i> {% trans "Clear" %}
</a>
{% endif %}
</div>
</div>
</form>
</div>
</div>
</div>
</div>
{% if people_list %}
<div id="person-list">
<!-- View Switcher -->
{% include "includes/_list_view_switcher.html" with list_id="person-list" %}
<!-- Table View (Default) -->
<div class="table-view">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead>
<tr>
<th scope="col">{% trans "Photo" %}</th>
<th scope="col">{% trans "Name" %}</th>
<th scope="col">{% trans "Email" %}</th>
<th scope="col">{% trans "Phone" %}</th>
<th scope="col">{% trans "Nationality" %}</th>
<th scope="col">{% trans "Gender" %}</th>
<th scope="col">{% trans "Agency" %}</th>
<th scope="col">{% trans "Created" %}</th>
<th scope="col" class="text-end">{% trans "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for person in people_list %}
<tr>
<td>
{% if person.profile_image %}
<img src="{{ person.profile_image.url }}" alt="{{ person.get_full_name }}"
class="profile-image-small">
{% else %}
<div class="profile-image-small d-flex align-items-center justify-content-center bg-light">
<i class="fas fa-user text-muted"></i>
</div>
{% endif %}
</td>
<td class="fw-medium">
<a href="{% url 'person_detail' person.slug %}"
class="text-decoration-none link-secondary">
{{ person.full_name }}
</a>
</td>
<td>{{ person.email|default:"N/A" }}</td>
<td>{{ person.phone|default:"N/A" }}</td>
<td>
{% if person.nationality %}
<span class="badge bg-primary">{{ person.nationality }}</span>
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
<td>
{% if person.gender %}
<span class="badge bg-info">
{% if person.gender == 'M' %}{% trans "Male" %}{% else %}{% trans "Female" %}{% endif %}
</span>
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
<td><span class="badge bg-secondary">{{ person.agency.name|default:"N/A" }}</span></td>
<td>{{ person.created_at|date:"d-m-Y" }}</td>
<td class="text-end">
<div class="btn-group btn-group-sm" role="group">
<a href="{% url 'person_detail' person.slug %}"
class="btn btn-outline-primary" title="{% trans 'View' %}">
<i class="fas fa-eye"></i>
</a>
{% if user.is_staff %}
<a href="{% url 'person_update' person.slug %}"
class="btn btn-outline-secondary" title="{% trans 'Edit' %}">
<i class="fas fa-edit"></i>
</a>
<button type="button" class="btn btn-outline-danger"
title="{% trans 'Delete' %}"
data-bs-toggle="modal" data-bs-target="#deleteModal"
data-delete-url="{% url 'person_delete' person.slug %}"
data-item-name="{{ person.get_full_name }}">
<i class="fas fa-trash-alt"></i>
</button>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Card View -->
<div class="card-view row">
{% for person in people_list %}
<div class="col-md-6 col-lg-4 mb-4">
<div class="card person-card h-100 shadow-sm">
<div class="card-body d-flex flex-column">
<div class="d-flex align-items-start mb-3">
<div class="me-3">
{% if person.profile_image %}
<img src="{{ person.profile_image.url }}" alt="{{ person.get_full_name }}"
class="profile-image-medium">
{% else %}
<div class="profile-image-medium d-flex align-items-center justify-content-center bg-light">
<i class="fas fa-user text-muted fa-2x"></i>
</div>
{% endif %}
</div>
<div class="flex-grow-1">
<h5 class="card-title mb-1">
<a href="{% url 'person_detail' person.slug %}"
class="text-decoration-none text-primary-theme">
{{ person.get_full_name }}
</a>
</h5>
<p class="text-muted small mb-2">{{ person.email|default:"N/A" }}</p>
</div>
</div>
<div class="card-text text-muted small">
{% if person.phone %}
<div class="mb-2">
<i class="fas fa-phone me-2"></i>{{ person.phone }}
</div>
{% endif %}
{% if person.nationality %}
<div class="mb-2">
<i class="fas fa-globe me-2"></i>
<span class="badge bg-primary">{{ person.nationality }}</span>
</div>
{% endif %}
{% if person.gender %}
<div class="mb-2">
<i class="fas fa-venus-mars me-2"></i>
<span class="badge bg-info">
{% if person.gender == 'M' %}{% trans "Male" %}{% else %}{% trans "Female" %}{% endif %}
</span>
</div>
{% endif %}
{% if person.date_of_birth %}
<div class="mb-2">
<i class="fas fa-birthday-cake me-2"></i>{{ person.date_of_birth }}
</div>
{% endif %}
</div>
<div class="mt-auto pt-3 border-top">
<div class="d-flex gap-2">
<a href="{% url 'person_detail' person.slug %}"
class="btn btn-sm btn-main-action">
<i class="fas fa-eye"></i> {% trans "View" %}
</a>
{% if user.is_staff %}
<a href="{% url 'person_update' person.slug %}"
class="btn btn-sm btn-outline-secondary">
<i class="fas fa-edit"></i> {% trans "Edit" %}
</a>
<button type="button" class="btn btn-outline-danger btn-sm"
title="{% trans 'Delete' %}"
data-bs-toggle="modal" data-bs-target="#deleteModal"
data-delete-url="{% url 'person_delete' person.slug %}"
data-item-name="{{ person.get_full_name }}">
<i class="fas fa-trash-alt"></i>
</button>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
<!-- Pagination -->
{% include "includes/paginator.html" %}
{% else %}
<!-- Empty State -->
<div class="text-center py-5 card shadow-sm">
<div class="card-body">
<i class="fas fa-user-friends fa-3x mb-3" style="color: var(--kaauh-teal-dark);"></i>
<h3>{% trans "No people found" %}</h3>
<p class="text-muted">{% trans "Create your first person record." %}</p>
{% if user.is_staff %}
<a href="{% url 'person_create' %}" class="btn btn-main-action mt-3">
<i class="fas fa-plus me-1"></i> {% trans "Add Person" %}
</a>
{% endif %}
</div>
</div>
{% endif %}
</div>
{% endblock %}

View File

@ -0,0 +1,572 @@
{% extends "base.html" %}
{% load static i18n crispy_forms_tags %}
{% block title %}Update {{ person.get_full_name }} - {{ block.super }}{% endblock %}
{% block customCSS %}
<style>
/* UI Variables for the KAAT-S Theme */
:root {
--kaauh-teal: #00636e;
--kaauh-teal-dark: #004a53;
--kaauh-border: #eaeff3;
--kaauh-gray-light: #f8f9fa;
}
/* Form Container Styling */
.form-container {
max-width: 800px;
margin: 0 auto;
}
/* Card Styling */
.card {
border: 1px solid var(--kaauh-border);
border-radius: 0.75rem;
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
}
/* Main Action Button Style */
.btn-main-action {
background-color: var(--kaauh-teal);
border-color: var(--kaauh-teal);
color: white;
font-weight: 600;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 1.5rem;
}
.btn-main-action:hover {
background-color: var(--kaauh-teal-dark);
border-color: var(--kaauh-teal-dark);
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
/* Secondary Button Style */
.btn-outline-secondary {
color: var(--kaauh-teal-dark);
border-color: var(--kaauh-teal);
}
.btn-outline-secondary:hover {
background-color: var(--kaauh-teal-dark);
color: white;
border-color: var(--kaauh-teal-dark);
}
/* Form Field Styling */
.form-control:focus {
border-color: var(--kaauh-teal);
box-shadow: 0 0 0 0.2rem rgba(0, 99, 110, 0.25);
}
.form-select:focus {
border-color: var(--kaauh-teal);
box-shadow: 0 0 0 0.2rem rgba(0, 99, 110, 0.25);
}
/* Profile Image Upload Styling */
.profile-image-upload {
border: 2px dashed var(--kaauh-border);
border-radius: 0.5rem;
padding: 2rem;
text-align: center;
transition: all 0.3s ease;
cursor: pointer;
}
.profile-image-upload:hover {
border-color: var(--kaauh-teal);
background-color: var(--kaauh-gray-light);
}
.profile-image-preview {
width: 120px;
height: 120px;
object-fit: cover;
border-radius: 50%;
border: 3px solid var(--kaauh-teal);
margin: 0 auto 1rem;
}
.current-image {
width: 100px;
height: 100px;
object-fit: cover;
border-radius: 50%;
border: 2px solid var(--kaauh-teal);
margin-right: 1rem;
}
/* Breadcrumb Styling */
.breadcrumb {
background-color: transparent;
padding: 0;
margin-bottom: 1rem;
}
.breadcrumb-item + .breadcrumb-item::before {
content: ">";
color: var(--kaauh-teal);
}
/* Alert Styling */
.alert {
border-radius: 0.5rem;
border: none;
}
/* Loading State */
.btn.loading {
position: relative;
pointer-events: none;
opacity: 0.8;
}
.btn.loading::after {
content: "";
position: absolute;
width: 16px;
height: 16px;
margin: auto;
border: 2px solid transparent;
border-top-color: #ffffff;
border-radius: 50%;
animation: spin 1s linear infinite;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Current Profile Section */
.current-profile {
background-color: var(--kaauh-gray-light);
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 1rem;
}
.current-profile h6 {
color: var(--kaauh-teal-dark);
font-weight: 600;
margin-bottom: 0.75rem;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="form-container">
<!-- Breadcrumb Navigation -->
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a href="{% url 'person_list' %}" class="text-decoration-none">
<i class="fas fa-user-friends me-1"></i> {% trans "People" %}
</a>
</li>
<li class="breadcrumb-item">
<a href="{% url 'person_detail' person.slug %}" class="text-decoration-none">
{{ person.get_full_name }}
</a>
</li>
<li class="breadcrumb-item active" aria-current="page">{% trans "Update" %}</li>
</ol>
</nav>
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 style="color: var(--kaauh-teal-dark); font-weight: 700;">
<i class="fas fa-user-edit me-2"></i> {% trans "Update Person" %}
</h1>
<div class="d-flex gap-2">
<a href="{% url 'person_detail' person.slug %}" class="btn btn-outline-secondary">
<i class="fas fa-eye me-1"></i> {% trans "View Details" %}
</a>
<a href="{% url 'person_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to List" %}
</a>
</div>
</div>
<!-- Current Profile Info -->
<div class="card shadow-sm mb-4">
<div class="card-body">
<div class="current-profile">
<h6><i class="fas fa-info-circle me-2"></i>{% trans "Currently Editing" %}</h6>
<div class="d-flex align-items-center">
{% if person.profile_image %}
<img src="{{ person.profile_image.url }}" alt="{{ person.get_full_name }}"
class="current-image">
{% else %}
<div class="current-image d-flex align-items-center justify-content-center bg-light">
<i class="fas fa-user text-muted"></i>
</div>
{% endif %}
<div>
<h5 class="mb-1">{{ person.get_full_name }}</h5>
{% if person.email %}
<p class="text-muted mb-0">{{ person.email }}</p>
{% endif %}
<small class="text-muted">
{% trans "Created" %}: {{ person.created_at|date:"d M Y" }} •
{% trans "Last Updated" %}: {{ person.updated_at|date:"d M Y" }}
</small>
</div>
</div>
</div>
</div>
</div>
<!-- Form Card -->
<div class="card shadow-sm">
<div class="card-body p-4">
{% if form.non_field_errors %}
<div class="alert alert-danger" role="alert">
<h5 class="alert-heading">
<i class="fas fa-exclamation-triangle me-2"></i>{% trans "Error" %}
</h5>
{% for error in form.non_field_errors %}
<p class="mb-0">{{ error }}</p>
{% endfor %}
</div>
{% endif %}
{% if 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="{% trans 'Close' %}"></button>
</div>
{% endfor %}
{% endif %}
<form method="post" enctype="multipart/form-data" id="person-form">
{% csrf_token %}
<!-- Profile Image Section -->
<div class="row mb-4">
<div class="col-12">
<div class="profile-image-upload" onclick="document.getElementById('id_profile_image').click()">
<div id="image-preview-container">
{% if person.profile_image %}
<img src="{{ person.profile_image.url }}" alt="Current Profile"
class="profile-image-preview">
<h5 class="text-muted mt-3">{% trans "Click to change photo" %}</h5>
<p class="text-muted small">{% trans "Current photo will be replaced" %}</p>
{% else %}
<i class="fas fa-camera fa-3x text-muted mb-3"></i>
<h5 class="text-muted">{% trans "Upload Profile Photo" %}</h5>
<p class="text-muted small">{% trans "Click to browse or drag and drop" %}</p>
{% endif %}
</div>
<input type="file" name="profile_image" id="id_profile_image"
class="d-none" accept="image/*">
</div>
{% if person.profile_image %}
<div class="mt-2">
<small class="text-muted">
{% trans "Leave empty to keep current photo" %}
</small>
</div>
{% endif %}
</div>
</div>
<!-- Personal Information Section -->
<div class="row mb-4">
<div class="col-12">
<h5 class="mb-3" style="color: var(--kaauh-teal-dark); border-bottom: 2px solid var(--kaauh-teal); padding-bottom: 0.5rem;">
<i class="fas fa-user me-2"></i> {% trans "Personal Information" %}
</h5>
</div>
<div class="col-md-4">
{{ form.first_name|as_crispy_field }}
</div>
<div class="col-md-4">
{{ form.middle_name|as_crispy_field }}
</div>
<div class="col-md-4">
{{ form.last_name|as_crispy_field }}
</div>
</div>
<!-- Contact Information Section -->
<div class="row mb-4">
<div class="col-12">
<h5 class="mb-3" style="color: var(--kaauh-teal-dark); border-bottom: 2px solid var(--kaauh-teal); padding-bottom: 0.5rem;">
<i class="fas fa-envelope me-2"></i> {% trans "Contact Information" %}
</h5>
</div>
<div class="col-md-6">
{{ form.email|as_crispy_field }}
</div>
<div class="col-md-6">
{{ form.phone|as_crispy_field }}
</div>
</div>
<!-- Additional Information Section -->
<div class="row mb-4">
<div class="col-12">
<h5 class="mb-3" style="color: var(--kaauh-teal-dark); border-bottom: 2px solid var(--kaauh-teal); padding-bottom: 0.5rem;">
<i class="fas fa-info-circle me-2"></i> {% trans "Additional Information" %}
</h5>
</div>
<div class="col-md-4">
{{ form.date_of_birth|as_crispy_field }}
</div>
<div class="col-md-4">
{{ form.nationality|as_crispy_field }}
</div>
<div class="col-md-4">
{{ form.gender|as_crispy_field }}
</div>
</div>
<!-- Address Section -->
<div class="row mb-4">
<div class="col-12">
<h5 class="mb-3" style="color: var(--kaauh-teal-dark); border-bottom: 2px solid var(--kaauh-teal); padding-bottom: 0.5rem;">
<i class="fas fa-map-marker-alt me-2"></i> {% trans "Address Information" %}
</h5>
</div>
<div class="col-12">
{{ form.address|as_crispy_field }}
</div>
</div>
<!-- LinkedIn Profile Section -->
<div class="row mb-4">
<div class="col-12">
<h5 class="mb-3" style="color: var(--kaauh-teal-dark); border-bottom: 2px solid var(--kaauh-teal); padding-bottom: 0.5rem;">
<i class="fab fa-linkedin me-2"></i> {% trans "Professional Profile" %}
</h5>
</div>
<div class="col-12">
<div class="form-group mb-3">
<label for="id_linkedin_profile" class="form-label">
{% trans "LinkedIn Profile URL" %}
</label>
<input type="url" name="linkedin_profile" id="id_linkedin_profile"
class="form-control" placeholder="https://linkedin.com/in/username"
value="{{ person.linkedin_profile|default:'' }}">
<small class="form-text text-muted">
{% trans "Optional: Add LinkedIn profile URL" %}
</small>
</div>
</div>
</div>
<!-- Form Actions -->
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex gap-2">
<a href="{% url 'person_detail' person.slug %}" class="btn btn-outline-secondary">
<i class="fas fa-times me-1"></i> {% trans "Cancel" %}
</a>
<a href="{% url 'person_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-list me-1"></i> {% trans "Back to List" %}
</a>
</div>
<div class="d-flex gap-2">
<button type="reset" class="btn btn-outline-secondary">
<i class="fas fa-undo me-1"></i> {% trans "Reset Changes" %}
</button>
<button type="submit" class="btn btn-main-action">
<i class="fas fa-save me-1"></i> {% trans "Update Person" %}
</button>
</div>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% block customJS %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Profile Image Preview
const profileImageInput = document.getElementById('id_profile_image');
const imagePreviewContainer = document.getElementById('image-preview-container');
const originalImage = imagePreviewContainer.innerHTML;
profileImageInput.addEventListener('change', function(e) {
const file = e.target.files[0];
if (file && file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = function(e) {
imagePreviewContainer.innerHTML = `
<img src="${e.target.result}" alt="Profile Preview" class="profile-image-preview">
<h5 class="text-muted mt-3">${file.name}</h5>
<p class="text-muted small">{% trans "New photo selected" %}</p>
`;
};
reader.readAsDataURL(file);
} else if (!file) {
// Reset to original if no file selected
imagePreviewContainer.innerHTML = originalImage;
}
});
// Form Validation
const form = document.getElementById('person-form');
form.addEventListener('submit', function(e) {
const submitBtn = form.querySelector('button[type="submit"]');
submitBtn.classList.add('loading');
submitBtn.disabled = true;
// Basic validation
const firstName = document.getElementById('id_first_name').value.trim();
const lastName = document.getElementById('id_last_name').value.trim();
const email = document.getElementById('id_email').value.trim();
if (!firstName || !lastName) {
e.preventDefault();
submitBtn.classList.remove('loading');
submitBtn.disabled = false;
alert('{% trans "First name and last name are required." %}');
return;
}
if (email && !isValidEmail(email)) {
e.preventDefault();
submitBtn.classList.remove('loading');
submitBtn.disabled = false;
alert('{% trans "Please enter a valid email address." %}');
return;
}
});
// Email validation helper
function isValidEmail(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
// LinkedIn URL validation
const linkedinInput = document.getElementById('id_linkedin_profile');
linkedinInput.addEventListener('blur', function() {
const value = this.value.trim();
if (value && !isValidLinkedInURL(value)) {
this.classList.add('is-invalid');
if (!this.nextElementSibling || !this.nextElementSibling.classList.contains('invalid-feedback')) {
const feedback = document.createElement('div');
feedback.className = 'invalid-feedback';
feedback.textContent = '{% trans "Please enter a valid LinkedIn URL" %}';
this.parentNode.appendChild(feedback);
}
} else {
this.classList.remove('is-invalid');
const feedback = this.parentNode.querySelector('.invalid-feedback');
if (feedback) feedback.remove();
}
});
function isValidLinkedInURL(url) {
const linkedinRegex = /^https?:\/\/(www\.)?linkedin\.com\/.+/i;
return linkedinRegex.test(url);
}
// Drag and Drop functionality
const uploadArea = document.querySelector('.profile-image-upload');
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
uploadArea.addEventListener(eventName, preventDefaults, false);
});
function preventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
['dragenter', 'dragover'].forEach(eventName => {
uploadArea.addEventListener(eventName, highlight, false);
});
['dragleave', 'drop'].forEach(eventName => {
uploadArea.addEventListener(eventName, unhighlight, false);
});
function highlight(e) {
uploadArea.style.borderColor = 'var(--kaauh-teal)';
uploadArea.style.backgroundColor = 'var(--kaauh-gray-light)';
}
function unhighlight(e) {
uploadArea.style.borderColor = 'var(--kaauh-border)';
uploadArea.style.backgroundColor = 'transparent';
}
uploadArea.addEventListener('drop', handleDrop, false);
function handleDrop(e) {
const dt = e.dataTransfer;
const files = dt.files;
if (files.length > 0) {
profileImageInput.files = files;
const event = new Event('change', { bubbles: true });
profileImageInput.dispatchEvent(event);
}
}
// Reset button functionality
const resetBtn = form.querySelector('button[type="reset"]');
resetBtn.addEventListener('click', function(e) {
e.preventDefault();
// Reset form fields
form.reset();
// Reset image preview
imagePreviewContainer.innerHTML = originalImage;
// Clear any validation states
form.querySelectorAll('.is-invalid').forEach(element => {
element.classList.remove('is-invalid');
});
// Remove any invalid feedback messages
form.querySelectorAll('.invalid-feedback').forEach(element => {
element.remove();
});
});
// Warn before leaving if changes are made
let formChanged = false;
const formInputs = form.querySelectorAll('input, select, textarea');
formInputs.forEach(input => {
input.addEventListener('change', function() {
formChanged = true;
});
});
window.addEventListener('beforeunload', function(e) {
if (formChanged) {
e.preventDefault();
e.returnValue = '{% trans "You have unsaved changes. Are you sure you want to leave?" %}';
return e.returnValue;
}
});
form.addEventListener('submit', function() {
formChanged = false;
});
});
</script>
{% endblock %}

View File

@ -49,16 +49,23 @@
</div>
</div>
</div>
{# Using inline style for nav background color - replace with a dedicated CSS class (e.g., .bg-kaauh-nav) if defined in main.css #}
<div style="background-color: #00636e;">
<div style="background-color: #00636e;">
<nav class="navbar navbar-expand-lg navbar-dark sticky-top">
<div class="container-fluid" style="max-width: 1600px;">
{% if request.user.user_type == 'candidate' %}
<a class="navbar-brand text-white" href="{% url 'candidate_portal_dashboard' %}" aria-label="Applicant Dashboard">
<img src="{% static 'image/kaauh_green1.png' %}" alt="{% trans 'kaauh logo green bg' %}" style="width: 40px; height: 40px;">
<span class="ms-3 d-none d-md-inline fw-semibold">{% trans "Applicant Portal" %}</span>
</a>
{% elif request.user.user_type == 'agency' %}
<a class="navbar-brand text-white" href="{% url 'agency_portal_dashboard' %}" aria-label="Agency Dashboard">
<img src="{% static 'image/kaauh_green1.png' %}" alt="{% trans 'kaauh logo green bg' %}" style="width: 40px; height: 40px;">
<span class="ms-3 d-none d-md-inline fw-semibold">{% trans "Agency Portal" %}</span>
</a>
{% endif %}
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#agencyNavbar"
aria-controls="agencyNavbar" aria-expanded="false" aria-label="{% trans 'Toggle navigation' %}">
@ -67,8 +74,26 @@
<div class="collapse navbar-collapse" id="agencyNavbar">
<div class="navbar-nav ms-auto">
{# NAVIGATION LINKS (Add your portal links here if needed) #}
{% if request.user.user_type == 'agency' %}
<li class="nav-item">
<a class="nav-link text-white" href="{% url 'agency_portal_dashboard' %}">
<i class="fas fa-tachometer-alt me-1"></i> {% trans "Dashboard" %}
</a>
</li>
<li class="nav-item">
<a class="nav-link text-white" href="{% url 'agency_portal_persons_list' %}">
<i class="fas fa-users me-1"></i> {% trans "Persons" %}
</a>
</li>
{% elif request.user.user_type == 'candidate' %}
<li class="nav-item">
<a class="nav-link text-white" href="{% url 'candidate_portal_dashboard' %}">
<i class="fas fa-tachometer-alt me-1"></i> {% trans "Dashboard" %}
</a>
</li>
{% endif %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle text-white" href="#" role="button" data-bs-toggle="dropdown"
@ -97,7 +122,7 @@
</li>
<li class="nav-item ms-3">
<form method="post" action="{% url 'agency_portal_logout' %}" class="d-inline">
<form method="post" action="{% url 'portal_logout' %}" class="d-inline">
{% csrf_token %}
<button type="submit" class="btn btn-outline-light btn-sm">
<i class="fas fa-sign-out-alt me-1"></i> {% trans "Logout" %}
@ -134,7 +159,11 @@
{% trans "All rights reserved." %}
</p>
<p class="mb-0 text-white-50">
{% trans "Agency Portal" %}
{% if request.user.user_type == 'candidate' %}
{% trans "Candidate Portal" %}
{% elif request.user.user_type == 'agency' %}
{% trans "Agency Portal" %}
{% endif %}
</p>
</div>
</div>
@ -146,7 +175,7 @@
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js"></script>
<script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.6/bundles/datastar.js"></script>
{# JavaScript (Left unchanged as it was mostly correct) #}
<script>
document.addEventListener('DOMContentLoaded', () => {
@ -206,4 +235,4 @@
{% block customJS %}{% endblock %}
</body>
</html>
</html>

View File

@ -1,4 +1,4 @@
{% extends 'agency_base.html' %}
{% extends 'portal_base.html' %}
{% load static i18n %}
{% block title %}{% trans "Access Link Details" %} - ATS{% endblock %}

View File

@ -162,7 +162,7 @@
</div>
{% endif %}
</div>
<div class="kaauh-card shadow-sm mb-4">
@ -210,10 +210,12 @@
{% trans "Share these credentials securely with the agency. They can use this information to log in and submit candidates." %}
</div>
<a href="{% url 'agency_access_link_detail' access_link.slug %}"
class="btn btn-outline-info btn-sm mx-2">
<i class="fas fa-eye me-1"></i> {% trans "View Access Links Details" %}
</a>
{% if access_link %}
<a href="{% url 'agency_access_link_detail' access_link.slug %}"
class="btn btn-outline-info btn-sm mx-2">
<i class="fas fa-eye me-1"></i> {% trans "View Access Links Details" %}
</a>
{% endif %}
</div>
</div>
@ -331,7 +333,7 @@
</div>
</div>
<!-- Actions Card -->
<div class="kaauh-card p-4">
<h5 class="mb-4" style="color: var(--kaauh-teal-dark);">
@ -488,14 +490,14 @@ function copyToClipboard(elementId) {
function confirmDeactivate() {
if (confirm('{% trans "Are you sure you want to deactivate this access link? Agencies will no longer be able to use it." %}')) {
// Submit form to deactivate
window.location.href = '{% url "agency_access_link_deactivate" access_link.slug %}';
window.location.href = '';
}
}
function confirmReactivate() {
if (confirm('{% trans "Are you sure you want to reactivate this access link?" %}')) {
// Submit form to reactivate
window.location.href = '{% url "agency_access_link_reactivate" access_link.slug %}';
window.location.href = '';
}
}

View File

@ -1,4 +1,4 @@
{% extends 'agency_base.html' %}
{% extends 'portal_base.html' %}
{% load static i18n %}
{% block title %}{{ assignment.job.title }} - {{ assignment.agency.name }} - Agency Portal{% endblock %}

View File

@ -1,4 +1,4 @@
{% extends 'agency_base.html' %}
{% extends 'portal_base.html' %}
{% load static i18n %}
{% block title %}{% trans "Agency Dashboard" %} - ATS{% endblock %}

View File

@ -1,4 +1,4 @@
{% extends 'agency_base.html' %}
{% extends 'portal_base.html' %}
{% load static i18n %}
{% block title %}{% trans "Agency Portal Login" %} - ATS{% endblock %}
@ -132,7 +132,7 @@
<!-- Login Body -->
<div class="login-body">
<!-- Messages -->
<!-- Login Form -->
<form method="post" novalidate>

View File

@ -0,0 +1,390 @@
{% extends 'portal_base.html' %}
{% load static i18n crispy_forms_tags %}
{% block title %}{% trans "Persons List" %} - ATS{% endblock %}
{% block customCSS %}
<style>
:root {
--kaauh-teal: #00636e;
--kaauh-teal-dark: #004a53;
--kaauh-border: #eaeff3;
--kaauh-primary-text: #343a40;
--kaauh-success: #28a745;
--kaauh-info: #17a2b8;
--kaauh-danger: #dc3545;
--kaauh-warning: #ffc107;
}
.kaauh-card {
border: 1px solid var(--kaauh-border);
border-radius: 0.75rem;
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
background-color: white;
}
.btn-main-action {
background-color: var(--kaauh-teal);
border-color: var(--kaauh-teal);
color: white;
font-weight: 600;
transition: all 0.2s ease;
}
.btn-main-action:hover {
background-color: var(--kaauh-teal-dark);
border-color: var(--kaauh-teal-dark);
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
.person-row:hover {
background-color: #f8f9fa;
cursor: pointer;
}
.stage-badge {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-radius: 0.375rem;
font-weight: 500;
}
.search-form {
background-color: white;
border: 1px solid var(--kaauh-border);
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 1.5rem;
}
</style>
{% endblock%}
{% block content %}
<div class="container-fluid py-4 persons-list">
<div class="d-flex justify-content-between align-items-center mb-4">
<div class="px-2 py-2">
<h1 class="h3 mb-1" style="color: var(--kaauh-teal-dark); font-weight: 700;">
<i class="fas fa-users me-2"></i>
{% trans "All Persons" %}
</h1>
<p class="text-muted mb-0">
{% trans "All persons who come through" %} {{ agency.name }}
</p>
</div>
<div>
<!-- Add Person Button -->
<button type="button" class="btn btn-main-action" data-bs-toggle="modal" data-bs-target="#personModal">
<i class="fas fa-plus me-1"></i> {% trans "Add New Person" %}
</button>
</div>
</div>
<!-- Search and Filter Section -->
<div class="kaauh-card shadow-sm mb-4">
<div class="card-body">
<form method="get" class="search-form">
<div class="row g-3">
<div class="col-md-6">
<label for="search" class="form-label fw-semibold">
<i class="fas fa-search me-1"></i>{% trans "Search" %}
</label>
<input type="text"
class="form-control"
id="search"
name="q"
value="{{ search_query }}"
placeholder="{% trans 'Search by name, email, phone, or job title...' %}">
</div>
<div class="col-md-3">
<label for="stage" class="form-label fw-semibold">
<i class="fas fa-filter me-1"></i>{% trans "Stage" %}
</label>
<select class="form-select" id="stage" name="stage">
<option value="">{% trans "All Stages" %}</option>
{% for stage_value, stage_label in stage_choices %}
<option value="{{ stage_value }}"
{% if stage_filter == stage_value %}selected{% endif %}>
{{ stage_label }}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-3 d-flex align-items-end">
<button type="submit" class="btn btn-main-action w-100">
<i class="fas fa-search me-1"></i> {% trans "Search" %}
</button>
</div>
</div>
</form>
</div>
</div>
<!-- Results Summary -->
<div class="row mb-3">
<div class="col-md-6">
<div class="kaauh-card shadow-sm h-100">
<div class="card-body text-center">
<div class="text-info mb-2">
<i class="fas fa-users fa-2x"></i>
</div>
<h4 class="card-title">{{ total_persons }}</h4>
<p class="card-text text-muted">{% trans "Total Persons" %}</p>
</div>
</div>
</div>
<div class="col-md-6">
<div class="kaauh-card shadow-sm h-100">
<div class="card-body text-center">
<div class="text-success mb-2">
<i class="fas fa-check-circle fa-2x"></i>
</div>
<h4 class="card-title">{{ page_obj|length }}</h4>
<p class="card-text text-muted">{% trans "Showing on this page" %}</p>
</div>
</div>
</div>
</div>
<!-- Persons Table -->
<div class="kaauh-card shadow-sm">
<div class="card-body p-0">
{% if page_obj %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<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 "Stage" %}</th>
<th scope="col">{% trans "Applied Date" %}</th>
<th scope="col" class="text-center">{% trans "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for person in page_obj %}
<tr class="person-row">
<td>
<div class="d-flex align-items-center">
<div class="rounded-circle bg-primary text-white d-flex align-items-center justify-content-center me-2"
style="width: 32px; height: 32px; font-size: 14px; font-weight: 600;">
{{ person.first_name|first|upper }}{{ person.last_name|first|upper }}
</div>
<div>
<div class="fw-semibold">{{ person.first_name }} {{ person.last_name }}</div>
{% if person.address %}
<small class="text-muted">{{ person.address|truncatechars:50 }}</small>
{% endif %}
</div>
</div>
</td>
<td>
<a href="mailto:{{ person.email }}" class="text-decoration-none">
{{ person.email }}
</a>
</td>
<td>{{ person.phone|default:"-" }}</td>
<td>
<span class="badge bg-light text-dark">
{{ person.job.title|truncatechars:30 }}
</span>
</td>
<td>
{% with stage_class=person.stage|lower %}
<span class="stage-badge
{% if stage_class == 'applied' %}bg-secondary{% endif %}
{% if stage_class == 'exam' %}bg-info{% endif %}
{% if stage_class == 'interview' %}bg-warning{% endif %}
{% if stage_class == 'offer' %}bg-success{% endif %}
{% if stage_class == 'hired' %}bg-primary{% endif %}
{% if stage_class == 'rejected' %}bg-danger{% endif %}
text-white">
{{ person.get_stage_display }}
</span>
{% endwith %}
</td>
<td>{{ person.created_at|date:"Y-m-d" }}</td>
<td class="text-center">
<div class="btn-group" role="group">
<a href="{% url 'candidate_detail' person.slug %}"
class="btn btn-sm btn-outline-primary"
title="{% trans 'View Details' %}">
<i class="fas fa-eye"></i>
</a>
<button type="button"
class="btn btn-sm btn-outline-secondary"
title="{% trans 'Edit Person' %}"
onclick="editPerson({{ person.id }})">
<i class="fas fa-edit"></i>
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5">
<i class="fas fa-users fa-3x text-muted mb-3"></i>
<h5 class="text-muted">{% trans "No persons found" %}</h5>
<p class="text-muted">
{% if search_query or stage_filter %}
{% trans "Try adjusting your search or filter criteria." %}
{% else %}
{% trans "No persons have been added yet." %}
{% endif %}
</p>
{% if not search_query and not stage_filter and agency.assignments.exists %}
<a href="{% url 'agency_portal_submit_candidate_page' agency.assignments.first.slug %}"
class="btn btn-main-action">
<i class="fas fa-user-plus me-1"></i> {% trans "Add First Person" %}
</a>
{% endif %}
</div>
{% endif %}
</div>
</div>
<!-- Pagination -->
{% if page_obj.has_other_pages %}
<nav aria-label="{% trans 'Persons 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{% if search_query %}&q={{ search_query }}{% endif %}{% if stage_filter %}&stage={{ stage_filter }}{% endif %}">
<i class="fas fa-angle-double-left"></i>
</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if search_query %}&q={{ search_query }}{% endif %}{% if stage_filter %}&stage={{ stage_filter }}{% endif %}">
<i class="fas fa-angle-left"></i>
</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 %}&q={{ search_query }}{% endif %}{% if stage_filter %}&stage={{ stage_filter }}{% 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 %}&q={{ search_query }}{% endif %}{% if stage_filter %}&stage={{ stage_filter }}{% endif %}">
<i class="fas fa-angle-right"></i>
</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}{% if search_query %}&q={{ search_query }}{% endif %}{% if stage_filter %}&stage={{ stage_filter }}{% endif %}">
<i class="fas fa-angle-double-right"></i>
</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
</div>
<!-- Person Modal -->
<div class="modal fade modal-lg" id="personModal" tabindex="-1" aria-labelledby="personModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="personModalLabel">
<i class="fas fa-users me-2"></i>
{% trans "Person Details" %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="personModalBody">
<form id="person_form" hx-post="{% url 'person_create' %}" hx-vals='{"view":"portal","agency":"{{ agency.slug }}"}' hx-select=".persons-list" hx-target=".persons-list" hx-swap="outerHTML"
hx-on:afterRequest="$('#personModal').modal('hide')">
{% csrf_token %}
<div class="row g-4">
<div class="col-md-4">
{{ person_form.first_name|as_crispy_field }}
</div>
<div class="col-md-4">
{{ person_form.middle_name|as_crispy_field }}
</div>
<div class="col-md-4">
{{ person_form.last_name|as_crispy_field }}
</div>
</div>
<div class="row g-4">
<div class="col-md-6">
{{ person_form.email|as_crispy_field }}
</div>
<div class="col-md-6">
{{ person_form.phone|as_crispy_field }}
</div>
</div>
<div class="row g-4">
<div class="col-md-6">
{{ person_form.date_of_birth|as_crispy_field }}
</div>
<div class="col-md-6">
{{ person_form.nationality|as_crispy_field }}
</div>
</div>
<div class="row g-4">
<div class="col-12">
{{ person_form.address|as_crispy_field }}
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-main-action" type="submit" form="person_form">{% trans "Save" %}</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
{% trans "Close" %}
</button>
</div>
</div>
</div>
</div>
<script>
function openPersonModal(personId, personName) {
const modal = new bootstrap.Modal(document.getElementById('personModal'));
document.getElementById('person-modal-text').innerHTML = `<strong>${personName}</strong> (ID: ${personId})`;
modal.show();
}
</script>
<script>
function editPerson(personId) {
// Placeholder for edit functionality
// This would typically open a modal or navigate to edit page
console.log('Edit person:', personId);
// For now, you can redirect to a placeholder edit URL
// window.location.href = `/portal/candidates/${personId}/edit/`;
}
// Auto-submit form on filter change
document.getElementById('stage').addEventListener('change', function() {
this.form.submit();
});
// Add row click functionality
document.querySelectorAll('.person-row').forEach(row => {
row.addEventListener('click', function(e) {
// Don't trigger if clicking on buttons or links
if (e.target.closest('a, button')) {
return;
}
// Find the view details button and click it
const viewBtn = this.querySelector('a[title*="View"]');
if (viewBtn) {
viewBtn.click();
}
});
});
</script>
{% endblock %}

View File

@ -1,4 +1,4 @@
{% extends 'agency_base.html' %}
{% extends 'portal_base.html' %}
{% load static i18n %}
{% block title %}{% trans "Submit Candidate" %} - {{ assignment.job.title }} - Agency Portal{% endblock %}

View File

@ -1,7 +1,7 @@
{% extends "base.html" %}
{% load static i18n crispy_forms_tags %}
{% block title %}Create Candidate - {{ block.super }}{% endblock %}
{% block title %}Create Application - {{ block.super }}{% endblock %}
{% block customCSS %}
<style>
@ -36,7 +36,7 @@
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
/* Outlined Button Styles */
.btn-secondary, .btn-outline-secondary {
background-color: #f8f9fa;
@ -58,7 +58,7 @@
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
background-color: white;
}
/* Colored Header Card */
.candidate-header-card {
background: linear-gradient(135deg, var(--kaauh-teal), #004d57);
@ -84,18 +84,22 @@
{% block content %}
<div class="container-fluid py-4">
<div class="card mb-4">
<div class="candidate-header-card">
<div class="d-flex justify-content-between align-items-start flex-wrap">
<div class="flex-grow-1">
<h1 class="h3 mb-1">
<i class="fas fa-user-plus"></i>
{% trans "Create New Candidate" %}
<i class="fas fa-user-plus"></i>
{% trans "Create New Application" %}
</h1>
<p class="text-white opacity-75 mb-0">{% trans "Enter details to create a new candidate record." %}</p>
<p class="text-white opacity-75 mb-0">{% trans "Enter details to create a new application record." %}</p>
</div>
<div class="d-flex gap-2 mt-1">
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-toggle="modal" data-bs-target="#personModal">
<i class="fas fa-user-plus me-1"></i>
<span class="d-none d-sm-inline">{% trans "Create New Person" %}</span>
</button>
<a href="{% url 'candidate_list' %}" class="btn btn-outline-light btn-sm" title="{% trans 'Back to List' %}">
<i class="fas fa-arrow-left"></i>
<span class="d-none d-sm-inline">{% trans "Back to List" %}</span>
@ -109,13 +113,13 @@
<div class="card-header bg-white border-bottom">
<h2 class="h5 mb-0 text-primary">
<i class="fas fa-file-alt me-1"></i>
{% trans "Candidate Information" %}
{% trans "Application Information" %}
</h2>
</div>
<div class="card-body">
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{# Split form into two columns for better horizontal use #}
<div class="row g-4">
{% for field in form %}
@ -124,14 +128,69 @@
</div>
{% endfor %}
</div>
<hr class="mt-4 mb-4">
<button class="btn btn-main-action" type="submit">
<i class="fas fa-save me-1"></i>
{% trans "Create Candidate" %}
{% trans "Create Application" %}
</button>
</form>
</div>
</div>
</div>
<!-- Modal -->
<div class="modal fade modal-lg" id="personModal" tabindex="-1" aria-labelledby="personModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="personModalLabel">
<i class="fas fa-question-circle me-2"></i>{% trans "Help" %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form id="person_form" hx-post="{% url 'person_create' %}" hx-vals='{"view":"job"}' hx-target="#div_id_person" hx-select="#div_id_person" hx-swap="outerHTML">
{% csrf_token %}
<div class="row g-4">
<div class="col-md-4">
{{ person_form.first_name|as_crispy_field }}
</div>
<div class="col-md-4">
{{ person_form.middle_name|as_crispy_field }}
</div>
<div class="col-md-4">
{{ person_form.last_name|as_crispy_field }}
</div>
</div>
<div class="row g-4">
<div class="col-md-6">
{{ person_form.email|as_crispy_field }}
</div>
<div class="col-md-6">
{{ person_form.phone|as_crispy_field }}
</div>
</div>
<div class="row g-4">
<div class="col-md-6">
{{ person_form.date_of_birth|as_crispy_field }}
</div>
<div class="col-md-6">
{{ person_form.nationality|as_crispy_field }}
</div>
</div>
<div class="row g-4">
<div class="col-12">
{{ person_form.address|as_crispy_field }}
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-main-action" data-bs-dismiss="modal" form="person_form">{% trans "Save" %}</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% trans "Close" %}</button>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -321,6 +321,12 @@
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="documents-tab" data-bs-toggle="tab" data-bs-target="#documents-pane" type="button" role="tab" aria-controls="documents-pane" aria-selected="false">
<i class="fas fa-file-alt me-1"></i> {% trans "Documents" %}
</button>
</li>
</ul>
<div class="card-body">
@ -417,7 +423,7 @@
</div>
{% endif %}
{% if candidate.get_interview_date %}
<div class="timeline-item">
<div class="timeline-icon timeline-bg-applied"><i class="fas fas fa-comments"></i></div>
@ -440,13 +446,13 @@
<p class="timeline-stage fw-bold mb-0 ms-2">{% trans "Offer" %}</p>
<small class="text-muted">
<i class="far fa-calendar-alt me-1"></i> {{ candidate.offer_date|date:"M d, Y" }}
</small>
</div>
</div>
{% endif %}
{% if candidate.hired_date %}
<div class="timeline-item">
<div class="timeline-icon timeline-bg-applied"><i class="fas fas fa-handshake"></i></div>
@ -454,19 +460,25 @@
<p class="timeline-stage fw-bold mb-0 ms-2">{% trans "Offer" %}</p>
<small class="text-muted">
<i class="far fa-calendar-alt me-1"></i> {{ candidate.hired_date|date:"M d, Y" }}
</small>
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{# TAB 4 CONTENT: PARSED SUMMARY #}
{# TAB 4 CONTENT: DOCUMENTS #}
<div class="tab-pane fade" id="documents-pane" role="tabpanel" aria-labelledby="documents-tab">
{% with documents=candidate.documents %}
{% include 'includes/document_list.html' %}
{% endwith %}
</div>
{# TAB 5 CONTENT: PARSED SUMMARY #}
{% if candidate.parsed_summary %}
<div class="tab-pane fade" id="summary-pane" role="tabpanel" aria-labelledby="summary-tab">
<h5 class="text-primary mb-4">{% trans "AI Generated Summary" %}</h5>
@ -666,7 +678,7 @@
<div class="card shadow-sm mb-4 p-2">
<h5 class="text-muted mb-3"><i class="fas fa-clock me-2"></i>{% trans "Time to Hire:" %}
{% with days=candidate.time_to_hire_days %}
{% if days > 0 %}
{{ days }} day{{ days|pluralize }}
@ -712,4 +724,4 @@
{% if user.is_staff %}
{% include "recruitment/partials/stage_update_modal.html" with candidate=candidate form=stage_form %}
{% endif %}
{% endblock %}
{% endblock %}

View File

@ -196,7 +196,7 @@
<h2 class="h4 mb-3" style="color: var(--kaauh-primary-text);">
{% trans "Candidate List" %}
<span class="badge bg-primary-theme ms-2">{{ candidates|length }} / {{ total_candidates }} Total</span>
<small class="text-muted fw-normal ms-2">(Sorted by AI Score)</small>
<small class="text-muted fw-normal ms-2">({% trans "Sorted by AI Score" %})</small>
</h2>
<div class="kaauh-card shadow-sm p-3">
@ -210,7 +210,7 @@
{# Select Input Group #}
<div>
<select name="mark_as" id="update_status" class="form-select form-select-sm" style="min-width: 150px;">
<option selected>
----------
@ -259,7 +259,7 @@
{% endif %}
</th>
<th style="width: 15%;">{% trans "Name" %}</th>
<th style="width: 15%;">{% trans "Contact" %}</th>
<th style="width: 15%;">{% trans "Contact Info" %}</th>
<th style="width: 10%;" class="text-center">{% trans "AI Score" %}</th>
<th style="width: 15%;">{% trans "Exam Date" %}</th>
<th style="width: 10%;" class="text-center">{% trans "Exam Results" %}</th>
@ -313,7 +313,7 @@
hx-get="{% url 'update_candidate_status' job.slug candidate.slug 'exam' 'passed' %}"
hx-target="#candidateviewModalBody"
title="Pass Exam">
{{ candidate.interview_status }}
{{ candidate.exam_status }}
</button>
{% else %}
--

View File

@ -1,7 +1,7 @@
{% extends "base.html" %}
{% load static i18n %}
{% block title %}Candidates - {{ block.super }}{% endblock %}
{% block title %}Applications - {{ block.super }}{% endblock %}
{% block customCSS %}
<style>
@ -189,13 +189,11 @@
<div class="container-fluid py-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 style="color: var(--kaauh-teal-dark); font-weight: 700;">
<i class="fas fa-users me-2"></i> {% trans "Candidate Profiles" %}
<i class="fas fa-users me-2"></i> {% trans "Applications List" %}
</h1>
{% if user.is_staff %}
<a href="{% url 'candidate_create' %}" class="btn btn-main-action">
<i class="fas fa-plus me-1"></i> {% trans "Add New Candidate" %}
<i class="fas fa-plus me-1"></i> {% trans "Add New Application" %}
</a>
{% endif %}
</div>
<div class="card mb-4 shadow-sm no-hover">
@ -262,8 +260,7 @@
</div>
</div>
{% if candidates %}
{% if applications %}
<div id="candidate-list">
{# View Switcher - list_id must match the container ID #}
{% include "includes/_list_view_switcher.html" with list_id="candidate-list" %}
@ -286,7 +283,7 @@
</tr>
</thead>
<tbody>
{% for candidate in candidates %}
{% for candidate in applications %}
<tr>
<td class="fw-medium"><a href="{% url 'candidate_detail' candidate.slug %}" class="text-decoration-none link-secondary">{{ candidate.name }}<a></td>
<td>{{ candidate.email }}</td>
@ -315,14 +312,14 @@
</span>
</td>
<td>
{% if candidate.hiring_agency %}
{% if candidate.hiring_agency and candidate.hiring_source == 'Agency' %}
<a href="{% url 'agency_detail' candidate.hiring_agency.slug %}" class="text-decoration-none">
<span class="badge bg-info">
<span class="badge bg-primary">
<i class="fas fa-building"></i> {{ candidate.hiring_agency.name }}
</span>
</a>
{% else %}
<span class="text-muted">-</span>
<span class="badge bg-primary">{{ candidate.hiring_source }}</span>
{% endif %}
</td>
<td>{{ candidate.created_at|date:"d-m-Y" }}</td>
@ -404,11 +401,11 @@
<div class="text-center py-5 card shadow-sm">
<div class="card-body">
<i class="fas fa-users fa-3x mb-3" style="color: var(--kaauh-teal-dark);"></i>
<h3>{% trans "No candidate profiles found" %}</h3>
<p class="text-muted">{% trans "Create your first candidate profile or adjust your filters." %}</p>
<h3>{% trans "No application found" %}</h3>
<p class="text-muted">{% trans "Create your first application." %}</p>
{% if user.is_staff %}
<a href="{% url 'candidate_create' %}" class="btn btn-main-action mt-3">
<i class="fas fa-plus me-1"></i> {% trans "Add Candidate" %}
<i class="fas fa-plus me-1"></i> {% trans "Add Application" %}
</a>
{% endif %}
</div>

View File

@ -0,0 +1,165 @@
{% extends 'portal_base.html' %}
{% load static i18n %}
{% block title %}{% trans "Candidate Dashboard" %} - ATS{% endblock %}
{% block content %}
<div class="container-fluid">
<!-- Dashboard Header -->
<div class="row mb-4">
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="row align-items-center">
<div class="col-md-6">
<h4 class="mb-1">
<i class="fas fa-user-tie me-2 text-primary"></i>
{% trans "Welcome" %} {{ candidate.first_name }}
</h4>
<p class="text-muted mb-0">
{% trans "Manage your applications and profile" %}
</p>
</div>
{% comment %} <div class="col-md-6 text-md-end">
<span class="badge bg-success fs-6">
<i class="fas fa-circle me-1"></i>
{% trans "Active" %}
</span>
</div> {% endcomment %}
</div>
</div>
</div>
</div>
</div>
<!-- Quick Stats -->
<div class="row mb-4">
<div class="col-md-4 mb-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body text-center">
<div class="mb-3">
<i class="fas fa-briefcase fa-2x text-primary"></i>
</div>
<h5 class="card-title">{{ candidate.job.title|default:"No Job" }}</h5>
<p class="text-muted small mb-0">
{% trans "Applied Position" %}
</p>
</div>
</div>
</div>
<div class="col-md-4 mb-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body text-center">
<div class="mb-3">
<i class="fas fa-tasks fa-2x text-info"></i>
</div>
<h5 class="card-title">{{ candidate.stage|default:"Applied" }}</h5>
<p class="text-muted small mb-0">
{% trans "Current Stage" %}
</p>
</div>
</div>
</div>
<div class="col-md-4 mb-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body text-center">
<div class="mb-3">
<i class="fas fa-calendar fa-2x text-success"></i>
</div>
<h5 class="card-title">{{ candidate.created_at|date:"M d, Y" }}</h5>
<p class="text-muted small mb-0">
{% trans "Application Date" %}
</p>
</div>
</div>
</div>
</div>
<!-- Profile Information -->
<div class="row mb-4">
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-header bg-primary text-white">
<h5 class="mb-0">
<i class="fas fa-user me-2"></i>
{% trans "Profile Information" %}
</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label fw-bold">
{% trans "Full Name" %}
</label>
<p class="form-control-plaintext">
{{ candidate.first_name }} {{ candidate.last_name }}
</p>
</div>
<div class="col-md-6 mb-3">
<label class="form-label fw-bold">
{% trans "Email Address" %}
</label>
<p class="form-control-plaintext">
{{ candidate.email }}
</p>
</div>
<div class="col-md-6 mb-3">
<label class="form-label fw-bold">
{% trans "Phone Number" %}
</label>
<p class="form-control-plaintext">
{{ candidate.phone|default:"Not provided" }}
</p>
</div>
<div class="col-md-6 mb-3">
<label class="form-label fw-bold">
{% trans "Resume" %}
</label>
<p class="form-control-plaintext">
{% if candidate.resume %}
<a href="{{ candidate.resume.url }}" class="btn btn-sm btn-outline-primary" target="_blank">
<i class="fas fa-download me-1"></i>
{% trans "Download Resume" %}
</a>
{% else %}
<span class="text-muted">{% trans "No resume uploaded" %}</span>
{% endif %}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Actions -->
<div class="row">
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="row text-center">
<div class="col-md-4 mb-3">
<button class="btn btn-outline-primary w-100">
<i class="fas fa-edit me-2"></i>
{% trans "Edit Profile" %}
</button>
</div>
<div class="col-md-4 mb-3">
<button class="btn btn-outline-success w-100">
<i class="fas fa-file-upload me-2"></i>
{% trans "Update Resume" %}
</button>
</div>
<div class="col-md-4 mb-3">
<button class="btn btn-outline-info w-100">
<i class="fas fa-eye me-2"></i>
{% trans "View Application" %}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -162,6 +162,13 @@
font-size: 0.8rem !important; /* Slightly smaller font */
}
.kaats-spinner {
animation: kaats-spinner-rotate 1.5s linear infinite; /* Faster rotation */
width: 40px; /* Standard size */
height: 40px;
display: inline-block; /* Useful for table cells */
vertical-align: middle;
}
.kaats-spinner .path {
stroke: var(--kaauh-teal, #00636e); /* Use Teal color, fallback to dark teal */
@ -265,16 +272,16 @@
<select name="screening_rating" id="screening_rating" class="form-select form-select-sm" style="width: 120px;">
<option value="">{% trans "Any Rating" %}</option>
<option value="Highly Qualified" {% if screening_rating == "Highly Qualified" %}selected{% endif %}>
Highly Qualified
{% trans "Highly Qualified" %}
</option>
<option value="Qualified" {% if screening_rating == "Qualified" %}selected{% endif %}>
Qualified
{% trans "Qualified" %}
</option>
<option value="Partially Qualified" {% if screening_rating == "Partially Qualified" %}selected{% endif %}>
Partially Qualified
{% trans "Partially Qualified" %}
</option>
<option value="Not Qualified" {% if screening_rating == "Not Qualified" %}selected{% endif %}>
Not Qualified
{% trans "Not Qualified" %}
</option>
</select>
</div>
@ -312,7 +319,7 @@
{# Select Input Group #}
<div>
<select name="mark_as" id="update_status" class="form-select form-select-sm" style="min-width: 150px;">
<option selected>
----------
@ -415,7 +422,7 @@
<div class="text-nowrap">
<svg class="kaats-spinner" viewBox="0 0 50 50" style="width: 25px; height: 25px;">
<circle cx="25" cy="25" r="20"></circle>
<circle class="path" cx="25" cy="25" r="20" fill="none" stroke-width="5"
<circle class="path" cx="25" cy="25" r="20" fill="none" stroke-width="5"
style="animation: kaats-spinner-dash 1.5s ease-in-out infinite;"></circle>
</svg>
<span class="text-teal-primary">{% trans 'AI scoring..' %}</span>
@ -511,7 +518,7 @@
<div class="text-center py-5 text-muted">
<i class="fas fa-spinner fa-spin fa-2x"></i><br>
{% trans "Loading email form..." %}
</div>
</div>
</div>

View File

@ -0,0 +1,148 @@
{% extends "base.html" %}
{% load i18n %}
{% block title %}{% trans "Candidate Signup" %}{% endblock %}
{% block content %}
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-8 col-lg-6">
<div class="card shadow">
<div class="card-header bg-primary text-white">
<h4 class="mb-0">
<i class="fas fa-user-plus me-2"></i>
{% trans "Candidate Signup" %}
</h4>
</div>
<div class="card-body">
{% if 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"></button>
</div>
{% endfor %}
{% endif %}
<form method="post" novalidate>
{% csrf_token %}
<div class="row">
<div class="col-md-6 mb-3">
<label for="{{ form.first_name.id_for_label }}" class="form-label">
{% trans "First Name" %} <span class="text-danger">*</span>
</label>
{{ form.first_name }}
{% if form.first_name.errors %}
<div class="text-danger small">
{{ form.first_name.errors.0 }}
</div>
{% endif %}
</div>
<div class="col-md-6 mb-3">
<label for="{{ form.last_name.id_for_label }}" class="form-label">
{% trans "Last Name" %} <span class="text-danger">*</span>
</label>
{{ form.last_name }}
{% if form.last_name.errors %}
<div class="text-danger small">
{{ form.last_name.errors.0 }}
</div>
{% endif %}
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="{{ form.middle_name.id_for_label }}" class="form-label">
{% trans "Middle Name" %}
</label>
{{ form.middle_name }}
{% if form.middle_name.errors %}
<div class="text-danger small">
{{ form.middle_name.errors.0 }}
</div>
{% endif %}
</div>
<div class="col-md-6 mb-3">
<label for="{{ form.phone.id_for_label }}" class="form-label">
{% trans "Phone Number" %} <span class="text-danger">*</span>
</label>
{{ form.phone }}
{% if form.phone.errors %}
<div class="text-danger small">
{{ form.phone.errors.0 }}
</div>
{% endif %}
</div>
</div>
<div class="mb-3">
<label for="{{ form.email.id_for_label }}" class="form-label">
{% trans "Email Address" %} <span class="text-danger">*</span>
</label>
{{ form.email }}
{% if form.email.errors %}
<div class="text-danger small">
{{ form.email.errors.0 }}
</div>
{% endif %}
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="{{ form.password.id_for_label }}" class="form-label">
{% trans "Password" %} <span class="text-danger">*</span>
</label>
{{ form.password }}
{% if form.password.errors %}
<div class="text-danger small">
{{ form.password.errors.0 }}
</div>
{% endif %}
</div>
<div class="col-md-6 mb-3">
<label for="{{ form.confirm_password.id_for_label }}" class="form-label">
{% trans "Confirm Password" %} <span class="text-danger">*</span>
</label>
{{ form.confirm_password }}
{% if form.confirm_password.errors %}
<div class="text-danger small">
{{ form.confirm_password.errors.0 }}
</div>
{% endif %}
</div>
</div>
{% if form.non_field_errors %}
<div class="alert alert-danger">
{% for error in form.non_field_errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary">
<i class="fas fa-user-plus me-2"></i>
{% trans "Sign Up" %}
</button>
</div>
</form>
</div>
<div class="card-footer text-center">
<small class="text-muted">
{% trans "Already have an account?" %}
<a href="{% url 'portal_login' %}" class="text-decoration-none">
{% trans "Login here" %}
</a>
</small>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -1,14 +1,4 @@
<td class="text-center" id="status-result-{{ candidate.pk}}">
{% if not candidate.interview_status %}
<button type="button" class="btn btn-warning btn-sm"
data-bs-toggle="modal"
data-bs-target="#candidateviewModal"
hx-get="{% url 'update_candidate_status' job.slug candidate.slug 'exam' 'passed' %}"
hx-target="#candidateviewModalBody"
title="Pass Exam">
<i class="fas fa-plus"></i>
</button>
{% else %}
{% if candidate.exam_status %}
<button type="button" class="btn btn-{% if candidate.exam_status == 'Passed' %}success{% else %}danger{% endif %} btn-sm"
data-bs-toggle="modal"
@ -21,5 +11,4 @@
{% else %}
--
{% endif %}
{% endif %}
</td>

View File

@ -1,22 +1,22 @@
<td class="text-center" id="status-result-{{ candidate.pk}}">
<td class="text-center" id="interview-result-{{ candidate.pk}}">
{% if not candidate.interview_status %}
<button type="button" class="btn btn-warning btn-sm"
data-bs-toggle="modal"
data-bs-target="#candidateviewModal"
hx-get="{% url 'update_candidate_status' job.slug candidate.slug 'offer' 'passed' %}"
hx-get="{% url 'update_candidate_status' job.slug candidate.slug 'interview' 'passed' %}"
hx-target="#candidateviewModalBody"
title="Pass Exam">
<i class="fas fa-plus"></i>
</button>
{% else %}
{% if candidate.offer_status %}
<button type="button" class="btn btn-{% if candidate.offer_status == 'Passed' %}success{% else %}danger{% endif %} btn-sm"
{% if candidate.interview_status %}
<button type="button" class="btn btn-{% if candidate.interview_status == 'Passed' %}success{% else %}danger{% endif %} btn-sm"
data-bs-toggle="modal"
data-bs-target="#candidateviewModal"
hx-get="{% url 'update_candidate_status' job.slug candidate.slug 'offer' 'passed' %}"
hx-get="{% url 'update_candidate_status' job.slug candidate.slug 'interview' 'passed' %}"
hx-target="#candidateviewModalBody"
title="Pass Exam">
{{ candidate.offer_status }}
{{ candidate.interview_status }}
</button>
{% else %}
--

View File

@ -0,0 +1,295 @@
{% extends 'portal_base.html' %}
{% load static i18n %}
{% block title %}{% trans "Portal Login" %} - ATS{% endblock %}
{% block customCSS %}
<style>
/* KAAT-S UI Variables */
:root {
--kaauh-teal: #00636e;
--kaauh-teal-dark: #004a53;
--kaauh-border: #eaeff3;
--kaauh-primary-text: #343a40;
--kaauh-success: #28a745;
--kaauh-info: #17a2b8;
--kaauh-danger: #dc3545;
--kaauh-warning: #ffc107;
}
body {
background: linear-gradient(135deg, var(--kaauh-teal) 0%, var(--kaauh-teal-dark) 100%);
min-height: 100vh;
}
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem 0;
}
.login-card {
background: white;
border-radius: 1rem;
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
border: none;
max-width: 500px;
width: 100%;
margin: 0 1rem;
}
.login-header {
background: linear-gradient(135deg, var(--kaauh-teal) 0%, var(--kaauh-teal-dark) 100%);
color: white;
padding: 2rem;
border-radius: 1rem 1rem 0 0;
text-align: center;
}
.login-body {
padding: 2.5rem;
}
.form-control:focus {
border-color: var(--kaauh-teal);
box-shadow: 0 0 0 0.2rem rgba(0, 99, 110, 0.25);
}
.btn-login {
background: linear-gradient(135deg, var(--kaauh-teal) 0%, var(--kaauh-teal-dark) 100%);
border: none;
color: white;
font-weight: 600;
padding: 0.75rem 2rem;
border-radius: 0.5rem;
transition: all 0.3s ease;
}
.btn-login:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 99, 110, 0.3);
}
.input-group-text {
background-color: var(--kaauh-teal);
border-color: var(--kaauh-teal);
color: white;
}
.user-type-cards {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-bottom: 1.5rem;
}
.user-type-card {
border: 2px solid #e9ecef;
border-radius: 0.5rem;
padding: 1rem;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
}
.user-type-card:hover {
border-color: var(--kaauh-teal);
background-color: #f8f9fa;
}
.user-type-card.selected {
border-color: var(--kaauh-teal);
background-color: rgba(0, 99, 110, 0.1);
}
.user-type-icon {
font-size: 2rem;
color: var(--kaauh-teal);
margin-bottom: 0.5rem;
}
.alert {
border-radius: 0.5rem;
border: none;
}
</style>
{% endblock %}
{% block content %}
<div class="login-container">
<div class="login-card">
<!-- Login Header -->
<div class="login-header">
<div class="mb-3">
<i class="fas fa-users fa-3x"></i>
</div>
<h3 class="mb-2">{% trans "Portal Login" %}</h3>
<p class="mb-0 opacity-75">
{% trans "Access your personalized dashboard" %}
</p>
</div>
<!-- Login Body -->
<div class="login-body">
<!-- Login Form -->
<form method="post" novalidate>
{% csrf_token %}
<!-- Email Field -->
<div class="mb-3">
<label for="{{ form.email.id_for_label }}" class="form-label fw-bold">
<i class="fas fa-envelope me-2"></i>
{% trans "Email Address" %}
</label>
<div class="input-group">
<span class="input-group-text">
<i class="fas fa-envelope"></i>
</span>
{{ form.email }}
</div>
{% if form.email.errors %}
<div class="text-danger small mt-1">
{% for error in form.email.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
</div>
<!-- Password Field -->
<div class="mb-4">
<label for="{{ form.password.id_for_label }}" class="form-label fw-bold">
<i class="fas fa-lock me-2"></i>
{% trans "Password" %}
</label>
<div class="input-group">
<span class="input-group-text">
<i class="fas fa-key"></i>
</span>
{{ form.password }}
</div>
{% if form.password.errors %}
<div class="text-danger small mt-1">
{% for error in form.password.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
</div>
<!-- User Type Selection -->
<div class="mb-4">
<label for="{{ form.user_type.id_for_label }}" class="form-label fw-bold">
<i class="fas fa-user-tag me-2"></i>
{% trans "Select User Type" %}
</label>
{{ form.user_type }}
{% if form.user_type.errors %}
<div class="text-danger small mt-1">
{% for error in form.user_type.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
</div>
<!-- Submit Button -->
<div class="d-grid">
<button type="submit" class="btn btn-login btn-lg">
<i class="fas fa-sign-in-alt me-2"></i>
{% trans "Login" %}
</button>
</div>
</form>
<!-- Help Links -->
<div class="text-center mt-4">
<small class="text-muted">
{% trans "Need help?" %}
<a href="#" class="text-decoration-none">
{% trans "Contact Support" %}
</a>
</small>
</div>
</div>
</div>
</div>
{% endblock %}
{% block customJS %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Focus on user type field
const userTypeField = document.getElementById('{{ form.user_type.id_for_label }}');
if (userTypeField) {
userTypeField.focus();
}
// Form validation
const form = document.querySelector('form');
const emailField = document.getElementById('{{ form.email.id_for_label }}');
const passwordField = document.getElementById('{{ form.password.id_for_label }}');
if (form) {
form.addEventListener('submit', function(e) {
const userType = userTypeField.value;
const email = emailField.value.trim();
const password = passwordField.value.trim();
if (!userType) {
e.preventDefault();
showError('{% trans "Please select a user type." %}');
userTypeField.focus();
return;
}
if (!email) {
e.preventDefault();
showError('{% trans "Please enter your email address." %}');
emailField.focus();
return;
}
if (!password) {
e.preventDefault();
showError('{% trans "Please enter your password." %}');
passwordField.focus();
return;
}
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
e.preventDefault();
showError('{% trans "Please enter a valid email address." %}');
emailField.focus();
return;
}
});
}
function showError(message) {
// Remove existing alerts
const existingAlerts = document.querySelectorAll('.alert-danger');
existingAlerts.forEach(alert => alert.remove());
// Create new alert
const alertDiv = document.createElement('div');
alertDiv.className = 'alert alert-danger alert-dismissible fade show';
alertDiv.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
// Insert at the top of login body
const loginBody = document.querySelector('.login-body');
loginBody.insertBefore(alertDiv, loginBody.firstChild);
// Auto-dismiss after 5 seconds
setTimeout(() => {
if (alertDiv.parentNode) {
alertDiv.remove();
}
}, 5000);
}
});
</script>
{% endblock %}

View File

@ -1,67 +0,0 @@
#!/usr/bin/env python
import os
import sys
import django
# Add project root to Python path
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
# Set Django settings module
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'NorahUniversity.settings')
# Initialize Django
django.setup()
def test_agency_access_links():
"""Test agency access link functionality"""
print("Testing agency access links...")
# Test 1: Check if URLs exist
try:
from recruitment.urls import urlpatterns
print("✅ URL patterns loaded successfully")
# Check if our new URLs are in patterns
url_patterns = [str(pattern.pattern) for pattern in urlpatterns]
# Look for our specific URL patterns
deactivate_found = any('agency-access-links' in pattern and 'deactivate' in pattern for pattern in url_patterns)
reactivate_found = any('agency-access-links' in pattern and 'reactivate' in pattern for pattern in url_patterns)
if deactivate_found:
print("✅ Found URL pattern for agency_access_link_deactivate")
else:
print("❌ Missing URL pattern for agency_access_link_deactivate")
if reactivate_found:
print("✅ Found URL pattern for agency_access_link_reactivate")
else:
print("❌ Missing URL pattern for agency_access_link_reactivate")
# Test 2: Check if views exist
try:
from recruitment.views import agency_access_link_deactivate, agency_access_link_reactivate
print("✅ View functions imported successfully")
# Test that functions are callable
if callable(agency_access_link_deactivate):
print("✅ agency_access_link_deactivate is callable")
else:
print("❌ agency_access_link_deactivate is not callable")
if callable(agency_access_link_reactivate):
print("✅ agency_access_link_reactivate is callable")
else:
print("❌ agency_access_link_reactivate is not callable")
except ImportError as e:
print(f"❌ Import error: {e}")
print("Agency access link functionality test completed!")
return True
except Exception as e:
print(f"❌ Test failed: {e}")
return False
if __name__ == "__main__":
test_agency_access_links()

View File

@ -1,98 +0,0 @@
#!/usr/bin/env python
"""
Test script to verify agency assignment functionality
"""
import os
import sys
import django
# Setup Django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'NorahUniversity.settings')
django.setup()
from django.test import Client
from django.urls import reverse
from django.contrib.auth.models import User
from recruitment.models import HiringAgency, JobPosting, AgencyJobAssignment
def test_agency_assignments():
"""Test agency assignment functionality"""
print("🧪 Testing Agency Assignment Functionality")
print("=" * 50)
# Create test client
client = Client()
# Test URLs
urls_to_test = [
('agency_list', '/recruitment/agencies/'),
('agency_assignment_list', '/recruitment/agency-assignments/'),
]
print("\n📋 Testing URL Accessibility:")
for url_name, expected_path in urls_to_test:
try:
url = reverse(url_name)
print(f"{url_name}: {url}")
except Exception as e:
print(f"{url_name}: Error - {e}")
print("\n🔍 Testing Views:")
# Test agency list view (without authentication - should redirect)
try:
response = client.get(reverse('agency_list'))
if response.status_code == 302: # Redirect to login
print("✅ Agency list view redirects unauthenticated users (as expected)")
else:
print(f"⚠️ Agency list view status: {response.status_code}")
except Exception as e:
print(f"❌ Agency list view error: {e}")
# Test agency assignment list view (without authentication - should redirect)
try:
response = client.get(reverse('agency_assignment_list'))
if response.status_code == 302: # Redirect to login
print("✅ Agency assignment list view redirects unauthenticated users (as expected)")
else:
print(f"⚠️ Agency assignment list view status: {response.status_code}")
except Exception as e:
print(f"❌ Agency assignment list view error: {e}")
print("\n📊 Testing Database Models:")
# Test if models exist and can be created
try:
# Check if we can query the models
agency_count = HiringAgency.objects.count()
job_count = JobPosting.objects.count()
assignment_count = AgencyJobAssignment.objects.count()
print(f"✅ HiringAgency model: {agency_count} agencies in database")
print(f"✅ JobPosting model: {job_count} jobs in database")
print(f"✅ AgencyJobAssignment model: {assignment_count} assignments in database")
except Exception as e:
print(f"❌ Database model error: {e}")
print("\n🎯 Navigation Menu Test:")
print("✅ Agency Assignments link added to navigation menu")
print("✅ Navigation includes both 'Agencies' and 'Agency Assignments' links")
print("\n📝 Summary:")
print("✅ Agency assignment functionality is fully implemented")
print("✅ All required views are present in views.py")
print("✅ URL patterns are configured in urls.py")
print("✅ Navigation menu has been updated")
print("✅ Templates are created and functional")
print("\n🚀 Ready for use!")
print("Users can now:")
print(" - View agencies at /recruitment/agencies/")
print(" - Manage agency assignments at /recruitment/agency-assignments/")
print(" - Create, update, and delete assignments")
print(" - Generate access links for external agencies")
print(" - Send messages to agencies")
if __name__ == '__main__':
test_agency_assignments()

View File

@ -1,204 +0,0 @@
#!/usr/bin/env python
"""
Test script to verify Agency CRUD functionality
"""
import os
import sys
import django
# Add the project directory to the Python path
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
# Set up Django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'NorahUniversity.settings')
django.setup()
from django.test import Client
from django.contrib.auth.models import User
from recruitment.models import HiringAgency
def test_agency_crud():
"""Test Agency CRUD operations"""
print("🧪 Testing Agency CRUD Functionality")
print("=" * 50)
# Create a test user
user, created = User.objects.get_or_create(
username='testuser',
defaults={'email': 'test@example.com', 'is_staff': True, 'is_superuser': True}
)
if created:
user.set_password('testpass123')
user.save()
print("✅ Created test user")
else:
print(" Using existing test user")
# Create test client
client = Client()
# Login the user
client.login(username='testuser', password='testpass123')
print("✅ Logged in test user")
# Test 1: Agency List View
print("\n1. Testing Agency List View...")
response = client.get('/recruitment/agencies/')
if response.status_code == 200:
print("✅ Agency list view works")
else:
print(f"❌ Agency list view failed: {response.status_code}")
return False
# Test 2: Agency Create View (GET)
print("\n2. Testing Agency Create View (GET)...")
response = client.get('/recruitment/agencies/create/')
if response.status_code == 200:
print("✅ Agency create view works")
else:
print(f"❌ Agency create view failed: {response.status_code}")
return False
# Test 3: Agency Create (POST)
print("\n3. Testing Agency Create (POST)...")
agency_data = {
'name': 'Test Agency',
'contact_person': 'John Doe',
'email': 'test@agency.com',
'phone': '+1234567890',
'country': 'SA',
'city': 'Riyadh',
'address': 'Test Address',
'website': 'https://testagency.com',
'description': 'Test agency description'
}
response = client.post('/recruitment/agencies/create/', agency_data)
if response.status_code == 302: # Redirect after successful creation
print("✅ Agency creation works")
# Get the created agency
agency = HiringAgency.objects.filter(name='Test Agency').first()
if agency:
print(f"✅ Agency created with ID: {agency.id}")
# Test 4: Agency Detail View
print("\n4. Testing Agency Detail View...")
response = client.get(f'/recruitment/agencies/{agency.slug}/')
if response.status_code == 200:
print("✅ Agency detail view works")
else:
print(f"❌ Agency detail view failed: {response.status_code}")
return False
# Test 5: Agency Update View (GET)
print("\n5. Testing Agency Update View (GET)...")
response = client.get(f'/recruitment/agencies/{agency.slug}/update/')
if response.status_code == 200:
print("✅ Agency update view works")
else:
print(f"❌ Agency update view failed: {response.status_code}")
return False
# Test 6: Agency Update (POST)
print("\n6. Testing Agency Update (POST)...")
update_data = agency_data.copy()
update_data['name'] = 'Updated Test Agency'
response = client.post(f'/recruitment/agencies/{agency.slug}/update/', update_data)
if response.status_code == 302:
print("✅ Agency update works")
# Verify the update
agency.refresh_from_db()
if agency.name == 'Updated Test Agency':
print("✅ Agency data updated correctly")
else:
print("❌ Agency data not updated correctly")
return False
else:
print(f"❌ Agency update failed: {response.status_code}")
return False
# Test 7: Agency Delete View (GET)
print("\n7. Testing Agency Delete View (GET)...")
response = client.get(f'/recruitment/agencies/{agency.slug}/delete/')
if response.status_code == 200:
print("✅ Agency delete view works")
else:
print(f"❌ Agency delete view failed: {response.status_code}")
return False
# Test 8: Agency Delete (POST)
print("\n8. Testing Agency Delete (POST)...")
delete_data = {
'confirm_name': 'Updated Test Agency',
'confirm_delete': 'on'
}
response = client.post(f'/recruitment/agencies/{agency.slug}/delete/', delete_data)
if response.status_code == 302:
print("✅ Agency deletion works")
# Verify deletion
if not HiringAgency.objects.filter(name='Updated Test Agency').exists():
print("✅ Agency deleted successfully")
else:
print("❌ Agency not deleted")
return False
else:
print(f"❌ Agency deletion failed: {response.status_code}")
return False
else:
print("❌ Agency not found after creation")
return False
else:
print(f"❌ Agency creation failed: {response.status_code}")
print(f"Response content: {response.content.decode()}")
return False
# Test 9: URL patterns
print("\n9. Testing URL patterns...")
try:
from django.urls import reverse
print(f"✅ Agency list URL: {reverse('agency_list')}")
print(f"✅ Agency create URL: {reverse('agency_create')}")
print("✅ All URL patterns resolved correctly")
except Exception as e:
print(f"❌ URL pattern error: {e}")
return False
# Test 10: Model functionality
print("\n10. Testing Model functionality...")
try:
# Test model creation
test_agency = HiringAgency.objects.create(
name='Model Test Agency',
contact_person='Jane Smith',
email='model@test.com',
phone='+9876543210',
country='SA'
)
print(f"✅ Model creation works: {test_agency.name}")
print(f"✅ Slug generation works: {test_agency.slug}")
print(f"✅ String representation works: {str(test_agency)}")
# Test model methods
print(f"✅ Country display: {test_agency.get_country_display()}")
# Clean up
test_agency.delete()
print("✅ Model deletion works")
except Exception as e:
print(f"❌ Model functionality error: {e}")
return False
print("\n" + "=" * 50)
print("🎉 All Agency CRUD tests passed!")
return True
if __name__ == '__main__':
success = test_agency_crud()
sys.exit(0 if success else 1)

View File

@ -1,278 +0,0 @@
#!/usr/bin/env python
"""
Test script to verify agency user isolation and all fixes are working properly.
This tests:
1. Agency login functionality (AttributeError fix)
2. Agency portal template isolation (agency_base.html usage)
3. Agency user access restrictions
4. JavaScript fixes in submit candidate form
"""
import os
import sys
import django
from django.test import TestCase, Client
from django.urls import reverse
from django.contrib.auth.models import User
from unittest.mock import patch, MagicMock
# Setup Django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'NorahUniversity.settings')
django.setup()
from recruitment.models import Agency, AgencyJobAssignment, AgencyAccessLink, Candidate, Job
class AgencyIsolationTest(TestCase):
"""Test agency user isolation and functionality"""
def setUp(self):
"""Set up test data"""
# Create internal staff user
self.staff_user = User.objects.create_user(
username='staff_user',
email='staff@example.com',
password='testpass123',
is_staff=True
)
# Create agency user
self.agency_user = User.objects.create_user(
username='agency_user',
email='agency@example.com',
password='testpass123',
is_staff=False
)
# Create agency
self.agency = Agency.objects.create(
name='Test Agency',
contact_email='agency@example.com',
contact_phone='+1234567890',
address='Test Address',
is_active=True
)
# Create job
self.job = Job.objects.create(
title='Test Job',
department='IT',
description='Test job description',
status='active'
)
# Create agency assignment
self.assignment = AgencyJobAssignment.objects.create(
agency=self.agency,
job=self.job,
max_candidates=10,
deadline_date='2024-12-31',
status='active'
)
# Create access link
self.access_link = AgencyAccessLink.objects.create(
assignment=self.assignment,
unique_token='test-token-123',
access_password='testpass123',
expires_at='2024-12-31'
)
# Create test candidate
self.candidate = Candidate.objects.create(
first_name='Test',
last_name='Candidate',
email='candidate@example.com',
phone='+1234567890',
job=self.job,
source='agency',
hiring_agency=self.agency
)
def test_agency_login_form_attribute_error_fix(self):
"""Test that AgencyLoginForm handles missing validated_access_link attribute"""
from recruitment.forms import AgencyLoginForm
# Test form with valid data
form_data = {
'access_token': 'test-token-123',
'password': 'testpass123'
}
form = AgencyLoginForm(data=form_data)
# This should not raise AttributeError anymore
try:
is_valid = form.is_valid()
print(f"✓ AgencyLoginForm validation works: {is_valid}")
except AttributeError as e:
if 'validated_access_link' in str(e):
self.fail("AttributeError 'validated_access_link' not fixed!")
else:
raise
def test_agency_portal_templates_use_agency_base(self):
"""Test that agency portal templates use agency_base.html"""
agency_portal_templates = [
'recruitment/agency_portal_login.html',
'recruitment/agency_portal_dashboard.html',
'recruitment/agency_portal_submit_candidate.html',
'recruitment/agency_portal_messages.html',
'recruitment/agency_access_link_detail.html'
]
for template_name in agency_portal_templates:
template_path = f'templates/{template_name}'
if os.path.exists(template_path):
with open(template_path, 'r') as f:
content = f.read()
self.assertIn("{% extends 'agency_base.html' %}", content,
f"{template_name} should use agency_base.html")
print(f"{template_name} uses agency_base.html")
else:
print(f"⚠ Template {template_name} not found")
def test_agency_base_template_isolation(self):
"""Test that agency_base.html properly isolates agency users"""
agency_base_path = 'templates/agency_base.html'
if os.path.exists(agency_base_path):
with open(agency_base_path, 'r') as f:
content = f.read()
# Check that it extends base.html
self.assertIn("{% extends 'base.html' %}", content)
# Check that it has agency-specific navigation
self.assertIn('agency_portal_dashboard', content)
self.assertIn('agency_portal_logout', content)
# Check that it doesn't include admin navigation
self.assertNotIn('admin:', content)
print("✓ agency_base.html properly configured")
else:
self.fail("agency_base.html not found")
def test_agency_login_view(self):
"""Test agency login functionality"""
client = Client()
# Test GET request
response = client.get(reverse('agency_portal_login'))
self.assertEqual(response.status_code, 200)
print("✓ Agency login page loads")
# Test POST with valid credentials
response = client.post(reverse('agency_portal_login'), {
'access_token': 'test-token-123',
'password': 'testpass123'
})
# Should redirect or show success (depending on implementation)
self.assertIn(response.status_code, [200, 302])
print("✓ Agency login POST request handled")
def test_agency_user_access_restriction(self):
"""Test that agency users can't access internal pages"""
client = Client()
# Log in as agency user
client.login(username='agency_user', password='testpass123')
# Try to access internal pages (should be restricted)
internal_urls = [
'/admin/',
reverse('agency_list'),
reverse('candidate_list'),
]
for url in internal_urls:
try:
response = client.get(url)
# Agency users should get redirected or forbidden
self.assertIn(response.status_code, [302, 403, 404])
print(f"✓ Agency user properly restricted from {url}")
except:
print(f"⚠ Could not test access to {url}")
def test_javascript_fixes_in_submit_candidate(self):
"""Test that JavaScript fixes are in place in submit candidate template"""
template_path = 'templates/recruitment/agency_portal_submit_candidate.html'
if os.path.exists(template_path):
with open(template_path, 'r') as f:
content = f.read()
# Check for safe element access patterns
self.assertIn('getElementValue', content)
self.assertIn('if (element)', content)
# Check for error handling
self.assertIn('console.error', content)
print("✓ JavaScript fixes present in submit candidate template")
else:
self.fail("agency_portal_submit_candidate.html not found")
def test_agency_portal_navigation(self):
"""Test agency portal navigation links"""
agency_portal_urls = [
'agency_portal_dashboard',
'agency_portal_login',
'agency_portal_logout',
]
for url_name in agency_portal_urls:
try:
url = reverse(url_name)
print(f"✓ Agency portal URL {url_name} resolves: {url}")
except:
print(f"⚠ Agency portal URL {url_name} not found")
def run_tests():
"""Run all tests"""
print("=" * 60)
print("AGENCY ISOLATION AND FIXES TEST")
print("=" * 60)
test_case = AgencyIsolationTest()
test_case.setUp()
tests = [
test_case.test_agency_login_form_attribute_error_fix,
test_case.test_agency_portal_templates_use_agency_base,
test_case.test_agency_base_template_isolation,
test_case.test_agency_login_view,
test_case.test_agency_user_access_restriction,
test_case.test_javascript_fixes_in_submit_candidate,
test_case.test_agency_portal_navigation,
]
passed = 0
failed = 0
for test in tests:
try:
test()
passed += 1
except Exception as e:
print(f"{test.__name__} failed: {e}")
failed += 1
print("=" * 60)
print(f"TEST RESULTS: {passed} passed, {failed} failed")
print("=" * 60)
if failed == 0:
print("🎉 All tests passed! Agency isolation is working properly.")
else:
print("⚠️ Some tests failed. Please review the issues above.")
return failed == 0
if __name__ == '__main__':
success = run_tests()
sys.exit(0 if success else 1)

View File

@ -1,105 +0,0 @@
#!/usr/bin/env python
"""
Test script for async email functionality
"""
import os
import sys
import django
# Setup Django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'NorahUniversity.settings')
django.setup()
from django.contrib.auth.models import User
from recruitment.email_service import send_bulk_email
from recruitment.models import JobPosting, Candidate
def test_async_email():
"""Test async email sending functionality"""
print("🧪 Testing Async Email Functionality")
print("=" * 50)
try:
# Get a test user
test_user = User.objects.first()
if not test_user:
print("❌ No users found in database. Please create a user first.")
return
# Get a test job and candidate
test_job = JobPosting.objects.first()
test_candidate = Candidate.objects.first()
if not test_job or not test_candidate:
print("❌ No test job or candidate found. Please create some test data first.")
return
print(f"📧 Test User: {test_user.email}")
print(f"💼 Test Job: {test_job.title}")
print(f"👤 Test Candidate: {test_candidate.name}")
# Test synchronous email sending
print("\n1. Testing Synchronous Email Sending...")
try:
sync_result = send_bulk_email(
subject="Test Synchronous Email",
message="This is a test synchronous email from the ATS system.",
recipient_list=[test_user.email],
async_task=False
)
print(f" ✅ Sync result: {sync_result}")
except Exception as e:
print(f" ❌ Sync error: {e}")
# Test asynchronous email sending
print("\n2. Testing Asynchronous Email Sending...")
try:
async_result = send_bulk_email(
subject="Test Asynchronous Email",
message="This is a test asynchronous email from the ATS system.",
recipient_list=[test_user.email],
async_task=True
)
print(f" ✅ Async result: {async_result}")
except Exception as e:
print(f" ❌ Async error: {e}")
print("\n3. Testing Email Service Status...")
# Check Django Q configuration
try:
import django_q
from django_q.models import Task
pending_tasks = Task.objects.count()
print(f" 📊 Django Q Status: Installed, {pending_tasks} tasks in queue")
except ImportError:
print(" ⚠️ Django Q not installed")
except Exception as e:
print(f" 📊 Django Q Status: Installed but error checking status: {e}")
# Check email backend configuration
from django.conf import settings
email_backend = getattr(settings, 'EMAIL_BACKEND', 'not configured')
print(f" 📧 Email Backend: {email_backend}")
email_host = getattr(settings, 'EMAIL_HOST', 'not configured')
print(f" 🌐 Email Host: {email_host}")
email_port = getattr(settings, 'EMAIL_PORT', 'not configured')
print(f" 🔌 Email Port: {email_port}")
print("\n✅ Async email functionality test completed!")
print("💡 If emails are not being received, check:")
print(" - Email server configuration in settings.py")
print(" - Django Q cluster status (python manage.py qmonitor)")
print(" - Email logs and spam folders")
except Exception as e:
print(f"❌ Test failed with error: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
test_async_email()

View File

@ -1,131 +0,0 @@
#!/usr/bin/env python
"""
Test script to verify CSV export functionality with updated JSON structure
"""
import os
import sys
import django
# Setup Django environment
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'NorahUniversity.settings')
django.setup()
from recruitment.models import Candidate, JobPosting
from recruitment.views_frontend import export_candidates_csv
from django.test import RequestFactory
from django.contrib.auth.models import User
def test_csv_export():
"""Test the CSV export function with sample data"""
print("🧪 Testing CSV Export Functionality")
print("=" * 50)
# Create a test request factory
factory = RequestFactory()
# Get or create a test user
user, created = User.objects.get_or_create(
username='testuser',
defaults={'email': 'test@example.com', 'is_staff': True}
)
# Get a sample job
job = JobPosting.objects.first()
if not job:
print("❌ No jobs found in database. Please create a job first.")
return False
print(f"📋 Using job: {job.title}")
# Test different stages
stages = ['screening', 'exam', 'interview', 'offer', 'hired']
for stage in stages:
print(f"\n🔍 Testing stage: {stage}")
# Create a mock request
request = factory.get(f'/export/{job.slug}/{stage}/')
request.user = user
request.GET = {'search': ''}
try:
# Call the export function
response = export_candidates_csv(request, job.slug, stage)
# Check if response is successful
if response.status_code == 200:
print(f"{stage} export successful")
# Read and analyze the CSV content
content = response.content.decode('utf-8-sig')
lines = content.split('\n')
if len(lines) > 1:
headers = lines[0].split(',')
print(f"📊 Headers: {len(headers)} columns")
print(f"📊 Data rows: {len(lines) - 1}")
# Check for AI score column
if 'Match Score' in headers:
print("✅ Match Score column found")
else:
print("⚠️ Match Score column not found")
# Check for other AI columns
ai_columns = ['Years Experience', 'Screening Rating', 'Professional Category', 'Top 3 Skills']
found_ai_columns = [col for col in ai_columns if col in headers]
print(f"🤖 AI columns found: {found_ai_columns}")
else:
print("⚠️ No data rows found")
else:
print(f"{stage} export failed with status: {response.status_code}")
except Exception as e:
print(f"{stage} export error: {str(e)}")
import traceback
traceback.print_exc()
# Test with actual candidate data
print(f"\n🔍 Testing with actual candidate data")
candidates = Candidate.objects.filter(job=job)
print(f"📊 Total candidates for job: {candidates.count()}")
if candidates.exists():
# Test AI data extraction for first candidate
candidate = candidates.first()
print(f"\n🧪 Testing AI data extraction for: {candidate.name}")
try:
# Test the model properties
print(f"📊 Match Score: {candidate.match_score}")
print(f"📊 Years Experience: {candidate.years_of_experience}")
print(f"📊 Screening Rating: {candidate.screening_stage_rating}")
print(f"📊 Professional Category: {candidate.professional_category}")
print(f"📊 Top 3 Skills: {candidate.top_3_keywords}")
print(f"📊 Strengths: {candidate.strengths}")
print(f"📊 Weaknesses: {candidate.weaknesses}")
# Test AI analysis data structure
if candidate.ai_analysis_data:
print(f"📊 AI Analysis Data keys: {list(candidate.ai_analysis_data.keys())}")
if 'analysis_data' in candidate.ai_analysis_data:
analysis_keys = list(candidate.ai_analysis_data['analysis_data'].keys())
print(f"📊 Analysis Data keys: {analysis_keys}")
else:
print("⚠️ 'analysis_data' key not found in ai_analysis_data")
else:
print("⚠️ No AI analysis data found")
except Exception as e:
print(f"❌ Error extracting AI data: {str(e)}")
import traceback
traceback.print_exc()
print("\n🎉 CSV Export Test Complete!")
return True
if __name__ == '__main__':
test_csv_export()

File diff suppressed because one or more lines are too long

View File

@ -1,267 +0,0 @@
#!/usr/bin/env python
"""
Clean test script for email attachment functionality
"""
import os
import sys
import django
from django.conf import settings
# Configure Django settings BEFORE importing any Django modules
if not settings.configured:
settings.configure(
DEBUG=True,
DATABASES={
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': ':memory:',
}
},
USE_TZ=True,
SECRET_KEY='test-secret-key',
INSTALLED_APPS=[
'django.contrib.contenttypes',
'django.contrib.auth',
'django.contrib.sessions',
'recruitment',
],
EMAIL_BACKEND='django.core.mail.backends.console.EmailBackend',
MEDIA_ROOT='/tmp/test_media',
FILE_UPLOAD_TEMP_DIR='/tmp/test_uploads',
)
# Setup Django
django.setup()
# Now import Django modules
from django.test import TestCase, Client
from django.core.files.uploadedfile import SimpleUploadedFile
from django.core.files.base import ContentFile
import io
from unittest.mock import Mock
from recruitment.email_service import send_bulk_email
from recruitment.forms import CandidateEmailForm
from recruitment.models import JobPosting, Candidate
from django.test import RequestFactory
from django.contrib.auth.models import User
# Setup test database
from django.db import connection
from django.core.management import execute_from_command_line
def setup_test_data():
"""Create test data for email attachment testing"""
# Create test user
user = User.objects.create_user(
username='testuser',
email='test@example.com',
first_name='Test',
last_name='User'
)
# Create test job
job = JobPosting.objects.create(
title='Test Job Position',
description='This is a test job for email attachment testing.',
status='ACTIVE',
internal_job_id='TEST-001'
)
# Create test candidate
candidate = Candidate.objects.create(
first_name='John',
last_name='Doe',
email='john.doe@example.com',
phone='+1234567890',
address='123 Test Street',
job=job,
stage='Interview'
)
return user, job, candidate
def test_email_service_with_attachments():
"""Test the email service directly with attachments"""
print("Testing email service with attachments...")
# Create test files
test_files = []
# Test 1: Simple text file
text_content = "This is a test attachment content."
text_file = ContentFile(
text_content.encode('utf-8'),
name='test_document.txt'
)
test_files.append(('test_document.txt', text_file, 'text/plain'))
# Test 2: PDF content (simulated)
pdf_content = b'%PDF-1.4\n1 0 obj\n<<\n/Length 100\n>>stream\nxref\nstartxref\n1234\n5678\n/ModDate(D:20250101)\n'
pdf_file = ContentFile(
pdf_content,
name='test_document.pdf'
)
test_files.append(('test_document.pdf', pdf_file, 'application/pdf'))
# Test 3: Image file (simulated PNG header)
image_content = b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01'
image_file = ContentFile(
image_content,
name='test_image.png'
)
test_files.append(('test_image.png', image_file, 'image/png'))
try:
# Test email service with attachments
result = send_bulk_email(
subject='Test Email with Attachments',
body='This is a test email with attachments.',
from_email='test@example.com',
recipient_list=['recipient@example.com'],
attachments=test_files
)
print(f"Email service result: {result}")
print("✓ Email service with attachments test passed")
return True
except Exception as e:
print(f"✗ Email service test failed: {e}")
return False
def test_candidate_email_form_with_attachments():
"""Test the CandidateEmailForm with attachments"""
print("\nTesting CandidateEmailForm with attachments...")
user, job, candidate = setup_test_data()
# Create test files for form
text_file = SimpleUploadedFile(
"test.txt",
b"This is test content for form attachment"
)
pdf_file = SimpleUploadedFile(
"test.pdf",
b"%PDF-1.4 test content"
)
form_data = {
'subject': 'Test Subject',
'body': 'Test body content',
'from_email': 'test@example.com',
'recipient_list': 'recipient@example.com',
}
files_data = {
'attachments': [text_file, pdf_file]
}
try:
form = CandidateEmailForm(data=form_data, files=files_data)
if form.is_valid():
print("✓ Form validation passed")
print(f"Form cleaned data: {form.cleaned_data}")
# Test form processing
try:
result = form.save()
print(f"✓ Form save result: {result}")
return True
except Exception as e:
print(f"✗ Form save failed: {e}")
return False
else:
print(f"✗ Form validation failed: {form.errors}")
return False
except Exception as e:
print(f"✗ Form test failed: {e}")
return False
def test_email_view_with_attachments():
"""Test the email view with attachments"""
print("\nTesting email view with attachments...")
user, job, candidate = setup_test_data()
factory = RequestFactory()
# Create a mock request with files
text_file = SimpleUploadedFile(
"test.txt",
b"This is test content for view attachment"
)
request = factory.post(
'/recruitment/send-candidate-email/',
data={
'subject': 'Test Subject',
'body': 'Test body content',
'from_email': 'test@example.com',
'recipient_list': 'recipient@example.com',
},
format='multipart'
)
request.FILES['attachments'] = [text_file]
request.user = user
try:
# Import and test the view
from recruitment.views import send_candidate_email
response = send_candidate_email(request)
print(f"View response status: {response.status_code}")
if response.status_code == 200:
print("✓ Email view test passed")
return True
else:
print(f"✗ Email view test failed with status: {response.status_code}")
return False
except Exception as e:
print(f"✗ Email view test failed: {e}")
return False
def main():
"""Run all email attachment tests"""
print("=" * 60)
print("EMAIL ATTACHMENT FUNCTIONALITY TESTS")
print("=" * 60)
# Initialize Django
django.setup()
# Create tables
from django.core.management import execute_from_command_line
execute_from_command_line(['manage.py', 'migrate', '--run-syncdb'])
results = []
# Run tests
results.append(test_email_service_with_attachments())
results.append(test_candidate_email_form_with_attachments())
results.append(test_email_view_with_attachments())
# Summary
print("\n" + "=" * 60)
print("TEST SUMMARY")
print("=" * 60)
passed = sum(results)
total = len(results)
print(f"Tests passed: {passed}/{total}")
if passed == total:
print("🎉 All email attachment tests passed!")
return True
else:
print("❌ Some email attachment tests failed!")
return False
if __name__ == '__main__':
success = main()
exit(0 if success else 1)

View File

@ -1,218 +0,0 @@
#!/usr/bin/env python3
"""
Test script to verify email composition functionality
"""
import os
import sys
import django
# Setup Django environment
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'NorahUniversity.settings')
django.setup()
from django.test import TestCase, Client
from django.urls import reverse
from django.contrib.auth.models import User
from django.utils import timezone
from recruitment.models import JobPosting, Candidate
from unittest.mock import patch, MagicMock
def test_email_composition_view():
"""Test the email composition view"""
print("Testing email composition view...")
# Create test user (delete if exists)
User.objects.filter(username='testuser').delete()
user = User.objects.create_user(
username='testuser',
email='test@example.com',
password='testpass123'
)
# Create test job
job = JobPosting.objects.create(
title='Test Job',
internal_job_id='TEST001',
description='Test job description',
status='active',
application_deadline=timezone.now() + timezone.timedelta(days=30)
)
# Add user to job participants so they appear in recipient choices
job.users.add(user)
# Create test candidate
candidate = Candidate.objects.create(
first_name='Test Candidate',
last_name='',
email='candidate@example.com',
phone='1234567890',
job=job
)
# Create client and login
client = Client()
client.login(username='testuser', password='testpass123')
# Test GET request to email composition view
url = reverse('compose_candidate_email', kwargs={
'job_slug': job.slug,
'candidate_slug': candidate.slug
})
try:
response = client.get(url)
print(f"✓ GET request successful: {response.status_code}")
if response.status_code == 200:
print("✓ Email composition form rendered successfully")
# Check if form contains expected fields
content = response.content.decode('utf-8')
if 'subject' in content.lower():
print("✓ Subject field found in form")
if 'message' in content.lower():
print("✓ Message field found in form")
if 'recipients' in content.lower():
print("✓ Recipients field found in form")
else:
print(f"✗ Unexpected status code: {response.status_code}")
except Exception as e:
print(f"✗ Error testing GET request: {e}")
# Test POST request with mock email sending
post_data = {
'subject': 'Test Subject',
'message': 'Test message content',
'recipients': ['candidate@example.com'],
'include_candidate_info': True,
'include_meeting_details': False
}
with patch('django.core.mail.send_mass_mail') as mock_send_mail:
mock_send_mail.return_value = 1
try:
response = client.post(url, data=post_data)
print(f"✓ POST request successful: {response.status_code}")
if response.status_code == 200:
# Check if JSON response is correct
try:
json_data = response.json()
if json_data.get('success'):
print("✓ Email sent successfully")
print(f"✓ Success message: {json_data.get('message')}")
else:
print(f"✗ Email send failed: {json_data.get('error')}")
except:
print("✗ Invalid JSON response")
else:
print(f"✗ Unexpected status code: {response.status_code}")
except Exception as e:
print(f"✗ Error testing POST request: {e}")
# Clean up
user.delete()
job.delete()
candidate.delete()
print("Email composition test completed!")
def test_email_form():
"""Test the CandidateEmailForm"""
print("\nTesting CandidateEmailForm...")
from recruitment.forms import CandidateEmailForm
# Create test user for form (delete if exists)
User.objects.filter(username='formuser').delete()
form_user = User.objects.create_user(
username='formuser',
email='form@example.com',
password='formpass123'
)
# Create test job and candidate for form
job = JobPosting.objects.create(
title='Test Job Form',
internal_job_id='TEST002',
description='Test job description for form',
status='active',
application_deadline=timezone.now() + timezone.timedelta(days=30)
)
# Add user to job participants so they appear in recipient choices
job.users.add(form_user)
candidate = Candidate.objects.create(
first_name='Test Candidate',
last_name='Form',
email='candidate_form@example.com',
phone='1234567890',
job=job
)
try:
# Test valid form data - get available choices from form
form = CandidateEmailForm(job, candidate)
available_choices = [choice[0] for choice in form.fields['recipients'].choices]
# Use first available choice for testing
test_recipient = available_choices[0] if available_choices else None
if test_recipient:
form = CandidateEmailForm(job, candidate, data={
'subject': 'Test Subject',
'message': 'Test message content',
'recipients': [test_recipient],
'include_candidate_info': True,
'include_meeting_details': False
})
if form.is_valid():
print("✓ Form validation passed")
print(f"✓ Cleaned recipients: {form.cleaned_data['recipients']}")
else:
print(f"✗ Form validation failed: {form.errors}")
else:
print("✗ No recipient choices available for testing")
except Exception as e:
print(f"✗ Error testing form: {e}")
try:
# Test invalid form data (empty subject)
form = CandidateEmailForm(job, candidate, data={
'subject': '',
'message': 'Test message content',
'recipients': [],
'include_candidate_info': True,
'include_meeting_details': False
})
if not form.is_valid():
print("✓ Form correctly rejected empty subject")
if 'subject' in form.errors:
print("✓ Subject field has validation error")
else:
print("✗ Form should have failed validation")
except Exception as e:
print(f"✗ Error testing invalid form: {e}")
# Clean up
job.delete()
candidate.delete()
if __name__ == '__main__':
print("Running Email Composition Tests")
print("=" * 50)
test_email_form()
test_email_composition_view()
print("\n" + "=" * 50)
print("All tests completed!")

View File

@ -1,507 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Email Compose Form Test</title>
<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">
</head>
<body>
<div class="container mt-4">
<h1>Email Compose Form JavaScript Test</h1>
<!-- Mock form for testing JavaScript -->
<div class="container-fluid">
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header bg-primary text-white">
<h5 class="mb-0">
<i class="fas fa-envelope me-2"></i>
Compose Email
</h5>
</div>
<div class="card-body">
<form method="post" id="email-compose-form" action="/test/">
<input type="hidden" name="csrfmiddlewaretoken" value="test-token">
<!-- Subject Field -->
<div class="mb-3">
<label for="id_subject" class="form-label fw-bold">
Subject
</label>
<input type="text" class="form-control" id="id_subject" name="subject">
</div>
<!-- Recipients Field -->
<div class="mb-3">
<label class="form-label fw-bold">
Recipients
</label>
<div class="border rounded p-3 bg-light" style="max-height: 200px; overflow-y: auto;">
<div class="form-check mb-2">
<input type="checkbox" class="form-check-input" name="recipients" value="user1@example.com" id="recipient1">
<label class="form-check-label" for="recipient1">user1@example.com</label>
</div>
<div class="form-check mb-2">
<input type="checkbox" class="form-check-input" name="recipients" value="user2@example.com" id="recipient2">
<label class="form-check-label" for="recipient2">user2@example.com</label>
</div>
</div>
</div>
<!-- Message Field -->
<div class="mb-3">
<label for="id_message" class="form-label fw-bold">
Message
</label>
<textarea class="form-control" id="id_message" name="message" rows="5"></textarea>
</div>
<!-- Options Checkboxes -->
<div class="mb-4">
<div class="row">
<div class="col-md-6">
<div class="form-check">
<input type="checkbox" class="form-check-input" name="include_candidate_info" id="id_include_candidate_info">
<label class="form-check-label" for="id_include_candidate_info">
Include candidate information
</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check">
<input type="checkbox" class="form-check-input" name="include_meeting_details" id="id_include_meeting_details">
<label class="form-check-label" for="id_include_meeting_details">
Include meeting details
</label>
</div>
</div>
</div>
</div>
<!-- Form Actions -->
<div class="d-flex justify-content-between align-items-center">
<div class="text-muted small">
<i class="fas fa-info-circle me-1"></i>
Email will be sent to all selected recipients
</div>
<div>
<button type="button" class="btn btn-secondary me-2" data-bs-dismiss="modal">
<i class="fas fa-times me-1"></i>
Cancel
</button>
<button type="submit" class="btn btn-primary" id="send-email-btn">
<i class="fas fa-paper-plane me-1"></i>
Send Email
</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Loading Overlay -->
<div id="email-loading-overlay" class="d-none">
<div class="d-flex justify-content-center align-items-center" style="min-height: 200px;">
<div class="text-center">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<div class="mt-2">
Sending email...
</div>
</div>
</div>
</div>
<!-- Success/Error Messages Container -->
<div id="email-messages-container"></div>
</div>
<!-- Test Results -->
<div class="mt-4">
<h3>Test Results</h3>
<div id="test-results"></div>
</div>
</div>
<style>
.card {
border: none;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
border-radius: 8px;
}
.card-header {
border-radius: 8px 8px 0 0 !important;
border-bottom: none;
}
.form-control:focus {
border-color: #00636e;
box-shadow: 0 0 0 0.2rem rgba(0,99,110,0.25);
}
.btn-primary {
background-color: #00636e;
border-color: #00636e;
}
.btn-primary:hover {
background-color: #004a53;
border-color: #004a53;
}
.form-check-input:checked {
background-color: #00636e;
border-color: #00636e;
}
.border {
border-color: #dee2e6 !important;
}
.bg-light {
background-color: #f8f9fa !important;
}
.text-danger {
color: #dc3545 !important;
}
.spinner-border {
width: 3rem;
height: 3rem;
}
</style>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('email-compose-form');
const sendBtn = document.getElementById('send-email-btn');
const loadingOverlay = document.getElementById('email-loading-overlay');
const messagesContainer = document.getElementById('email-messages-container');
const testResults = document.getElementById('test-results');
// Test results tracking
let tests = [];
function addTestResult(testName, passed, message) {
tests.push({ name: testName, passed, message });
updateTestResults();
}
function updateTestResults() {
const passedTests = tests.filter(t => t.passed).length;
const totalTests = tests.length;
testResults.innerHTML = `
<div class="alert alert-info">
<strong>Tests: ${passedTests}/${totalTests} passed</strong>
</div>
<ul class="list-group">
${tests.map(test => `
<li class="list-group-item ${test.passed ? 'list-group-item-success' : 'list-group-item-danger'}">
<i class="fas fa-${test.passed ? 'check' : 'times'} me-2"></i>
<strong>${test.name}:</strong> ${test.message}
</li>
`).join('')}
</ul>
`;
}
if (form) {
form.addEventListener('submit', function(e) {
e.preventDefault();
addTestResult('Form Submit Handler', true, 'Form submission intercepted successfully');
// Show loading state
if (sendBtn) {
sendBtn.disabled = true;
sendBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i> Sending...';
addTestResult('Loading State', true, 'Button loading state activated');
}
if (loadingOverlay) {
loadingOverlay.classList.remove('d-none');
addTestResult('Loading Overlay', true, 'Loading overlay displayed');
}
// Clear previous messages
if (messagesContainer) {
messagesContainer.innerHTML = '';
}
// Mock form submission
setTimeout(() => {
// Hide loading state
if (sendBtn) {
sendBtn.disabled = false;
sendBtn.innerHTML = '<i class="fas fa-paper-plane me-1"></i> Send Email';
}
if (loadingOverlay) {
loadingOverlay.classList.add('d-none');
}
// Show success message
showMessage('Email sent successfully!', 'success');
addTestResult('Success Message', true, 'Success message displayed');
}, 2000);
});
}
function showMessage(message, type) {
if (!messagesContainer) return;
const alertClass = type === 'success' ? 'alert-success' : 'alert-danger';
const icon = type === 'success' ? 'fa-check-circle' : 'fa-exclamation-triangle';
const messageHtml = `
<div class="alert ${alertClass} alert-dismissible fade show" role="alert">
<i class="fas ${icon} me-2"></i>
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
`;
messagesContainer.innerHTML = messageHtml;
// Auto-hide success messages after 5 seconds
if (type === 'success') {
setTimeout(() => {
const alert = messagesContainer.querySelector('.alert');
if (alert) {
const bsAlert = bootstrap.Alert(alert);
bsAlert.close();
addTestResult('Auto-hide Message', true, 'Message auto-hidden after 5 seconds');
}
}, 5000);
}
}
// Form validation
function validateForm() {
let isValid = true;
const subject = form.querySelector('#id_subject');
const message = form.querySelector('#id_message');
const recipients = form.querySelectorAll('input[name="recipients"]:checked');
// Clear previous validation states
form.querySelectorAll('.is-invalid').forEach(field => {
field.classList.remove('is-invalid');
});
form.querySelectorAll('.invalid-feedback').forEach(feedback => {
feedback.remove();
});
// Validate subject
if (!subject || !subject.value.trim()) {
showFieldError(subject, 'Subject is required');
isValid = false;
addTestResult('Subject Validation', false, 'Subject validation triggered - field empty');
} else {
addTestResult('Subject Validation', true, 'Subject validation passed');
}
// Validate message
if (!message || !message.value.trim()) {
showFieldError(message, 'Message is required');
isValid = false;
addTestResult('Message Validation', false, 'Message validation triggered - field empty');
} else {
addTestResult('Message Validation', true, 'Message validation passed');
}
// Validate recipients
if (recipients.length === 0) {
const recipientsContainer = form.querySelector('.border.rounded.p-3.bg-light');
if (recipientsContainer) {
recipientsContainer.classList.add('border-danger');
showFieldError(recipientsContainer, 'Please select at least one recipient');
}
isValid = false;
addTestResult('Recipients Validation', false, 'Recipients validation triggered - none selected');
} else {
addTestResult('Recipients Validation', true, `Recipients validation passed - ${recipients.length} selected`);
}
return isValid;
}
function showFieldError(field, message) {
if (!field) return;
field.classList.add('is-invalid');
const feedback = document.createElement('div');
feedback.className = 'invalid-feedback';
feedback.textContent = message;
if (field.classList.contains('border')) {
// For container elements (like recipients)
field.parentNode.appendChild(feedback);
} else {
// For form fields
field.parentNode.appendChild(feedback);
}
}
// Character counter for message field
function setupCharacterCounter() {
const messageField = form.querySelector('#id_message');
if (!messageField) return;
const counter = document.createElement('div');
counter.className = 'text-muted small mt-1';
counter.id = 'message-counter';
messageField.parentNode.appendChild(counter);
function updateCounter() {
const length = messageField.value.length;
const maxLength = 5000; // Adjust as needed
counter.textContent = `${length} / ${maxLength} characters`;
if (length > maxLength * 0.9) {
counter.classList.add('text-warning');
counter.classList.remove('text-muted');
} else {
counter.classList.remove('text-warning');
counter.classList.add('text-muted');
}
}
messageField.addEventListener('input', updateCounter);
updateCounter(); // Initial count
addTestResult('Character Counter', true, 'Character counter initialized');
}
// Auto-save functionality
let autoSaveTimer;
function setupAutoSave() {
const subject = form.querySelector('#id_subject');
const message = form.querySelector('#id_message');
if (!subject || !message) return;
function saveDraft() {
const draftData = {
subject: subject.value,
message: message.value,
recipients: Array.from(form.querySelectorAll('input[name="recipients"]:checked')).map(cb => cb.value),
include_candidate_info: form.querySelector('#id_include_candidate_info').checked,
include_meeting_details: form.querySelector('#id_include_meeting_details').checked
};
localStorage.setItem('email_draft_test', JSON.stringify(draftData));
addTestResult('Auto-save', true, 'Draft saved to localStorage');
}
function autoSave() {
clearTimeout(autoSaveTimer);
autoSaveTimer = setTimeout(saveDraft, 2000); // Save after 2 seconds of inactivity
}
[subject, message].forEach(field => {
field.addEventListener('input', autoSave);
});
form.addEventListener('change', autoSave);
addTestResult('Auto-save Setup', true, 'Auto-save functionality initialized');
}
function loadDraft() {
const draftData = localStorage.getItem('email_draft_test');
if (!draftData) return;
try {
const draft = JSON.parse(draftData);
const subject = form.querySelector('#id_subject');
const message = form.querySelector('#id_message');
if (subject && draft.subject) subject.value = draft.subject;
if (message && draft.message) message.value = draft.message;
// Restore recipients
if (draft.recipients) {
form.querySelectorAll('input[name="recipients"]').forEach(cb => {
cb.checked = draft.recipients.includes(cb.value);
});
}
// Restore checkboxes
if (draft.include_candidate_info) {
form.querySelector('#id_include_candidate_info').checked = draft.include_candidate_info;
}
if (draft.include_meeting_details) {
form.querySelector('#id_include_meeting_details').checked = draft.include_meeting_details;
}
addTestResult('Draft Loading', true, 'Draft loaded from localStorage');
showMessage('Draft restored from local storage', 'success');
} catch (e) {
console.error('Error loading draft:', e);
addTestResult('Draft Loading', false, 'Error loading draft: ' + e.message);
}
}
function clearDraft() {
localStorage.removeItem('email_draft_test');
}
// Initialize form enhancements
setupCharacterCounter();
setupAutoSave();
// Load draft on page load
setTimeout(loadDraft, 100);
// Clear draft on successful submission
const originalSubmitHandler = form.onsubmit;
form.addEventListener('submit', function(e) {
const isValid = validateForm();
if (!isValid) {
e.preventDefault();
e.stopPropagation();
return false;
}
// Clear draft on successful submission
setTimeout(clearDraft, 2000);
});
// Add keyboard shortcuts
document.addEventListener('keydown', function(e) {
// Ctrl/Cmd + Enter to submit
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
const activeElement = document.activeElement;
if (activeElement && (activeElement.tagName === 'TEXTAREA' || activeElement.tagName === 'INPUT')) {
form.dispatchEvent(new Event('submit'));
addTestResult('Keyboard Shortcut', true, 'Ctrl+Enter shortcut triggered');
}
}
// Escape to cancel/close modal
if (e.key === 'Escape') {
addTestResult('Keyboard Shortcut', true, 'Escape key pressed');
}
});
// Test validation with empty form
setTimeout(() => {
addTestResult('Initial Validation Test', validateForm() === false, 'Empty form correctly rejected');
}, 500);
console.log('Email compose form initialized');
addTestResult('Initialization', true, 'Email compose form JavaScript initialized successfully');
});
</script>
</body>
</html>

View File

@ -1,176 +0,0 @@
#!/usr/bin/env python
"""
Test script for HTML email template functionality
"""
import os
import sys
import django
# Setup Django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'NorahUniversity.settings')
django.setup()
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from recruitment.models import Candidate, JobPosting
from recruitment.email_service import send_interview_invitation_email
def test_html_template():
"""Test the HTML email template rendering"""
print("Testing HTML email template...")
# Create test context
context = {
'candidate_name': 'John Doe',
'candidate_email': 'john.doe@example.com',
'candidate_phone': '+966 50 123 4567',
'job_title': 'Senior Software Developer',
'department': 'Information Technology',
'company_name': 'Norah University',
'meeting_topic': 'Interview for Senior Software Developer',
'meeting_date_time': 'November 15, 2025 at 2:00 PM',
'meeting_duration': '60 minutes',
'join_url': 'https://zoom.us/j/123456789',
}
try:
# Test template rendering
html_content = render_to_string('emails/interview_invitation.html', context)
plain_content = strip_tags(html_content)
print("✅ HTML template rendered successfully!")
print(f"HTML content length: {len(html_content)} characters")
print(f"Plain text length: {len(plain_content)} characters")
# Save rendered HTML to file for inspection
with open('test_interview_email.html', 'w', encoding='utf-8') as f:
f.write(html_content)
print("✅ HTML content saved to 'test_interview_email.html'")
# Save plain text to file for inspection
with open('test_interview_email.txt', 'w', encoding='utf-8') as f:
f.write(plain_content)
print("✅ Plain text content saved to 'test_interview_email.txt'")
return True
except Exception as e:
print(f"❌ Error rendering template: {e}")
return False
def test_email_service_function():
"""Test the email service function with mock data"""
print("\nTesting email service function...")
try:
# Get a real candidate and job for testing
candidate = Candidate.objects.first()
job = JobPosting.objects.first()
if not candidate:
print("❌ No candidates found in database")
return False
if not job:
print("❌ No jobs found in database")
return False
print(f"Using candidate: {candidate.name}")
print(f"Using job: {job.title}")
# Test meeting details
meeting_details = {
'topic': f'Interview for {job.title}',
'date_time': 'November 15, 2025 at 2:00 PM',
'duration': '60 minutes',
'join_url': 'https://zoom.us/j/test123456',
}
# Test the email function (without actually sending)
result = send_interview_invitation_email(
candidate=candidate,
job=job,
meeting_details=meeting_details,
recipient_list=['test@example.com']
)
if result['success']:
print("✅ Email service function executed successfully!")
print(f"Recipients: {result.get('recipients_count', 'N/A')}")
print(f"Message: {result.get('message', 'N/A')}")
else:
print(f"❌ Email service function failed: {result.get('error', 'Unknown error')}")
return result['success']
except Exception as e:
print(f"❌ Error testing email service: {e}")
return False
def test_template_variables():
"""Test all template variables"""
print("\nTesting template variables...")
# Test with minimal data
minimal_context = {
'candidate_name': 'Test Candidate',
'candidate_email': 'test@example.com',
'job_title': 'Test Position',
}
try:
html_content = render_to_string('emails/interview_invitation.html', minimal_context)
print("✅ Template works with minimal data")
# Check for required variables
required_vars = ['candidate_name', 'candidate_email', 'job_title']
missing_vars = []
for var in required_vars:
if f'{{ {var} }}' in html_content:
missing_vars.append(var)
if missing_vars:
print(f"⚠️ Missing variables: {missing_vars}")
else:
print("✅ All required variables are present")
return True
except Exception as e:
print(f"❌ Error with minimal data: {e}")
return False
def main():
"""Run all tests"""
print("🧪 Testing HTML Email Template System")
print("=" * 50)
# Test 1: Template rendering
test1_passed = test_html_template()
# Test 2: Template variables
test2_passed = test_template_variables()
# Test 3: Email service function
test3_passed = test_email_service_function()
# Summary
print("\n" + "=" * 50)
print("📊 TEST SUMMARY")
print(f"Template Rendering: {'✅ PASS' if test1_passed else '❌ FAIL'}")
print(f"Template Variables: {'✅ PASS' if test2_passed else '❌ FAIL'}")
print(f"Email Service: {'✅ PASS' if test3_passed else '❌ FAIL'}")
overall_success = test1_passed and test2_passed and test3_passed
print(f"\nOverall Result: {'✅ ALL TESTS PASSED' if overall_success else '❌ SOME TESTS FAILED'}")
if overall_success:
print("\n🎉 HTML email template system is ready!")
print("You can now send professional interview invitations using the new template.")
else:
print("\n🔧 Please fix the issues before using the template system.")
if __name__ == '__main__':
main()

View File

@ -1,139 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Interview Invitation</title>
<style>
/* Basic reset and typography */
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333333;
margin: 0;
padding: 0;
background-color: #f4f4f4;
}
/* Container for the main content */
.container {
max-width: 600px;
margin: 20px auto;
background-color: #ffffff;
padding: 30px;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
/* Header styling */
.header {
text-align: center;
border-bottom: 2px solid #007bff;
padding-bottom: 10px;
margin-bottom: 20px;
}
.header h1 {
color: #007bff;
font-size: 24px;
margin: 0;
}
/* Section headings */
.section-header {
color: #007bff;
font-size: 18px;
margin-top: 25px;
margin-bottom: 10px;
border-left: 4px solid #007bff;
padding-left: 10px;
}
/* Key detail rows */
.detail-row {
margin-bottom: 10px;
}
.detail-row strong {
display: inline-block;
width: 120px;
color: #555555;
}
/* Button style for the Join URL */
.button {
display: block;
width: 80%;
margin: 25px auto;
padding: 12px 0;
background-color: #28a745; /* Success/Go color */
color: #ffffff;
text-align: center;
text-decoration: none;
border-radius: 5px;
font-weight: bold;
font-size: 16px;
}
/* Footer/closing section */
.footer {
margin-top: 30px;
padding-top: 15px;
border-top: 1px dashed #cccccc;
text-align: center;
font-size: 14px;
color: #777777;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Interview Confirmation</h1>
</div>
<p>Dear <strong>John Doe</strong>,</p>
<p>Thank you for your interest in the position. We are pleased to invite you to a virtual interview. Please find the details below.</p>
<h2 class="section-header">Interview Details</h2>
<div class="detail-row">
<strong>Topic:</strong> Interview for Senior Software Developer
</div>
<div class="detail-row">
<strong>Date & Time:</strong> <strong>November 15, 2025 at 2:00 PM</strong>
</div>
<div class="detail-row">
<strong>Duration:</strong> 60 minutes
</div>
<a href="https://zoom.us/j/123456789" class="button" target="_blank">
Join Interview Now
</a>
<p style="text-align: center; font-size: 14px; color: #777;">Please click the button above to join the meeting at the scheduled time.</p>
<h2 class="section-header">Your Information</h2>
<div class="detail-row">
<strong>Name:</strong> John Doe
</div>
<div class="detail-row">
<strong>Email:</strong> john.doe@example.com
</div>
<div class="detail-row">
<strong>Phone:</strong> +966 50 123 4567
</div>
<h2 class="section-header">Position Details</h2>
<div class="detail-row">
<strong>Position:</strong> Senior Software Developer
</div>
<div class="detail-row">
<strong>Department:</strong> Information Technology
</div>
<div class="footer">
<p>We look forward to speaking with you.</p>
<p>If you have any questions, please reply to this email.</p>
<p>Best regards,<br>The Norah University Team</p>
</div>
</div>
</body>
</html>

View File

@ -1,139 +0,0 @@
Interview Invitation
/* Basic reset and typography */
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333333;
margin: 0;
padding: 0;
background-color: #f4f4f4;
}
/* Container for the main content */
.container {
max-width: 600px;
margin: 20px auto;
background-color: #ffffff;
padding: 30px;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
/* Header styling */
.header {
text-align: center;
border-bottom: 2px solid #007bff;
padding-bottom: 10px;
margin-bottom: 20px;
}
.header h1 {
color: #007bff;
font-size: 24px;
margin: 0;
}
/* Section headings */
.section-header {
color: #007bff;
font-size: 18px;
margin-top: 25px;
margin-bottom: 10px;
border-left: 4px solid #007bff;
padding-left: 10px;
}
/* Key detail rows */
.detail-row {
margin-bottom: 10px;
}
.detail-row strong {
display: inline-block;
width: 120px;
color: #555555;
}
/* Button style for the Join URL */
.button {
display: block;
width: 80%;
margin: 25px auto;
padding: 12px 0;
background-color: #28a745; /* Success/Go color */
color: #ffffff;
text-align: center;
text-decoration: none;
border-radius: 5px;
font-weight: bold;
font-size: 16px;
}
/* Footer/closing section */
.footer {
margin-top: 30px;
padding-top: 15px;
border-top: 1px dashed #cccccc;
text-align: center;
font-size: 14px;
color: #777777;
}
Interview Confirmation
Dear John Doe,
Thank you for your interest in the position. We are pleased to invite you to a virtual interview. Please find the details below.
Interview Details
Topic: Interview for Senior Software Developer
Date & Time: November 15, 2025 at 2:00 PM
Duration: 60 minutes
Join Interview Now
Please click the button above to join the meeting at the scheduled time.
Your Information
Name: John Doe
Email: john.doe@example.com
Phone: +966 50 123 4567
Position Details
Position: Senior Software Developer
Department: Information Technology
We look forward to speaking with you.
If you have any questions, please reply to this email.
Best regards,The Norah University Team

View File

@ -1,239 +0,0 @@
#!/usr/bin/env python
"""
Simple test script for basic email functionality without attachments
"""
import os
import sys
import django
from django.conf import settings
# Configure Django settings BEFORE importing any Django modules
if not settings.configured:
settings.configure(
DEBUG=True,
DATABASES={
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': ':memory:',
}
},
USE_TZ=True,
SECRET_KEY='test-secret-key',
INSTALLED_APPS=[
'django.contrib.contenttypes',
'django.contrib.auth',
'django.contrib.sessions',
'recruitment',
],
EMAIL_BACKEND='django.core.mail.backends.console.EmailBackend',
)
# Setup Django
django.setup()
# Now import Django modules
from django.test import TestCase, Client
from django.test import RequestFactory
from django.contrib.auth.models import User
from recruitment.email_service import send_bulk_email
from recruitment.forms import CandidateEmailForm
from recruitment.models import JobPosting, Candidate, Participants
def setup_test_data():
"""Create test data for email testing"""
# Create test user (get or create to avoid duplicates)
user, created = User.objects.get_or_create(
username='testuser',
defaults={
'email': 'test@example.com',
'first_name': 'Test',
'last_name': 'User'
}
)
# Create test job
from datetime import datetime, timedelta
job = JobPosting.objects.create(
title='Test Job Position',
description='This is a test job for email testing.',
status='ACTIVE',
internal_job_id='TEST-001',
application_deadline=datetime.now() + timedelta(days=30)
)
# Create test candidate
candidate = Candidate.objects.create(
first_name='John',
last_name='Doe',
email='john.doe@example.com',
phone='+1234567890',
address='123 Test Street',
job=job,
stage='Interview'
)
# Create test participants
participant1 = Participants.objects.create(
name='Alice Smith',
email='alice@example.com',
phone='+1234567891',
designation='Interviewer'
)
participant2 = Participants.objects.create(
name='Bob Johnson',
email='bob@example.com',
phone='+1234567892',
designation='Hiring Manager'
)
# Add participants to job
job.participants.add(participant1, participant2)
return user, job, candidate, [participant1, participant2]
def test_email_service_basic():
"""Test the email service with basic functionality"""
print("Testing basic email service...")
try:
# Test email service without attachments
result = send_bulk_email(
subject='Test Basic Email',
message='This is a test email without attachments.',
recipient_list=['recipient1@example.com', 'recipient2@example.com']
)
print(f"Email service result: {result}")
print("✓ Basic email service test passed")
return True
except Exception as e:
print(f"✗ Basic email service test failed: {e}")
return False
def test_candidate_email_form_basic():
"""Test the CandidateEmailForm without attachments"""
print("\nTesting CandidateEmailForm without attachments...")
user, job, candidate, participants = setup_test_data()
form_data = {
'subject': 'Test Subject',
'message': 'Test body content',
'recipients': [f'participant_{p.id}' for p in participants],
'include_candidate_info': True,
'include_meeting_details': True,
}
try:
form = CandidateEmailForm(data=form_data, job=job, candidate=candidate)
if form.is_valid():
print("✓ Form validation passed")
print(f"Form cleaned data keys: {list(form.cleaned_data.keys())}")
# Test getting email addresses
email_addresses = form.get_email_addresses()
print(f"Email addresses: {email_addresses}")
# Test getting formatted message
formatted_message = form.get_formatted_message()
print(f"Formatted message length: {len(formatted_message)} characters")
return True
else:
print(f"✗ Form validation failed: {form.errors}")
return False
except Exception as e:
print(f"✗ Form test failed: {e}")
return False
def test_email_sending_workflow():
"""Test the complete email sending workflow"""
print("\nTesting complete email sending workflow...")
user, job, candidate, participants = setup_test_data()
form_data = {
'subject': 'Interview Update: John Doe - Test Job Position',
'message': 'Please find the interview update below.',
'recipients': [f'participant_{p.id}' for p in participants],
'include_candidate_info': True,
'include_meeting_details': True,
}
try:
# Create and validate form
form = CandidateEmailForm(data=form_data, job=job, candidate=candidate)
if not form.is_valid():
print(f"✗ Form validation failed: {form.errors}")
return False
# Get email data
subject = form.cleaned_data['subject']
message = form.get_formatted_message()
recipient_emails = form.get_email_addresses()
print(f"Subject: {subject}")
print(f"Recipients: {recipient_emails}")
print(f"Message preview: {message[:200]}...")
# Send email using service
result = send_bulk_email(
subject=subject,
message=message,
recipient_list=recipient_emails
)
print(f"Email sending result: {result}")
print("✓ Complete email workflow test passed")
return True
except Exception as e:
print(f"✗ Email workflow test failed: {e}")
return False
def main():
"""Run all simple email tests"""
print("=" * 60)
print("SIMPLE EMAIL FUNCTIONALITY TESTS")
print("=" * 60)
# Initialize Django
django.setup()
# Create tables
from django.core.management import execute_from_command_line
execute_from_command_line(['manage.py', 'migrate', '--run-syncdb'])
results = []
# Run tests
results.append(test_email_service_basic())
results.append(test_candidate_email_form_basic())
results.append(test_email_sending_workflow())
# Summary
print("\n" + "=" * 60)
print("TEST SUMMARY")
print("=" * 60)
passed = sum(results)
total = len(results)
print(f"Tests passed: {passed}/{total}")
if passed == total:
print("🎉 All simple email tests passed!")
return True
else:
print("❌ Some simple email tests failed!")
return False
if __name__ == '__main__':
success = main()
exit(0 if success else 1)

View File

@ -1,216 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SSE Test</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.status {
padding: 10px;
margin: 10px 0;
border-radius: 5px;
}
.connected {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.disconnected {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.notification {
background-color: #fff3cd;
color: #856404;
border: 1px solid #ffeaa7;
padding: 10px;
margin: 10px 0;
border-radius: 5px;
}
#notifications {
max-height: 400px;
overflow-y: auto;
border: 1px solid #ddd;
padding: 10px;
margin: 10px 0;
}
button {
background-color: #007bff;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
margin: 5px;
}
button:hover {
background-color: #0056b3;
}
button:disabled {
background-color: #6c757d;
cursor: not-allowed;
}
</style>
</head>
<body>
<h1>SSE Notification Test</h1>
<div id="status" class="status disconnected">
Disconnected
</div>
<div>
<button id="connectBtn" onclick="connectSSE()">Connect</button>
<button id="disconnectBtn" onclick="disconnectSSE()" disabled>Disconnect</button>
<button onclick="clearNotifications()">Clear Notifications</button>
</div>
<h3>Notifications:</h3>
<div id="notifications">
<p>No notifications yet...</p>
</div>
<h3>Test Instructions:</h3>
<ol>
<li>Click "Connect" to start the SSE connection</li>
<li>Run the test script: <code>python test_sse_notifications.py</code></li>
<li>Watch for real-time notifications to appear below</li>
<li>Check the browser console for debug information</li>
</ol>
<script>
let eventSource = null;
let reconnectAttempts = 0;
const maxReconnectAttempts = 5;
const reconnectDelay = 3000;
function updateStatus(message, isConnected) {
const statusDiv = document.getElementById('status');
statusDiv.textContent = message;
statusDiv.className = `status ${isConnected ? 'connected' : 'disconnected'}`;
document.getElementById('connectBtn').disabled = isConnected;
document.getElementById('disconnectBtn').disabled = !isConnected;
}
function addNotification(message) {
const notificationsDiv = document.getElementById('notifications');
const notification = document.createElement('div');
notification.className = 'notification';
notification.innerHTML = `
<strong>${new Date().toLocaleTimeString()}</strong><br>
${message}
`;
// Clear the "No notifications yet" message if it exists
if (notificationsDiv.querySelector('p')) {
notificationsDiv.innerHTML = '';
}
notificationsDiv.appendChild(notification);
notificationsDiv.scrollTop = notificationsDiv.scrollHeight;
}
function connectSSE() {
if (eventSource) {
eventSource.close();
}
updateStatus('Connecting...', false);
// Get CSRF token from cookies
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
const csrftoken = getCookie('csrftoken');
eventSource = new EventSource('/api/notifications/stream/');
eventSource.onopen = function(event) {
console.log('SSE connection opened:', event);
updateStatus('Connected - Waiting for notifications...', true);
reconnectAttempts = 0;
addNotification('SSE connection established successfully!');
};
eventSource.onmessage = function(event) {
console.log('SSE message received:', event.data);
try {
const data = JSON.parse(event.data);
addNotification(`Notification: ${data.message || 'No message'}`);
} catch (e) {
addNotification(`Raw message: ${event.data}`);
}
};
eventSource.onerror = function(event) {
console.error('SSE error:', event);
updateStatus('Connection error', false);
if (eventSource.readyState === EventSource.CLOSED) {
addNotification('SSE connection closed');
} else {
addNotification('SSE connection error');
}
// Attempt to reconnect
if (reconnectAttempts < maxReconnectAttempts) {
reconnectAttempts++;
addNotification(`Attempting to reconnect (${reconnectAttempts}/${maxReconnectAttempts})...`);
setTimeout(connectSSE, reconnectDelay);
} else {
addNotification('Max reconnection attempts reached');
}
};
eventSource.addEventListener('notification', function(event) {
console.log('Custom notification event:', event.data);
try {
const data = JSON.parse(event.data);
addNotification(`Custom Notification: ${data.message || 'No message'}`);
} catch (e) {
addNotification(`Custom notification: ${event.data}`);
}
});
}
function disconnectSSE() {
if (eventSource) {
eventSource.close();
eventSource = null;
}
updateStatus('Disconnected', false);
addNotification('SSE connection closed by user');
}
function clearNotifications() {
const notificationsDiv = document.getElementById('notifications');
notificationsDiv.innerHTML = '<p>No notifications yet...</p>';
}
// Auto-connect when page loads
window.addEventListener('load', function() {
addNotification('Page loaded. Click "Connect" to start SSE connection.');
});
</script>
</body>
</html>

View File

@ -1,57 +0,0 @@
#!/usr/bin/env python
"""
Test script to generate notifications and test SSE functionality
"""
import os
import sys
import django
# Setup Django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'NorahUniversity.settings')
django.setup()
from django.utils import timezone
from django.contrib.auth.models import User
from recruitment.models import Notification
def create_test_notification():
"""Create a test notification for admin user"""
try:
# Get first admin user
admin_user = User.objects.filter(is_staff=True).first()
if not admin_user:
print("No admin user found!")
return
# Create a test notification
notification = Notification.objects.create(
recipient=admin_user,
notification_type=Notification.NotificationType.IN_APP,
message="Test SSE Notification - Real-time update working!",
status=Notification.Status.PENDING,
scheduled_for=timezone.now() # Add required scheduled_for field
)
print(f"Created test notification: {notification.id}")
print(f"Recipient: {admin_user.username}")
print(f"Message: {notification.message}")
print(f"Status: {notification.status}")
return notification
except Exception as e:
print(f"Error creating notification: {e}")
return None
if __name__ == "__main__":
print("Testing SSE Notification System...")
print("=" * 50)
notification = create_test_notification()
if notification:
print("\n✅ Test notification created successfully!")
print("🔥 Check the browser console for SSE events")
print("📱 Open http://localhost:8000/ and look for real-time updates")
else:
print("\n❌ Failed to create test notification")

View File

@ -1,132 +0,0 @@
#!/usr/bin/env python3
"""
Test script for candidate sync functionality
"""
import os
import sys
import django
# Setup Django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'NorahUniversity.settings')
django.setup()
from recruitment.models import JobPosting, Candidate, Source
from recruitment.candidate_sync_service import CandidateSyncService
from django.utils import timezone
def test_sync_service():
"""Test the candidate sync service"""
print("🧪 Testing Candidate Sync Service")
print("=" * 50)
# Initialize sync service
sync_service = CandidateSyncService()
# Get test data
print("📊 Getting test data...")
jobs = JobPosting.objects.all()
sources = Source.objects.filter(supports_outbound_sync=True)
print(f"Found {jobs.count()} jobs")
print(f"Found {sources.count()} sources with outbound sync support")
if not jobs.exists():
print("❌ No jobs found. Creating test job...")
# Create a test job if none exists
job = JobPosting.objects.create(
title="Test Developer Position",
department="IT",
description="Test job for sync functionality",
application_deadline=timezone.now().date() + timezone.timedelta(days=30),
status="ACTIVE"
)
print(f"✅ Created test job: {job.title}")
else:
job = jobs.first()
print(f"✅ Using existing job: {job.title}")
if not sources.exists():
print("❌ No sources with outbound sync found. Creating test source...")
# Create a test source if none exists
source = Source.objects.create(
name="Test ERP System",
source_type="ERP",
sync_endpoint="https://httpbin.org/post", # Test endpoint that echoes back requests
sync_method="POST",
test_method="POST",
supports_outbound_sync=True,
is_active=True,
custom_headers='{"Content-Type": "application/json", "Authorization": "Bearer test-token"}'
)
print(f"✅ Created test source: {source.name}")
else:
source = sources.first()
print(f"✅ Using existing source: {source.name}")
# Test connection
print("\n🔗 Testing source connection...")
try:
connection_result = sync_service.test_source_connection(source)
print(f"✅ Connection test result: {connection_result}")
except Exception as e:
print(f"❌ Connection test failed: {str(e)}")
# Check for hired candidates
hired_candidates = job.candidates.filter(offer_status='Accepted')
print(f"\n👥 Found {hired_candidates.count()} hired candidates")
if hired_candidates.exists():
# Test sync for hired candidates
print("\n🔄 Testing sync for hired candidates...")
try:
results = sync_service.sync_hired_candidates_to_all_sources(job)
print("✅ Sync completed successfully!")
print(f"Results: {results}")
except Exception as e:
print(f"❌ Sync failed: {str(e)}")
else:
print(" No hired candidates to sync. Creating test candidate...")
# Create a test candidate if none exists
candidate = Candidate.objects.create(
job=job,
first_name="Test",
last_name="Candidate",
email="test@example.com",
phone="+1234567890",
address="Test Address",
stage="Offer",
offer_status="Accepted",
offer_date=timezone.now().date(),
ai_analysis_data={
'analysis_data': {
'match_score': 85,
'years_of_experience': 5,
'screening_stage_rating': 'A - Highly Qualified'
}
}
)
print(f"✅ Created test candidate: {candidate.name}")
# Test sync with the new candidate
print("\n🔄 Testing sync with new candidate...")
try:
results = sync_service.sync_hired_candidates_to_all_sources(job)
print("✅ Sync completed successfully!")
print(f"Results: {results}")
except Exception as e:
print(f"❌ Sync failed: {str(e)}")
print("\n🎯 Test Summary")
print("=" * 50)
print("✅ Candidate sync service is working correctly")
print("✅ Source connection testing works")
print("✅ Hired candidate sync functionality verified")
print("\n📝 Next Steps:")
print("1. Configure real source endpoints in the admin panel")
print("2. Test with actual external systems")
print("3. Monitor sync logs for production usage")
if __name__ == "__main__":
test_sync_service()

View File

@ -1,46 +0,0 @@
#!/usr/bin/env python
"""Test script to verify URL configuration"""
import os
import sys
import django
# Add the project directory to the Python path
sys.path.append('/home/ismail/projects/ats/kaauh_ats')
# Set up Django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'NorahUniversity.settings')
django.setup()
from django.urls import reverse
from django.test import Client
def test_urls():
"""Test the agency access link URLs"""
print("Testing agency access link URLs...")
try:
# Test URL reverse lookup
deactivate_url = reverse('agency_access_link_deactivate', kwargs={'slug': 'test-slug'})
print(f"✓ Deactivate URL: {deactivate_url}")
reactivate_url = reverse('agency_access_link_reactivate', kwargs={'slug': 'test-slug'})
print(f"✓ Reactivate URL: {reactivate_url}")
# Test URL resolution
from django.urls import resolve
deactivate_view = resolve('/recruitment/agency-access-link/test-slug/deactivate/')
print(f"✓ Deactivate view: {deactivate_view.view_name}")
reactivate_view = resolve('/recruitment/agency-access-link/test-slug/reactivate/')
print(f"✓ Reactivate view: {reactivate_view.view_name}")
print("\n✅ All URL tests passed!")
return True
except Exception as e:
print(f"❌ Error testing URLs: {e}")
return False
if __name__ == '__main__':
test_urls()

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