frontend #32
3
.env
Normal file
3
.env
Normal file
@ -0,0 +1,3 @@
|
||||
DB_NAME=norahuniversity
|
||||
DB_USER=norahuniversity
|
||||
DB_PASSWORD=norahuniversity
|
||||
Binary file not shown.
Binary file not shown.
@ -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"
|
||||
|
||||
@ -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'),
|
||||
|
||||
|
||||
212
comprehensive_translation_merger.py
Normal file
212
comprehensive_translation_merger.py
Normal 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()
|
||||
48
empty_translations_summary.txt
Normal file
48
empty_translations_summary.txt
Normal 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
9035
locale/ar/LC_MESSAGES/django.po.backup
Normal file
9035
locale/ar/LC_MESSAGES/django.po.backup
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
1845
recruitment/forms.py
1845
recruitment/forms.py
File diff suppressed because it is too large
Load Diff
Binary file not shown.
71
recruitment/management/commands/translate_po.py
Normal file
71
recruitment/management/commands/translate_po.py
Normal 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}")
|
||||
@ -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',
|
||||
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -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),
|
||||
),
|
||||
]
|
||||
@ -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',
|
||||
),
|
||||
]
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -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',
|
||||
),
|
||||
]
|
||||
@ -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),
|
||||
),
|
||||
]
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -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,
|
||||
),
|
||||
]
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -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),
|
||||
),
|
||||
]
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@ -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__'
|
||||
|
||||
@ -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()
|
||||
@ -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}
|
||||
|
||||
|
||||
27
recruitment/templatetags/file_filters.py
Normal file
27
recruitment/templatetags/file_filters.py
Normal 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)
|
||||
@ -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)
|
||||
|
||||
@ -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'),
|
||||
|
||||
|
||||
|
||||
]
|
||||
|
||||
3513
recruitment/views.py
3513
recruitment/views.py
File diff suppressed because it is too large
Load Diff
@ -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'
|
||||
|
||||
@ -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>
|
||||
|
||||
149
templates/includes/document_list.html
Normal file
149
templates/includes/document_list.html
Normal 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>
|
||||
@ -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 %}
|
||||
@ -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" }} — {{ 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" }}',
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -285,7 +285,7 @@
|
||||
<th style="width: calc(50% / 7);">{% trans "Offer" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
|
||||
<tbody>
|
||||
{% for job in jobs %}
|
||||
<tr>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
|
||||
179
templates/messages/message_detail.html
Normal file
179
templates/messages/message_detail.html
Normal 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 %}
|
||||
237
templates/messages/message_form.html
Normal file
237
templates/messages/message_form.html
Normal 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 %}
|
||||
230
templates/messages/message_list.html
Normal file
230
templates/messages/message_list.html
Normal 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"> </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 %}
|
||||
444
templates/people/create_person.html
Normal file
444
templates/people/create_person.html
Normal 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 %}
|
||||
607
templates/people/person_detail.html
Normal file
607
templates/people/person_detail.html
Normal 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 %}
|
||||
411
templates/people/person_list.html
Normal file
411
templates/people/person_list.html
Normal 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 %}
|
||||
572
templates/people/update_person.html
Normal file
572
templates/people/update_person.html
Normal 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 %}
|
||||
@ -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>
|
||||
@ -1,4 +1,4 @@
|
||||
{% extends 'agency_base.html' %}
|
||||
{% extends 'portal_base.html' %}
|
||||
{% load static i18n %}
|
||||
|
||||
{% block title %}{% trans "Access Link Details" %} - ATS{% endblock %}
|
||||
|
||||
@ -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 = '';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
{% extends 'agency_base.html' %}
|
||||
{% extends 'portal_base.html' %}
|
||||
{% load static i18n %}
|
||||
|
||||
{% block title %}{% trans "Agency Dashboard" %} - ATS{% endblock %}
|
||||
|
||||
@ -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>
|
||||
|
||||
390
templates/recruitment/agency_portal_persons_list.html
Normal file
390
templates/recruitment/agency_portal_persons_list.html
Normal 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 %}
|
||||
@ -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 %}
|
||||
|
||||
@ -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 %}
|
||||
@ -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 %}
|
||||
|
||||
@ -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 %}
|
||||
--
|
||||
|
||||
@ -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>
|
||||
|
||||
165
templates/recruitment/candidate_portal_dashboard.html
Normal file
165
templates/recruitment/candidate_portal_dashboard.html
Normal 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 %}
|
||||
@ -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>
|
||||
|
||||
148
templates/recruitment/candidate_signup.html
Normal file
148
templates/recruitment/candidate_signup.html
Normal 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 %}
|
||||
@ -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>
|
||||
@ -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 %}
|
||||
--
|
||||
|
||||
295
templates/recruitment/portal_login.html
Normal file
295
templates/recruitment/portal_login.html
Normal 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 %}
|
||||
@ -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()
|
||||
@ -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()
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -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()
|
||||
@ -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
@ -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)
|
||||
@ -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!")
|
||||
@ -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>
|
||||
@ -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()
|
||||
@ -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>
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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)
|
||||
216
test_sse.html
216
test_sse.html
@ -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>
|
||||
@ -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")
|
||||
@ -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()
|
||||
46
test_urls.py
46
test_urls.py
@ -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
Loading…
x
Reference in New Issue
Block a user