Compare commits
19 Commits
0c3f942161
...
f1499f7be0
| Author | SHA1 | Date | |
|---|---|---|---|
| f1499f7be0 | |||
| 64e04a011d | |||
| d0235bfefe | |||
| a28bfc11f3 | |||
| 06436a3b9e | |||
| 1babb1be63 | |||
| 0213bd6e11 | |||
| 5285335498 | |||
| 9497bf102e | |||
| cbace0274a | |||
| e4b6a359ea | |||
| 552c6e4d64 | |||
| 870988424b | |||
| da555c1460 | |||
| eb79173e26 | |||
| caa7ed88aa | |||
| 9830b1173f | |||
| 08ecea8934 | |||
| 15f8cb2650 |
6
.gitignore
vendored
6
.gitignore
vendored
@ -110,4 +110,8 @@ settings.py
|
||||
# If a rule in .gitignore ends with a directory separator (i.e. `/`
|
||||
# character), then remove the file in the remaining pattern string and all
|
||||
# files with the same name in subdirectories.
|
||||
db.sqlite3
|
||||
db.sqlite3
|
||||
|
||||
.opencode
|
||||
openspec
|
||||
AGENTS.md
|
||||
Binary file not shown.
Binary file not shown.
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,103 @@ 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',
|
||||
"recruitment.backends.CustomAuthenticationBackend",
|
||||
"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,14 +137,17 @@ 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',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
# DATABASES = {
|
||||
# 'default': {
|
||||
# 'ENGINE': 'django.db.backends.sqlite3',
|
||||
@ -155,6 +160,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 +193,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 +214,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 +245,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 +282,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": 2,
|
||||
"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 +483,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()
|
||||
113
debug_test.py
Normal file
113
debug_test.py
Normal file
@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Debug test to check URL routing
|
||||
"""
|
||||
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.test import Client
|
||||
from django.urls import reverse
|
||||
from django.contrib.auth import get_user_model
|
||||
from recruitment.models import JobPosting, Application, Person
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
def debug_url_routing():
|
||||
"""Debug URL routing for document upload"""
|
||||
print("Debugging URL routing...")
|
||||
|
||||
# Clean up existing test data
|
||||
User.objects.filter(username__startswith='testcandidate').delete()
|
||||
|
||||
# Create test data
|
||||
client = Client()
|
||||
|
||||
# Create a test user with unique username
|
||||
import uuid
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
user = User.objects.create_user(
|
||||
username=f'testcandidate_{unique_id}',
|
||||
email=f'test_{unique_id}@example.com',
|
||||
password='testpass123',
|
||||
user_type='candidate'
|
||||
)
|
||||
|
||||
# Create a test job
|
||||
from datetime import date, timedelta
|
||||
job = JobPosting.objects.create(
|
||||
title='Test Job',
|
||||
description='Test Description',
|
||||
open_positions=1,
|
||||
status='ACTIVE',
|
||||
application_deadline=date.today() + timedelta(days=30)
|
||||
)
|
||||
|
||||
# Create a test person first
|
||||
person = Person.objects.create(
|
||||
first_name='Test',
|
||||
last_name='Candidate',
|
||||
email=f'test_{unique_id}@example.com',
|
||||
phone='1234567890',
|
||||
user=user
|
||||
)
|
||||
|
||||
# Create a test application
|
||||
application = Application.objects.create(
|
||||
job=job,
|
||||
person=person
|
||||
)
|
||||
|
||||
print(f"Created application with slug: {application.slug}")
|
||||
print(f"Application ID: {application.id}")
|
||||
|
||||
# Log in the user
|
||||
client.login(username=f'testcandidate_{unique_id}', password='testpass123')
|
||||
|
||||
# Test different URL patterns
|
||||
try:
|
||||
url1 = reverse('document_upload', kwargs={'slug': application.slug})
|
||||
print(f"URL pattern 1 (document_upload): {url1}")
|
||||
except Exception as e:
|
||||
print(f"Error with document_upload URL: {e}")
|
||||
|
||||
try:
|
||||
url2 = reverse('candidate_document_upload', kwargs={'slug': application.slug})
|
||||
print(f"URL pattern 2 (candidate_document_upload): {url2}")
|
||||
except Exception as e:
|
||||
print(f"Error with candidate_document_upload URL: {e}")
|
||||
|
||||
# Test GET request to see if the URL is accessible
|
||||
try:
|
||||
response = client.get(url1)
|
||||
print(f"GET request to {url1}: Status {response.status_code}")
|
||||
if response.status_code != 200:
|
||||
print(f"Response content: {response.content}")
|
||||
except Exception as e:
|
||||
print(f"Error making GET request: {e}")
|
||||
|
||||
# Test the second URL pattern
|
||||
try:
|
||||
response = client.get(url2)
|
||||
print(f"GET request to {url2}: Status {response.status_code}")
|
||||
if response.status_code != 200:
|
||||
print(f"Response content: {response.content}")
|
||||
except Exception as e:
|
||||
print(f"Error making GET request to {url2}: {e}")
|
||||
|
||||
# Clean up
|
||||
application.delete()
|
||||
job.delete()
|
||||
user.delete()
|
||||
|
||||
print("Debug completed.")
|
||||
|
||||
if __name__ == '__main__':
|
||||
debug_url_routing()
|
||||
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.
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, ZoomMeetingDetails,
|
||||
FormTemplate, FormStage, FormField, FormSubmission, FieldResponse,
|
||||
SharedFormTemplate, Source, HiringAgency, IntegrationLog,InterviewSchedule,Profile,JobPostingImage,MeetingComment,
|
||||
SharedFormTemplate, Source, HiringAgency, IntegrationLog,InterviewSchedule,JobPostingImage,InterviewNote,
|
||||
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']
|
||||
@ -200,7 +158,7 @@ class TrainingMaterialAdmin(admin.ModelAdmin):
|
||||
save_on_top = True
|
||||
|
||||
|
||||
@admin.register(ZoomMeeting)
|
||||
@admin.register(ZoomMeetingDetails)
|
||||
class ZoomMeetingAdmin(admin.ModelAdmin):
|
||||
list_display = ['topic', 'meeting_id', 'start_time', 'duration', 'created_at']
|
||||
list_filter = ['timezone', 'created_at']
|
||||
@ -223,24 +181,24 @@ class ZoomMeetingAdmin(admin.ModelAdmin):
|
||||
save_on_top = True
|
||||
|
||||
|
||||
@admin.register(MeetingComment)
|
||||
class MeetingCommentAdmin(admin.ModelAdmin):
|
||||
list_display = ['meeting', 'author', 'created_at', 'updated_at']
|
||||
list_filter = ['created_at', 'author', 'meeting']
|
||||
search_fields = ['content', 'meeting__topic', 'author__username']
|
||||
readonly_fields = ['created_at', 'updated_at', 'slug']
|
||||
fieldsets = (
|
||||
('Meeting Information', {
|
||||
'fields': ('meeting', 'author')
|
||||
}),
|
||||
('Comment Content', {
|
||||
'fields': ('content',)
|
||||
}),
|
||||
('Timestamps', {
|
||||
'fields': ('created_at', 'updated_at', 'slug')
|
||||
}),
|
||||
)
|
||||
save_on_top = True
|
||||
# @admin.register(InterviewNote)
|
||||
# class MeetingCommentAdmin(admin.ModelAdmin):
|
||||
# list_display = ['meeting', 'author', 'created_at', 'updated_at']
|
||||
# list_filter = ['created_at', 'author', 'meeting']
|
||||
# search_fields = ['content', 'meeting__topic', 'author__username']
|
||||
# readonly_fields = ['created_at', 'updated_at', 'slug']
|
||||
# fieldsets = (
|
||||
# ('Meeting Information', {
|
||||
# 'fields': ('meeting', 'author')
|
||||
# }),
|
||||
# ('Comment Content', {
|
||||
# 'fields': ('content',)
|
||||
# }),
|
||||
# ('Timestamps', {
|
||||
# 'fields': ('created_at', 'updated_at', 'slug')
|
||||
# }),
|
||||
# )
|
||||
# save_on_top = True
|
||||
|
||||
|
||||
@admin.register(FormTemplate)
|
||||
@ -280,13 +238,14 @@ 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)
|
||||
admin.site.register(Profile)
|
||||
admin.site.register(AgencyAccessLink)
|
||||
admin.site.register(AgencyJobAssignment)
|
||||
# AgencyMessage admin removed - model has been deleted
|
||||
|
||||
|
||||
admin.site.register(JobPostingImage)
|
||||
admin.site.register(User)
|
||||
|
||||
36
recruitment/backends.py
Normal file
36
recruitment/backends.py
Normal file
@ -0,0 +1,36 @@
|
||||
"""
|
||||
Custom authentication backends for the recruitment system.
|
||||
"""
|
||||
|
||||
from allauth.account.auth_backends import AuthenticationBackend
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import reverse
|
||||
|
||||
|
||||
class CustomAuthenticationBackend(AuthenticationBackend):
|
||||
"""
|
||||
Custom authentication backend that extends django-allauth's AuthenticationBackend
|
||||
to handle user type-based redirection after successful login.
|
||||
"""
|
||||
|
||||
def post_login(self, request, user, **kwargs):
|
||||
"""
|
||||
Called after successful authentication.
|
||||
Sets the appropriate redirect URL based on user type.
|
||||
"""
|
||||
# Set redirect URL based on user type
|
||||
if user.user_type == 'staff':
|
||||
redirect_url = '/dashboard/'
|
||||
elif user.user_type == 'agency':
|
||||
redirect_url = reverse('agency_portal_dashboard')
|
||||
elif user.user_type == 'candidate':
|
||||
redirect_url = reverse('candidate_portal_dashboard')
|
||||
else:
|
||||
# Fallback to default redirect URL if user type is unknown
|
||||
redirect_url = '/'
|
||||
|
||||
# Store the redirect URL in session for allauth to use
|
||||
request.session['allauth_login_redirect_url'] = redirect_url
|
||||
|
||||
# Call the parent method to complete the login process
|
||||
return super().post_login(request, user, **kwargs)
|
||||
@ -1,17 +1,164 @@
|
||||
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('account_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 = '/accounts/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('account_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 = '/accounts/login/'
|
||||
|
||||
|
||||
class CandidateRequiredMixin(UserTypeRequiredMixin):
|
||||
"""Mixin to restrict access to candidate users only."""
|
||||
allowed_user_types = ['candidate']
|
||||
login_url = '/accounts/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='/accounts/login/')(view_func)
|
||||
|
||||
|
||||
def candidate_user_required(view_func):
|
||||
"""Decorator to restrict view to candidate users only."""
|
||||
return user_type_required(['candidate'], login_url='/accounts/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='/accounts/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='/accounts/login/')(view_func)
|
||||
|
||||
@ -224,11 +224,8 @@ def send_interview_invitation_email(candidate, job, meeting_details=None, recipi
|
||||
logger.error(error_msg, exc_info=True)
|
||||
return {'success': False, 'error': error_msg}
|
||||
|
||||
from .models import Candidate
|
||||
from django.shortcuts import get_object_or_404
|
||||
# Assuming other necessary imports like logger, settings, EmailMultiAlternatives, strip_tags are present
|
||||
|
||||
from .models import Candidate
|
||||
from .models import Application
|
||||
from django.shortcuts import get_object_or_404
|
||||
import logging
|
||||
from django.conf import settings
|
||||
@ -262,15 +259,16 @@ def send_bulk_email(subject, message, recipient_list, request=None, attachments=
|
||||
email = email.strip().lower()
|
||||
|
||||
try:
|
||||
candidate = get_object_or_404(Candidate, email=email)
|
||||
candidate = get_object_or_404(Application, person__email=email)
|
||||
except Exception:
|
||||
logger.warning(f"Candidate not found for email: {email}")
|
||||
continue
|
||||
|
||||
candidate_name = candidate.first_name
|
||||
candidate_name = candidate.person.full_name
|
||||
|
||||
|
||||
# --- Candidate belongs to an agency (Final Recipient: Agency) ---
|
||||
if candidate.belong_to_an_agency and candidate.hiring_agency and candidate.hiring_agency.email:
|
||||
if candidate.hiring_agency and candidate.hiring_agency.email:
|
||||
agency_email = candidate.hiring_agency.email
|
||||
agency_message = f"Hi, {candidate_name}" + "\n" + message
|
||||
|
||||
@ -395,7 +393,7 @@ def send_bulk_email(subject, message, recipient_list, request=None, attachments=
|
||||
if not from_interview:
|
||||
# Send Emails - Pure Candidates
|
||||
for email in pure_candidate_emails:
|
||||
candidate_name = Candidate.objects.filter(email=email).first().first_name
|
||||
candidate_name = Application.objects.filter(email=email).first().first_name
|
||||
candidate_message = f"Hi, {candidate_name}" + "\n" + message
|
||||
send_individual_email(email, candidate_message)
|
||||
|
||||
@ -403,7 +401,7 @@ def send_bulk_email(subject, message, recipient_list, request=None, attachments=
|
||||
i = 0
|
||||
for email in agency_emails:
|
||||
candidate_email = candidate_through_agency_emails[i]
|
||||
candidate_name = Candidate.objects.filter(email=candidate_email).first().first_name
|
||||
candidate_name = Application.objects.filter(email=candidate_email).first().first_name
|
||||
agency_message = f"Hi, {candidate_name}" + "\n" + message
|
||||
send_individual_email(email, agency_message)
|
||||
i += 1
|
||||
|
||||
2197
recruitment/forms.py
2197
recruitment/forms.py
File diff suppressed because it is too large
Load Diff
@ -1,11 +1,11 @@
|
||||
from .models import Candidate
|
||||
from .models import Application
|
||||
from time import sleep
|
||||
|
||||
def callback_ai_parsing(task):
|
||||
if task.success:
|
||||
try:
|
||||
pk = task.args[0]
|
||||
c = Candidate.objects.get(pk=pk)
|
||||
c = Application.objects.get(pk=pk)
|
||||
if c.retry and not c.is_resume_parsed:
|
||||
sleep(30)
|
||||
c.retry -= 1
|
||||
|
||||
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.7 on 2025-11-17 09:52
|
||||
|
||||
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,20 @@ class Migration(migrations.Migration):
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='HiringAgency',
|
||||
name='InterviewLocation',
|
||||
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)),
|
||||
('location_type', models.CharField(choices=[('Remote', 'Remote (e.g., Zoom, Google Meet)'), ('Onsite', 'In-Person (Physical Location)')], db_index=True, max_length=10, verbose_name='Location Type')),
|
||||
('details_url', models.URLField(blank=True, max_length=2048, null=True, verbose_name='Meeting/Location URL')),
|
||||
('topic', models.CharField(blank=True, help_text="e.g., 'Zoom Topic: Software Interview' or 'Main Conference Room, 3rd Floor'", max_length=255, verbose_name='Location/Meeting Topic')),
|
||||
('timezone', models.CharField(default='UTC', max_length=50, verbose_name='Timezone')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Hiring Agency',
|
||||
'verbose_name_plural': 'Hiring Agencies',
|
||||
'ordering': ['name'],
|
||||
'verbose_name': 'Interview Location',
|
||||
'verbose_name_plural': 'Interview Locations',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
@ -113,29 +112,33 @@ class Migration(migrations.Migration):
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ZoomMeeting',
|
||||
name='CustomUser',
|
||||
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')),
|
||||
('meeting_id', models.CharField(db_index=True, max_length=20, unique=True, verbose_name='Meeting ID')),
|
||||
('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')),
|
||||
('join_url', models.URLField(verbose_name='Join URL')),
|
||||
('participant_video', models.BooleanField(default=True, verbose_name='Participant Video')),
|
||||
('password', models.CharField(blank=True, max_length=20, null=True, verbose_name='Password')),
|
||||
('join_before_host', models.BooleanField(default=False, verbose_name='Join Before Host')),
|
||||
('mute_upon_entry', models.BooleanField(default=False, verbose_name='Mute Upon Entry')),
|
||||
('waiting_room', models.BooleanField(default=False, verbose_name='Waiting Room')),
|
||||
('zoom_gateway_response', models.JSONField(blank=True, null=True, verbose_name='Zoom Gateway Response')),
|
||||
('status', models.CharField(blank=True, db_index=True, default='waiting', max_length=20, null=True, verbose_name='Status')),
|
||||
('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')),
|
||||
('profile_image', models.ImageField(blank=True, null=True, upload_to='profile_pic/', validators=[recruitment.validators.validate_image_size], verbose_name='Profile Image')),
|
||||
('designation', models.CharField(blank=True, max_length=100, null=True, verbose_name='Designation')),
|
||||
('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={
|
||||
'abstract': False,
|
||||
'verbose_name': 'User',
|
||||
'verbose_name_plural': 'Users',
|
||||
},
|
||||
managers=[
|
||||
('objects', django.contrib.auth.models.UserManager()),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='FormField',
|
||||
@ -206,42 +209,100 @@ 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)),
|
||||
('generated_password', models.CharField(blank=True, help_text='Generated password for agency user account', max_length=255, 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'), ('Document Review', 'Document Review'), ('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')),
|
||||
('exam_score', models.FloatField(blank=True, null=True, verbose_name='Exam Score')),
|
||||
('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(
|
||||
name='OnsiteLocationDetails',
|
||||
fields=[
|
||||
('interviewlocation_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='recruitment.interviewlocation')),
|
||||
('physical_address', models.CharField(blank=True, max_length=255, null=True, verbose_name='Physical Address')),
|
||||
('room_number', models.CharField(blank=True, max_length=50, null=True, verbose_name='Room Number/Name')),
|
||||
('start_time', models.DateTimeField(db_index=True, verbose_name='Start Time')),
|
||||
('duration', models.PositiveIntegerField(verbose_name='Duration (minutes)')),
|
||||
('status', models.CharField(choices=[('waiting', 'Waiting'), ('started', 'Started'), ('ended', 'Ended'), ('cancelled', 'Cancelled')], db_index=True, default='waiting', max_length=20)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Onsite Location Details',
|
||||
'verbose_name_plural': 'Onsite Location Details',
|
||||
},
|
||||
bases=('recruitment.interviewlocation',),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ZoomMeetingDetails',
|
||||
fields=[
|
||||
('interviewlocation_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='recruitment.interviewlocation')),
|
||||
('status', models.CharField(choices=[('waiting', 'Waiting'), ('started', 'Started'), ('ended', 'Ended'), ('cancelled', 'Cancelled')], db_index=True, default='waiting', max_length=20)),
|
||||
('start_time', models.DateTimeField(db_index=True, verbose_name='Start Time')),
|
||||
('duration', models.PositiveIntegerField(verbose_name='Duration (minutes)')),
|
||||
('meeting_id', models.CharField(db_index=True, max_length=50, unique=True, verbose_name='External Meeting ID')),
|
||||
('password', models.CharField(blank=True, max_length=20, null=True, verbose_name='Password')),
|
||||
('zoom_gateway_response', models.JSONField(blank=True, null=True, verbose_name='Zoom Gateway Response')),
|
||||
('participant_video', models.BooleanField(default=True, verbose_name='Participant Video')),
|
||||
('join_before_host', models.BooleanField(default=False, verbose_name='Join Before Host')),
|
||||
('host_email', models.CharField(blank=True, null=True)),
|
||||
('mute_upon_entry', models.BooleanField(default=False, verbose_name='Mute Upon Entry')),
|
||||
('waiting_room', models.BooleanField(default=False, verbose_name='Waiting Room')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Zoom Meeting Details',
|
||||
'verbose_name_plural': 'Zoom Meeting Details',
|
||||
},
|
||||
bases=('recruitment.interviewlocation',),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='JobPosting',
|
||||
fields=[
|
||||
@ -251,8 +312,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 +342,9 @@ 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)),
|
||||
('ai_parsed', models.BooleanField(default=False, help_text='Whether the job posting has been parsed by AI', verbose_name='AI Parsed')),
|
||||
('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 +360,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')),
|
||||
('schedule_interview_type', models.CharField(choices=[('Remote', 'Remote (e.g., Zoom, Google Meet)'), ('Onsite', 'In-Person (Physical Location)')], default='Remote', max_length=10, verbose_name='Interview 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,10 +370,14 @@ 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, related_name='interview_schedules', to='recruitment.application')),
|
||||
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
('template_location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='schedule_templates', to='recruitment.interviewlocation', verbose_name='Location Template (Zoom/Onsite)')),
|
||||
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interview_schedules', to='recruitment.jobposting')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='formtemplate',
|
||||
@ -319,9 +385,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',
|
||||
@ -357,14 +423,114 @@ class Migration(migrations.Migration):
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Profile',
|
||||
name='Message',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('profile_image', models.ImageField(blank=True, null=True, upload_to='profile_pic/', validators=[recruitment.validators.validate_image_size])),
|
||||
('designation', models.CharField(blank=True, max_length=100, null=True)),
|
||||
('phone', models.CharField(blank=True, max_length=12, null=True, verbose_name='Phone Number')),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
|
||||
('slug', 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(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='Notification',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('message', models.TextField(verbose_name='Notification Message')),
|
||||
('notification_type', models.CharField(choices=[('email', 'Email'), ('in_app', 'In-App')], default='email', max_length=20, verbose_name='Notification Type')),
|
||||
('status', models.CharField(choices=[('pending', 'Pending'), ('sent', 'Sent'), ('read', 'Read'), ('failed', 'Failed'), ('retrying', 'Retrying')], default='pending', max_length=20, verbose_name='Status')),
|
||||
('scheduled_for', models.DateTimeField(help_text='The date and time this notification is scheduled to be sent.', verbose_name='Scheduled Send Time')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('attempts', models.PositiveIntegerField(default=0, verbose_name='Send Attempts')),
|
||||
('last_error', models.TextField(blank=True, verbose_name='Last Error Message')),
|
||||
('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL, verbose_name='Recipient')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Notification',
|
||||
'verbose_name_plural': 'Notifications',
|
||||
'ordering': ['-scheduled_for', '-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')], max_length=1, null=True, verbose_name='Gender')),
|
||||
('gpa', models.DecimalField(blank=True, decimal_places=2, max_digits=3, null=True, verbose_name='GPA')),
|
||||
('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='ScheduledInterview',
|
||||
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')),
|
||||
('interview_date', models.DateField(db_index=True, verbose_name='Interview Date')),
|
||||
('interview_time', models.TimeField(verbose_name='Interview Time')),
|
||||
('status', models.CharField(choices=[('scheduled', 'Scheduled'), ('confirmed', 'Confirmed'), ('cancelled', 'Cancelled'), ('completed', 'Completed')], db_index=True, default='scheduled', max_length=20)),
|
||||
('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.application')),
|
||||
('interview_location', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='scheduled_interview', to='recruitment.interviewlocation', verbose_name='Meeting/Location Details')),
|
||||
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.jobposting')),
|
||||
('participants', models.ManyToManyField(blank=True, to='recruitment.participants')),
|
||||
('schedule', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='interviews', to='recruitment.interviewschedule')),
|
||||
('system_users', models.ManyToManyField(blank=True, related_name='attended_interviews', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='InterviewNote',
|
||||
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')),
|
||||
('note_type', models.CharField(choices=[('Feedback', 'Candidate Feedback'), ('Logistics', 'Logistical Note'), ('General', 'General Comment')], default='Feedback', max_length=50, verbose_name='Note Type')),
|
||||
('content', django_ckeditor_5.fields.CKEditor5Field(verbose_name='Content/Feedback')),
|
||||
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interview_notes', to=settings.AUTH_USER_MODEL, verbose_name='Author')),
|
||||
('interview', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notes', to='recruitment.scheduledinterview', verbose_name='Scheduled Interview')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Interview Note',
|
||||
'verbose_name_plural': 'Interview Notes',
|
||||
'ordering': ['created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SharedFormTemplate',
|
||||
@ -425,60 +591,6 @@ class Migration(migrations.Migration):
|
||||
'verbose_name_plural': 'Training Materials',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ScheduledInterview',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
|
||||
('interview_date', models.DateField(db_index=True, verbose_name='Interview Date')),
|
||||
('interview_time', models.TimeField(verbose_name='Interview Time')),
|
||||
('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')),
|
||||
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.jobposting')),
|
||||
('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')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Notification',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('message', models.TextField(verbose_name='Notification Message')),
|
||||
('notification_type', models.CharField(choices=[('email', 'Email'), ('in_app', 'In-App')], default='email', max_length=20, verbose_name='Notification Type')),
|
||||
('status', models.CharField(choices=[('pending', 'Pending'), ('sent', 'Sent'), ('read', 'Read'), ('failed', 'Failed'), ('retrying', 'Retrying')], default='pending', max_length=20, verbose_name='Status')),
|
||||
('scheduled_for', models.DateTimeField(help_text='The date and time this notification is scheduled to be sent.', verbose_name='Scheduled Send Time')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('attempts', models.PositiveIntegerField(default=0, verbose_name='Send Attempts')),
|
||||
('last_error', models.TextField(blank=True, verbose_name='Last Error Message')),
|
||||
('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL, verbose_name='Recipient')),
|
||||
('related_meeting', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to='recruitment.zoommeeting', verbose_name='Related Meeting')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Notification',
|
||||
'verbose_name_plural': 'Notifications',
|
||||
'ordering': ['-scheduled_for', '-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='MeetingComment',
|
||||
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')),
|
||||
('content', django_ckeditor_5.fields.CKEditor5Field(verbose_name='Content')),
|
||||
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='meeting_comments', to=settings.AUTH_USER_MODEL, verbose_name='Author')),
|
||||
('meeting', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='recruitment.zoommeeting', verbose_name='Meeting')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Meeting Comment',
|
||||
'verbose_name_plural': 'Meeting Comments',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='AgencyAccessLink',
|
||||
fields=[
|
||||
@ -501,6 +613,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=[
|
||||
@ -523,17 +656,10 @@ class Migration(migrations.Migration):
|
||||
model_name='formsubmission',
|
||||
index=models.Index(fields=['submitted_at'], name='recruitment_submitt_7946c8_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='interviewschedule',
|
||||
index=models.Index(fields=['start_date'], name='recruitment_start_d_15d55e_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='interviewschedule',
|
||||
index=models.Index(fields=['end_date'], name='recruitment_end_dat_aeb00e_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='interviewschedule',
|
||||
index=models.Index(fields=['created_by'], name='recruitment_created_d0bdcc_idx'),
|
||||
migrations.AddField(
|
||||
model_name='notification',
|
||||
name='related_meeting',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to='recruitment.zoommeetingdetails', verbose_name='Related Meeting'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='formtemplate',
|
||||
@ -543,14 +669,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'),
|
||||
@ -572,12 +690,52 @@ class Migration(migrations.Migration):
|
||||
unique_together={('agency', 'job')},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='jobposting',
|
||||
index=models.Index(fields=['status', 'created_at', 'title'], name='recruitment_status_8b77aa_idx'),
|
||||
model_name='message',
|
||||
index=models.Index(fields=['sender', 'created_at'], name='recruitment_sender__49d984_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='jobposting',
|
||||
index=models.Index(fields=['slug'], name='recruitment_slug_004045_idx'),
|
||||
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='scheduledinterview',
|
||||
@ -589,7 +747,15 @@ 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='jobposting',
|
||||
index=models.Index(fields=['status', 'created_at', 'title'], name='recruitment_status_8b77aa_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='jobposting',
|
||||
index=models.Index(fields=['slug'], name='recruitment_slug_004045_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
36
recruitment/score_utils.py
Normal file
36
recruitment/score_utils.py
Normal file
@ -0,0 +1,36 @@
|
||||
from django.db.models import Value, IntegerField, CharField, F
|
||||
from django.db.models.functions import Coalesce, Cast, Replace, NullIf, KeyTextTransform
|
||||
|
||||
# Define the path to the match score
|
||||
# Based on your tracebacks, the path is: ai_analysis_data -> analysis_data -> match_score
|
||||
SCORE_PATH_RAW = F('ai_analysis_data__analysis_data__match_score')
|
||||
|
||||
# Define a robust annotation expression for safely extracting and casting the match score.
|
||||
# This sequence handles three common failure points:
|
||||
# 1. Missing keys (handled by Coalesce).
|
||||
# 2. Textual scores (e.g., "N/A" or "") (handled by NullIf).
|
||||
# 3. Quoted numeric scores (e.g., "50") from JSONB extraction (handled by Replace).
|
||||
def get_safe_score_annotation():
|
||||
"""
|
||||
Returns a Django Expression object that safely extracts a score from the
|
||||
JSONField, cleans it, and casts it to an IntegerField.
|
||||
"""
|
||||
|
||||
# 1. Extract the JSON value as text and force a CharField for cleaning functions
|
||||
# Using the double-underscore path is equivalent to the KeyTextTransform
|
||||
# for the final nested key in a PostgreSQL JSONField.
|
||||
extracted_text = Cast(SCORE_PATH_RAW, output_field=CharField())
|
||||
|
||||
# 2. Clean up any residual double-quotes that sometimes remain if the data
|
||||
# was stored as a quoted string (e.g., "50")
|
||||
cleaned_text = Replace(extracted_text, Value('"'), Value(''))
|
||||
|
||||
# 3. Use NullIf to convert the cleaned text to NULL if it is an empty string
|
||||
# (or if it was a non-numeric string like "N/A" after quote removal)
|
||||
null_if_empty = NullIf(cleaned_text, Value(''))
|
||||
|
||||
# 4. Cast the result (which is now either a clean numeric string or NULL) to an IntegerField.
|
||||
final_cast = Cast(null_if_empty, output_field=IntegerField())
|
||||
|
||||
# 5. Use Coalesce to ensure NULL scores (from errors or missing data) default to 0.
|
||||
return Coalesce(final_cast, Value(0))
|
||||
@ -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,44 +1,62 @@
|
||||
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:
|
||||
FormTemplate.objects.create(job=instance, is_active=False, name=instance.title)
|
||||
if created or not instance.ai_parsed:
|
||||
try:
|
||||
form_template = instance.form_template
|
||||
except FormTemplate.DoesNotExist:
|
||||
FormTemplate.objects.get_or_create(
|
||||
job=instance, is_active=False, name=instance.title
|
||||
)
|
||||
async_task(
|
||||
'recruitment.tasks.format_job_description',
|
||||
"recruitment.tasks.format_job_description",
|
||||
instance.pk,
|
||||
# hook='myapp.tasks.email_sent_callback' # Optional callback
|
||||
)
|
||||
else:
|
||||
existing_schedule = Schedule.objects.filter(
|
||||
func='recruitment.tasks.form_close',
|
||||
args=f'[{instance.pk}]',
|
||||
schedule_type=Schedule.ONCE
|
||||
func="recruitment.tasks.form_close",
|
||||
args=f"[{instance.pk}]",
|
||||
schedule_type=Schedule.ONCE,
|
||||
).first()
|
||||
|
||||
if instance.STATUS_CHOICES=='ACTIVE' and instance.application_deadline:
|
||||
if instance.STATUS_CHOICES == "ACTIVE" and instance.application_deadline:
|
||||
if not existing_schedule:
|
||||
# Create a new schedule if one does not exist
|
||||
schedule(
|
||||
'recruitment.tasks.form_close',
|
||||
"recruitment.tasks.form_close",
|
||||
instance.pk,
|
||||
schedule_type=Schedule.ONCE,
|
||||
next_run=instance.application_deadline,
|
||||
repeats=-1, # Ensure the schedule is deleted after it runs
|
||||
name=f'job_closing_{instance.pk}' # Add a name for easier lookup
|
||||
repeats=-1, # Ensure the schedule is deleted after it runs
|
||||
name=f"job_closing_{instance.pk}", # Add a name for easier lookup
|
||||
)
|
||||
elif existing_schedule.next_run != instance.application_deadline:
|
||||
# Update an existing schedule's run time
|
||||
@ -48,6 +66,7 @@ def format_job(sender, instance, created, **kwargs):
|
||||
# If the instance is no longer active, delete the scheduled task
|
||||
existing_schedule.delete()
|
||||
|
||||
|
||||
# @receiver(post_save, sender=JobPosting)
|
||||
# def update_form_template_status(sender, instance, created, **kwargs):
|
||||
# if not created:
|
||||
@ -57,16 +76,18 @@ 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',
|
||||
"recruitment.tasks.handle_reume_parsing_and_scoring",
|
||||
instance.pk,
|
||||
hook='recruitment.hooks.callback_ai_parsing'
|
||||
hook="recruitment.hooks.callback_ai_parsing",
|
||||
)
|
||||
|
||||
|
||||
@receiver(post_save, sender=FormTemplate)
|
||||
def create_default_stages(sender, instance, created, **kwargs):
|
||||
"""
|
||||
@ -77,67 +98,75 @@ def create_default_stages(sender, instance, created, **kwargs):
|
||||
# Stage 1: Contact Information
|
||||
contact_stage = FormStage.objects.create(
|
||||
template=instance,
|
||||
name='Contact Information',
|
||||
name="Contact Information",
|
||||
order=0,
|
||||
is_predefined=True
|
||||
is_predefined=True,
|
||||
)
|
||||
# FormField.objects.create(
|
||||
# stage=contact_stage,
|
||||
# label="First Name",
|
||||
# field_type="text",
|
||||
# required=True,
|
||||
# order=0,
|
||||
# is_predefined=True,
|
||||
# )
|
||||
# FormField.objects.create(
|
||||
# stage=contact_stage,
|
||||
# label="Last Name",
|
||||
# field_type="text",
|
||||
# required=True,
|
||||
# order=1,
|
||||
# is_predefined=True,
|
||||
# )
|
||||
# FormField.objects.create(
|
||||
# stage=contact_stage,
|
||||
# label="Email Address",
|
||||
# field_type="email",
|
||||
# required=True,
|
||||
# order=2,
|
||||
# is_predefined=True,
|
||||
# )
|
||||
# FormField.objects.create(
|
||||
# stage=contact_stage,
|
||||
# label="Phone Number",
|
||||
# field_type="phone",
|
||||
# required=True,
|
||||
# order=3,
|
||||
# is_predefined=True,
|
||||
# )
|
||||
# FormField.objects.create(
|
||||
# stage=contact_stage,
|
||||
# label="Address",
|
||||
# field_type="text",
|
||||
# required=False,
|
||||
# order=4,
|
||||
# is_predefined=True,
|
||||
# )
|
||||
# FormField.objects.create(
|
||||
# stage=contact_stage,
|
||||
# label="National ID / Iqama Number",
|
||||
# field_type="text",
|
||||
# required=False,
|
||||
# order=5,
|
||||
# is_predefined=True,
|
||||
# )
|
||||
FormField.objects.create(
|
||||
stage=contact_stage,
|
||||
label='First Name',
|
||||
field_type='text',
|
||||
required=True,
|
||||
order=0,
|
||||
is_predefined=True
|
||||
)
|
||||
FormField.objects.create(
|
||||
stage=contact_stage,
|
||||
label='Last Name',
|
||||
field_type='text',
|
||||
required=True,
|
||||
label="GPA",
|
||||
field_type="text",
|
||||
required=False,
|
||||
order=1,
|
||||
is_predefined=True
|
||||
is_predefined=True,
|
||||
)
|
||||
FormField.objects.create(
|
||||
stage=contact_stage,
|
||||
label='Email Address',
|
||||
field_type='email',
|
||||
label="Resume Upload",
|
||||
field_type="file",
|
||||
required=True,
|
||||
order=2,
|
||||
is_predefined=True
|
||||
)
|
||||
FormField.objects.create(
|
||||
stage=contact_stage,
|
||||
label='Phone Number',
|
||||
field_type='phone',
|
||||
required=True,
|
||||
order=3,
|
||||
is_predefined=True
|
||||
)
|
||||
FormField.objects.create(
|
||||
stage=contact_stage,
|
||||
label='Address',
|
||||
field_type='text',
|
||||
required=False,
|
||||
order=4,
|
||||
is_predefined=True
|
||||
)
|
||||
FormField.objects.create(
|
||||
stage=contact_stage,
|
||||
label='National ID / Iqama Number',
|
||||
field_type='text',
|
||||
required=False,
|
||||
order=5,
|
||||
is_predefined=True
|
||||
)
|
||||
FormField.objects.create(
|
||||
stage=contact_stage,
|
||||
label='Resume Upload',
|
||||
field_type='file',
|
||||
required=True,
|
||||
order=6,
|
||||
is_predefined=True,
|
||||
file_types='.pdf,.doc,.docx',
|
||||
max_file_size=1
|
||||
file_types=".pdf,.doc,.docx",
|
||||
max_file_size=1,
|
||||
)
|
||||
|
||||
# # Stage 2: Resume Objective
|
||||
@ -371,11 +400,14 @@ def create_default_stages(sender, instance, created, **kwargs):
|
||||
# SSE notification cache for real-time updates
|
||||
SSE_NOTIFICATION_CACHE = {}
|
||||
|
||||
|
||||
@receiver(post_save, sender=Notification)
|
||||
def notification_created(sender, instance, created, **kwargs):
|
||||
"""Signal handler for when a notification is created"""
|
||||
if created:
|
||||
logger.info(f"New notification created: {instance.id} for user {instance.recipient.username}")
|
||||
logger.info(
|
||||
f"New notification created: {instance.id} for user {instance.recipient.username}"
|
||||
)
|
||||
|
||||
# Store notification in cache for SSE
|
||||
user_id = instance.recipient.id
|
||||
@ -383,12 +415,13 @@ def notification_created(sender, instance, created, **kwargs):
|
||||
SSE_NOTIFICATION_CACHE[user_id] = []
|
||||
|
||||
notification_data = {
|
||||
'id': instance.id,
|
||||
'message': instance.message[:100] + ('...' if len(instance.message) > 100 else ''),
|
||||
'type': instance.get_notification_type_display(),
|
||||
'status': instance.get_status_display(),
|
||||
'time_ago': 'Just now',
|
||||
'url': f"/notifications/{instance.id}/"
|
||||
"id": instance.id,
|
||||
"message": instance.message[:100]
|
||||
+ ("..." if len(instance.message) > 100 else ""),
|
||||
"type": instance.get_notification_type_display(),
|
||||
"status": instance.get_status_display(),
|
||||
"time_ago": "Just now",
|
||||
"url": f"/notifications/{instance.id}/",
|
||||
}
|
||||
|
||||
SSE_NOTIFICATION_CACHE[user_id].append(notification_data)
|
||||
@ -399,11 +432,40 @@ 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}")
|
||||
password = generate_random_password()
|
||||
user = User.objects.create_user(
|
||||
username=instance.name, email=instance.email, user_type="agency"
|
||||
)
|
||||
user.set_password(password)
|
||||
user.save()
|
||||
instance.user = user
|
||||
instance.generated_password = password
|
||||
instance.save()
|
||||
logger.info(f"Generated password stored for agency: {instance.pk}")
|
||||
|
||||
|
||||
@receiver(post_save, sender=Person)
|
||||
def person_created(sender, instance, created, **kwargs):
|
||||
if created and not instance.user:
|
||||
logger.info(f"New Person created: {instance.pk} - {instance.email}")
|
||||
user = User.objects.create_user(
|
||||
username=instance.email,
|
||||
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,12 +7,12 @@ 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
|
||||
from django.utils import timezone
|
||||
from . models import InterviewSchedule,ScheduledInterview,ZoomMeeting
|
||||
from . models import InterviewSchedule,ScheduledInterview,ZoomMeetingDetails
|
||||
|
||||
# Add python-docx import for Word document processing
|
||||
try:
|
||||
@ -25,10 +25,10 @@ except ImportError:
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
OPENROUTER_API_KEY ='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'
|
||||
OPENROUTER_MODEL = 'qwen/qwen-2.5-72b-instruct:free'
|
||||
OPENROUTER_API_KEY ='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'
|
||||
# OPENROUTER_MODEL = 'qwen/qwen-2.5-72b-instruct:free'
|
||||
|
||||
# OPENROUTER_MODEL = 'openai/gpt-oss-20b:free'
|
||||
OPENROUTER_MODEL = 'openai/gpt-oss-20b'
|
||||
# OPENROUTER_MODEL = 'openai/gpt-oss-20b'
|
||||
# OPENROUTER_MODEL = 'mistralai/mistral-small-3.2-24b-instruct:free'
|
||||
|
||||
@ -185,7 +185,8 @@ def format_job_description(pk):
|
||||
job_posting.benefits=data.get('html_benefits')
|
||||
job_posting.application_instructions=data.get('html_application_instruction')
|
||||
job_posting.linkedin_post_formated_data=data.get('linkedin_post_data')
|
||||
job_posting.save(update_fields=['description', 'qualifications','linkedin_post_formated_data'])
|
||||
job_posting.ai_parsed = True
|
||||
job_posting.save(update_fields=['description', 'qualifications','linkedin_post_formated_data','ai_parsed'])
|
||||
|
||||
|
||||
def ai_handler(prompt):
|
||||
@ -244,8 +245,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.")
|
||||
@ -440,7 +441,7 @@ def handle_reume_parsing_and_scoring(pk):
|
||||
print(f"Successfully scored and saved analysis for candidate {instance.id}")
|
||||
|
||||
|
||||
|
||||
from django.utils import timezone
|
||||
def create_interview_and_meeting(
|
||||
candidate_id,
|
||||
job_id,
|
||||
@ -453,43 +454,45 @@ 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)
|
||||
|
||||
interview_datetime = datetime.combine(slot_date, slot_time)
|
||||
interview_datetime = timezone.make_aware(datetime.combine(slot_date, slot_time))
|
||||
meeting_topic = f"Interview for {job.title} - {candidate.name}"
|
||||
|
||||
# 1. External API Call (Slow)
|
||||
|
||||
|
||||
result = create_zoom_meeting(meeting_topic, interview_datetime, duration)
|
||||
|
||||
if result["status"] == "success":
|
||||
# 2. Database Writes (Slow)
|
||||
zoom_meeting = ZoomMeeting.objects.create(
|
||||
zoom_meeting = ZoomMeetingDetails.objects.create(
|
||||
topic=meeting_topic,
|
||||
start_time=interview_datetime,
|
||||
duration=duration,
|
||||
meeting_id=result["meeting_details"]["meeting_id"],
|
||||
join_url=result["meeting_details"]["join_url"],
|
||||
details_url=result["meeting_details"]["join_url"],
|
||||
zoom_gateway_response=result["zoom_gateway_response"],
|
||||
host_email=result["meeting_details"]["host_email"],
|
||||
password=result["meeting_details"]["password"]
|
||||
password=result["meeting_details"]["password"],
|
||||
location_type="Remote"
|
||||
)
|
||||
ScheduledInterview.objects.create(
|
||||
candidate=candidate,
|
||||
application=candidate,
|
||||
job=job,
|
||||
zoom_meeting=zoom_meeting,
|
||||
interview_location=zoom_meeting,
|
||||
schedule=schedule,
|
||||
interview_date=slot_date,
|
||||
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:
|
||||
@ -517,7 +520,7 @@ def handle_zoom_webhook_event(payload):
|
||||
try:
|
||||
# Use filter().first() to avoid exceptions if the meeting doesn't exist yet,
|
||||
# and to simplify the logic flow.
|
||||
meeting_instance = ZoomMeeting.objects.filter(meeting_id=meeting_id_zoom).first()
|
||||
meeting_instance = ZoomMeetingDetails.objects.filter(meeting_id=meeting_id_zoom).first()
|
||||
print(meeting_instance)
|
||||
# --- 1. Creation and Update Events ---
|
||||
if event_type == 'meeting.updated':
|
||||
@ -698,20 +701,20 @@ def sync_candidate_to_source_task(candidate_id, source_id):
|
||||
dict: Sync result for this specific candidate-source pair
|
||||
"""
|
||||
from .candidate_sync_service import CandidateSyncService
|
||||
from .models import Candidate, Source, IntegrationLog
|
||||
from .models import Application, Source, IntegrationLog
|
||||
|
||||
logger.info(f"Starting sync task for candidate {candidate_id} to source {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 +722,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 +734,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}
|
||||
|
||||
@ -754,23 +757,23 @@ from django.utils.html import strip_tags
|
||||
|
||||
def _task_send_individual_email(subject, body_message, recipient, attachments):
|
||||
"""Internal helper to create and send a single email."""
|
||||
|
||||
|
||||
from_email = getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@kaauh.edu.sa')
|
||||
is_html = '<' in body_message and '>' in body_message
|
||||
|
||||
|
||||
if is_html:
|
||||
plain_message = strip_tags(body_message)
|
||||
email_obj = EmailMultiAlternatives(subject=subject, body=plain_message, from_email=from_email, to=[recipient])
|
||||
email_obj.attach_alternative(body_message, "text/html")
|
||||
else:
|
||||
email_obj = EmailMultiAlternatives(subject=subject, body=body_message, from_email=from_email, to=[recipient])
|
||||
|
||||
|
||||
if attachments:
|
||||
for attachment in attachments:
|
||||
if isinstance(attachment, tuple) and len(attachment) == 3:
|
||||
filename, content, content_type = attachment
|
||||
email_obj.attach(filename, content, content_type)
|
||||
|
||||
|
||||
try:
|
||||
email_obj.send(fail_silently=False)
|
||||
return True
|
||||
@ -796,7 +799,7 @@ def send_bulk_email_task(subject, message, recipient_list, attachments=None, hoo
|
||||
# The 'message' is the custom message specific to this recipient.
|
||||
if _task_send_individual_email(subject, message, recipient, attachments):
|
||||
successful_sends += 1
|
||||
|
||||
|
||||
if successful_sends > 0:
|
||||
logger.info(f"Bulk email task completed successfully. Sent to {successful_sends}/{total_recipients} recipients.")
|
||||
return {
|
||||
@ -817,4 +820,3 @@ def email_success_hook(task):
|
||||
logger.info(f"Task ID {task.id} succeeded. Result: {task.result}")
|
||||
else:
|
||||
logger.error(f"Task ID {task.id} failed. Error: {task.result}")
|
||||
|
||||
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)
|
||||
13
recruitment/templatetags/mytags.py
Normal file
13
recruitment/templatetags/mytags.py
Normal file
@ -0,0 +1,13 @@
|
||||
from django import template
|
||||
|
||||
register = template.Library()
|
||||
|
||||
@register.filter(name='split')
|
||||
def split(value, delimiter):
|
||||
"""
|
||||
Split a string by a delimiter and return a list.
|
||||
"""
|
||||
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,18 +7,20 @@ 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,
|
||||
JobPosting, Application, Person, ZoomMeeting, FormTemplate, FormStage, FormField,
|
||||
FormSubmission, FieldResponse, InterviewSchedule, ScheduledInterview,
|
||||
TrainingMaterial, Source, HiringAgency, Profile, MeetingComment
|
||||
TrainingMaterial, Source, HiringAgency, MeetingComment
|
||||
)
|
||||
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
|
||||
@ -35,7 +37,6 @@ class BaseTestCase(TestCase):
|
||||
password='testpass123',
|
||||
is_staff=True
|
||||
)
|
||||
self.profile = Profile.objects.create(user=self.user)
|
||||
|
||||
# Create test data
|
||||
self.job = JobPosting.objects.create(
|
||||
@ -46,14 +47,20 @@ 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
|
||||
person = Person.objects.create(
|
||||
first_name='John',
|
||||
last_name='Doe',
|
||||
email='john@example.com',
|
||||
phone='1234567890',
|
||||
phone='1234567890'
|
||||
)
|
||||
|
||||
self.candidate = Application.objects.create(
|
||||
person=person,
|
||||
resume=SimpleUploadedFile('resume.pdf', b'file_content', content_type='application/pdf'),
|
||||
job=self.job,
|
||||
stage='Applied'
|
||||
@ -231,28 +238,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 +253,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 +300,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 +352,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 = Application.objects.create(
|
||||
person=person,
|
||||
resume=SimpleUploadedFile('resume.pdf', b'file_content', content_type='application/pdf'),
|
||||
job=self.job,
|
||||
stage='Applied'
|
||||
@ -369,7 +384,7 @@ class IntegrationTests(BaseTestCase):
|
||||
)
|
||||
|
||||
# 5. Verify all stages and relationships
|
||||
self.assertEqual(Candidate.objects.count(), 2)
|
||||
self.assertEqual(Application.objects.count(), 2)
|
||||
self.assertEqual(ScheduledInterview.objects.count(), 1)
|
||||
self.assertEqual(candidate.stage, 'Interview')
|
||||
self.assertEqual(scheduled_interview.candidate, candidate)
|
||||
@ -439,7 +454,7 @@ class IntegrationTests(BaseTestCase):
|
||||
)
|
||||
|
||||
# Verify candidate was created
|
||||
self.assertEqual(Candidate.objects.filter(email='new@example.com').count(), 1)
|
||||
self.assertEqual(Application.objects.filter(person__email='new@example.com').count(), 1)
|
||||
|
||||
|
||||
class PerformanceTests(BaseTestCase):
|
||||
@ -449,11 +464,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}'
|
||||
)
|
||||
Application.objects.create(
|
||||
person=person,
|
||||
resume=SimpleUploadedFile(f'resume{i}.pdf', b'file_content', content_type='application/pdf'),
|
||||
job=self.job,
|
||||
stage='Applied'
|
||||
)
|
||||
@ -594,16 +613,20 @@ 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)
|
||||
return Application.objects.create(**defaults)
|
||||
|
||||
@staticmethod
|
||||
def create_zoom_meeting(**kwargs):
|
||||
|
||||
@ -23,28 +23,28 @@ from io import BytesIO
|
||||
from PIL import Image
|
||||
|
||||
from .models import (
|
||||
JobPosting, Candidate, ZoomMeeting, FormTemplate, FormStage, FormField,
|
||||
JobPosting, Application, Person, ZoomMeeting, FormTemplate, FormStage, FormField,
|
||||
FormSubmission, FieldResponse, InterviewSchedule, ScheduledInterview,
|
||||
TrainingMaterial, Source, HiringAgency, Profile, MeetingComment, JobPostingImage,
|
||||
TrainingMaterial, Source, HiringAgency, MeetingComment, JobPostingImage,
|
||||
BreakTime
|
||||
)
|
||||
from .forms import (
|
||||
JobPostingForm, CandidateForm, ZoomMeetingForm, MeetingCommentForm,
|
||||
CandidateStageForm, InterviewScheduleForm, BreakTimeFormSet
|
||||
JobPostingForm, ApplicationForm, ZoomMeetingForm, MeetingCommentForm,
|
||||
ApplicationStageForm, InterviewScheduleForm, BreakTimeFormSet
|
||||
)
|
||||
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,
|
||||
schedule_interviews_view, confirm_schedule_interviews_view, _handle_preview_submission,
|
||||
_handle_confirm_schedule, _handle_get_request
|
||||
)
|
||||
from .views_frontend import CandidateListView, JobListView, JobCreateView
|
||||
# from .views_frontend import CandidateListView, JobListView, JobCreateView
|
||||
from .utils import (
|
||||
create_zoom_meeting, delete_zoom_meeting, update_zoom_meeting,
|
||||
get_zoom_meeting_details, get_candidates_from_request,
|
||||
get_available_time_slots
|
||||
)
|
||||
from .zoom_api import ZoomAPIError
|
||||
# from .zoom_api import ZoomAPIError
|
||||
|
||||
|
||||
class AdvancedModelTests(TestCase):
|
||||
@ -57,7 +57,6 @@ class AdvancedModelTests(TestCase):
|
||||
password='testpass123',
|
||||
is_staff=True
|
||||
)
|
||||
self.profile = Profile.objects.create(user=self.user)
|
||||
|
||||
self.job = JobPosting.objects.create(
|
||||
title='Software Engineer',
|
||||
@ -121,11 +120,13 @@ class AdvancedModelTests(TestCase):
|
||||
|
||||
def test_candidate_stage_transition_validation(self):
|
||||
"""Test advanced candidate stage transition validation"""
|
||||
candidate = Candidate.objects.create(
|
||||
first_name='John',
|
||||
last_name='Doe',
|
||||
email='john@example.com',
|
||||
phone='1234567890',
|
||||
application = Application.objects.create(
|
||||
person=Person.objects.create(
|
||||
first_name='John',
|
||||
last_name='Doe',
|
||||
email='john@example.com',
|
||||
phone='1234567890'
|
||||
),
|
||||
job=self.job,
|
||||
stage='Applied'
|
||||
)
|
||||
@ -133,17 +134,19 @@ class AdvancedModelTests(TestCase):
|
||||
# Test valid transitions
|
||||
valid_transitions = ['Exam', 'Interview', 'Offer']
|
||||
for stage in valid_transitions:
|
||||
candidate.stage = stage
|
||||
candidate.save()
|
||||
form = CandidateStageForm(data={'stage': stage}, candidate=candidate)
|
||||
self.assertTrue(form.is_valid())
|
||||
application.stage = stage
|
||||
application.save()
|
||||
# Note: CandidateStageForm may need to be updated for Application model
|
||||
# form = CandidateStageForm(data={'stage': stage}, candidate=application)
|
||||
# self.assertTrue(form.is_valid())
|
||||
|
||||
# Test invalid transition (e.g., from Offer back to Applied)
|
||||
candidate.stage = 'Offer'
|
||||
candidate.save()
|
||||
form = CandidateStageForm(data={'stage': 'Applied'}, candidate=candidate)
|
||||
application.stage = 'Offer'
|
||||
application.save()
|
||||
# Note: CandidateStageForm may need to be updated for Application model
|
||||
# form = CandidateStageForm(data={'stage': 'Applied'}, candidate=application)
|
||||
# This should fail based on your STAGE_SEQUENCE logic
|
||||
# Note: You'll need to implement can_transition_to method in Candidate model
|
||||
# Note: You'll need to implement can_transition_to method in Application model
|
||||
|
||||
def test_zoom_meeting_conflict_detection(self):
|
||||
"""Test conflict detection for overlapping meetings"""
|
||||
@ -195,19 +198,25 @@ class AdvancedModelTests(TestCase):
|
||||
|
||||
def test_interview_schedule_complex_validation(self):
|
||||
"""Test interview schedule validation with complex constraints"""
|
||||
# Create candidates
|
||||
candidate1 = Candidate.objects.create(
|
||||
first_name='John', last_name='Doe', email='john@example.com',
|
||||
phone='1234567890', job=self.job, stage='Interview'
|
||||
# Create applications
|
||||
application1 = Application.objects.create(
|
||||
person=Person.objects.create(
|
||||
first_name='John', last_name='Doe', email='john@example.com',
|
||||
phone='1234567890'
|
||||
),
|
||||
job=self.job, stage='Interview'
|
||||
)
|
||||
candidate2 = Candidate.objects.create(
|
||||
first_name='Jane', last_name='Smith', email='jane@example.com',
|
||||
phone='9876543210', job=self.job, stage='Interview'
|
||||
application2 = Application.objects.create(
|
||||
person=Person.objects.create(
|
||||
first_name='Jane', last_name='Smith', email='jane@example.com',
|
||||
phone='9876543210'
|
||||
),
|
||||
job=self.job, stage='Interview'
|
||||
)
|
||||
|
||||
# Create schedule with valid data
|
||||
schedule_data = {
|
||||
'candidates': [candidate1.id, candidate2.id],
|
||||
'candidates': [application1.id, application2.id],
|
||||
'start_date': date.today() + timedelta(days=1),
|
||||
'end_date': date.today() + timedelta(days=7),
|
||||
'working_days': [0, 1, 2, 3, 4], # Mon-Fri
|
||||
@ -279,7 +288,6 @@ class AdvancedViewTests(TestCase):
|
||||
password='testpass123',
|
||||
is_staff=True
|
||||
)
|
||||
self.profile = Profile.objects.create(user=self.user)
|
||||
|
||||
self.job = JobPosting.objects.create(
|
||||
title='Software Engineer',
|
||||
@ -293,11 +301,13 @@ class AdvancedViewTests(TestCase):
|
||||
status='ACTIVE'
|
||||
)
|
||||
|
||||
self.candidate = Candidate.objects.create(
|
||||
first_name='John',
|
||||
last_name='Doe',
|
||||
email='john@example.com',
|
||||
phone='1234567890',
|
||||
self.application = Application.objects.create(
|
||||
person=Person.objects.create(
|
||||
first_name='John',
|
||||
last_name='Doe',
|
||||
email='john@example.com',
|
||||
phone='1234567890'
|
||||
),
|
||||
job=self.job,
|
||||
stage='Applied'
|
||||
)
|
||||
@ -313,18 +323,27 @@ class AdvancedViewTests(TestCase):
|
||||
|
||||
def test_job_detail_with_multiple_candidates(self):
|
||||
"""Test job detail view with multiple candidates at different stages"""
|
||||
# Create more candidates at different stages
|
||||
Candidate.objects.create(
|
||||
first_name='Jane', last_name='Smith', email='jane@example.com',
|
||||
phone='9876543210', job=self.job, stage='Exam'
|
||||
# Create more applications at different stages
|
||||
Application.objects.create(
|
||||
person=Person.objects.create(
|
||||
first_name='Jane', last_name='Smith', email='jane@example.com',
|
||||
phone='9876543210'
|
||||
),
|
||||
job=self.job, stage='Exam'
|
||||
)
|
||||
Candidate.objects.create(
|
||||
first_name='Bob', last_name='Johnson', email='bob@example.com',
|
||||
phone='5555555555', job=self.job, stage='Interview'
|
||||
Application.objects.create(
|
||||
person=Person.objects.create(
|
||||
first_name='Bob', last_name='Johnson', email='bob@example.com',
|
||||
phone='5555555555'
|
||||
),
|
||||
job=self.job, stage='Interview'
|
||||
)
|
||||
Candidate.objects.create(
|
||||
first_name='Alice', last_name='Brown', email='alice@example.com',
|
||||
phone='4444444444', job=self.job, stage='Offer'
|
||||
Application.objects.create(
|
||||
person=Person.objects.create(
|
||||
first_name='Alice', last_name='Brown', email='alice@example.com',
|
||||
phone='4444444444'
|
||||
),
|
||||
job=self.job, stage='Offer'
|
||||
)
|
||||
|
||||
response = self.client.get(reverse('job_detail', kwargs={'slug': self.job.slug}))
|
||||
@ -352,7 +371,7 @@ class AdvancedViewTests(TestCase):
|
||||
|
||||
# Create scheduled interviews
|
||||
ScheduledInterview.objects.create(
|
||||
candidate=self.candidate,
|
||||
application=self.application,
|
||||
job=self.job,
|
||||
zoom_meeting=self.zoom_meeting,
|
||||
interview_date=timezone.now().date(),
|
||||
@ -361,9 +380,12 @@ class AdvancedViewTests(TestCase):
|
||||
)
|
||||
|
||||
ScheduledInterview.objects.create(
|
||||
candidate=Candidate.objects.create(
|
||||
first_name='Jane', last_name='Smith', email='jane@example.com',
|
||||
phone='9876543210', job=self.job, stage='Interview'
|
||||
application=Application.objects.create(
|
||||
person=Person.objects.create(
|
||||
first_name='Jane', last_name='Smith', email='jane@example.com',
|
||||
phone='9876543210'
|
||||
),
|
||||
job=self.job, stage='Interview'
|
||||
),
|
||||
job=self.job,
|
||||
zoom_meeting=meeting2,
|
||||
@ -382,14 +404,20 @@ class AdvancedViewTests(TestCase):
|
||||
|
||||
def test_candidate_list_advanced_search(self):
|
||||
"""Test candidate list view with advanced search functionality"""
|
||||
# Create more candidates for testing
|
||||
Candidate.objects.create(
|
||||
first_name='Jane', last_name='Smith', email='jane@example.com',
|
||||
phone='9876543210', job=self.job, stage='Exam'
|
||||
# Create more applications for testing
|
||||
Application.objects.create(
|
||||
person=Person.objects.create(
|
||||
first_name='Jane', last_name='Smith', email='jane@example.com',
|
||||
phone='9876543210'
|
||||
),
|
||||
job=self.job, stage='Exam'
|
||||
)
|
||||
Candidate.objects.create(
|
||||
first_name='Bob', last_name='Johnson', email='bob@example.com',
|
||||
phone='5555555555', job=self.job, stage='Interview'
|
||||
Application.objects.create(
|
||||
person=Person.objects.create(
|
||||
first_name='Bob', last_name='Johnson', email='bob@example.com',
|
||||
phone='5555555555'
|
||||
),
|
||||
job=self.job, stage='Interview'
|
||||
)
|
||||
|
||||
# Test search by name
|
||||
@ -420,18 +448,20 @@ class AdvancedViewTests(TestCase):
|
||||
|
||||
def test_interview_scheduling_workflow(self):
|
||||
"""Test the complete interview scheduling workflow"""
|
||||
# Create candidates for scheduling
|
||||
candidates = []
|
||||
# Create applications for scheduling
|
||||
applications = []
|
||||
for i in range(3):
|
||||
candidate = Candidate.objects.create(
|
||||
first_name=f'Candidate{i}',
|
||||
last_name=f'Test{i}',
|
||||
email=f'candidate{i}@example.com',
|
||||
phone=f'123456789{i}',
|
||||
application = Application.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}'
|
||||
),
|
||||
job=self.job,
|
||||
stage='Interview'
|
||||
)
|
||||
candidates.append(candidate)
|
||||
applications.append(application)
|
||||
|
||||
# Test GET request (initial form)
|
||||
request = self.client.get(reverse('schedule_interviews', kwargs={'slug': self.job.slug}))
|
||||
@ -449,7 +479,7 @@ class AdvancedViewTests(TestCase):
|
||||
# Test _handle_preview_submission
|
||||
self.client.login(username='testuser', password='testpass123')
|
||||
post_data = {
|
||||
'candidates': [c.pk for c in candidates],
|
||||
'candidates': [a.pk for a in applications],
|
||||
'start_date': (date.today() + timedelta(days=1)).isoformat(),
|
||||
'end_date': (date.today() + timedelta(days=7)).isoformat(),
|
||||
'working_days': [0, 1, 2, 3, 4],
|
||||
@ -505,38 +535,40 @@ class AdvancedViewTests(TestCase):
|
||||
|
||||
def test_bulk_operations(self):
|
||||
"""Test bulk operations on candidates"""
|
||||
# Create multiple candidates
|
||||
candidates = []
|
||||
# Create multiple applications
|
||||
applications = []
|
||||
for i in range(5):
|
||||
candidate = Candidate.objects.create(
|
||||
first_name=f'Bulk{i}',
|
||||
last_name=f'Test{i}',
|
||||
email=f'bulk{i}@example.com',
|
||||
phone=f'123456789{i}',
|
||||
application = Application.objects.create(
|
||||
person=Person.objects.create(
|
||||
first_name=f'Bulk{i}',
|
||||
last_name=f'Test{i}',
|
||||
email=f'bulk{i}@example.com',
|
||||
phone=f'123456789{i}'
|
||||
),
|
||||
job=self.job,
|
||||
stage='Applied'
|
||||
)
|
||||
candidates.append(candidate)
|
||||
applications.append(application)
|
||||
|
||||
# Test bulk status update
|
||||
candidate_ids = [c.pk for c in candidates]
|
||||
application_ids = [a.pk for a in applications]
|
||||
self.client.login(username='testuser', password='testpass123')
|
||||
|
||||
# This would be tested via a form submission
|
||||
# For now, we test the view logic directly
|
||||
request = self.client.post(
|
||||
reverse('candidate_update_status', kwargs={'slug': self.job.slug}),
|
||||
data={'candidate_ids': candidate_ids, 'mark_as': 'Exam'}
|
||||
data={'candidate_ids': application_ids, 'mark_as': 'Exam'}
|
||||
)
|
||||
# Should redirect back to the view
|
||||
self.assertEqual(request.status_code, 302)
|
||||
|
||||
# Verify candidates were updated
|
||||
updated_count = Candidate.objects.filter(
|
||||
pk__in=candidate_ids,
|
||||
# Verify applications were updated
|
||||
updated_count = Application.objects.filter(
|
||||
pk__in=application_ids,
|
||||
stage='Exam'
|
||||
).count()
|
||||
self.assertEqual(updated_count, len(candidates))
|
||||
self.assertEqual(updated_count, len(applications))
|
||||
|
||||
|
||||
class AdvancedFormTests(TestCase):
|
||||
@ -627,7 +659,7 @@ class AdvancedFormTests(TestCase):
|
||||
'resume': valid_file
|
||||
}
|
||||
|
||||
form = CandidateForm(data=candidate_data, files=candidate_data)
|
||||
form = ApplicationForm(data=candidate_data, files=candidate_data)
|
||||
self.assertTrue(form.is_valid())
|
||||
|
||||
# Test invalid file type (would need custom validator)
|
||||
@ -636,25 +668,27 @@ class AdvancedFormTests(TestCase):
|
||||
def test_dynamic_form_fields(self):
|
||||
"""Test forms with dynamically populated fields"""
|
||||
# Test InterviewScheduleForm with dynamic candidate queryset
|
||||
# Create candidates in Interview stage
|
||||
candidates = []
|
||||
# Create applications in Interview stage
|
||||
applications = []
|
||||
for i in range(3):
|
||||
candidate = Candidate.objects.create(
|
||||
first_name=f'Interview{i}',
|
||||
last_name=f'Candidate{i}',
|
||||
email=f'interview{i}@example.com',
|
||||
phone=f'123456789{i}',
|
||||
application = Application.objects.create(
|
||||
person=Person.objects.create(
|
||||
first_name=f'Interview{i}',
|
||||
last_name=f'Candidate{i}',
|
||||
email=f'interview{i}@example.com',
|
||||
phone=f'123456789{i}'
|
||||
),
|
||||
job=self.job,
|
||||
stage='Interview'
|
||||
)
|
||||
candidates.append(candidate)
|
||||
applications.append(application)
|
||||
|
||||
# Form should only show Interview stage candidates
|
||||
# Form should only show Interview stage applications
|
||||
form = InterviewScheduleForm(slug=self.job.slug)
|
||||
self.assertEqual(form.fields['candidates'].queryset.count(), 3)
|
||||
|
||||
for candidate in candidates:
|
||||
self.assertIn(candidate, form.fields['candidates'].queryset)
|
||||
for application in applications:
|
||||
self.assertIn(application, form.fields['candidates'].queryset)
|
||||
|
||||
|
||||
class AdvancedIntegrationTests(TransactionTestCase):
|
||||
@ -668,7 +702,6 @@ class AdvancedIntegrationTests(TransactionTestCase):
|
||||
password='testpass123',
|
||||
is_staff=True
|
||||
)
|
||||
self.profile = Profile.objects.create(user=self.user)
|
||||
|
||||
def test_complete_hiring_workflow(self):
|
||||
"""Test the complete hiring workflow from job posting to hire"""
|
||||
@ -749,22 +782,22 @@ class AdvancedIntegrationTests(TransactionTestCase):
|
||||
)
|
||||
self.assertEqual(response.status_code, 302) # Redirect to success page
|
||||
|
||||
# 5. Verify candidate was created
|
||||
candidate = Candidate.objects.get(email='sarah@example.com')
|
||||
self.assertEqual(candidate.stage, 'Applied')
|
||||
self.assertEqual(candidate.job, job)
|
||||
# 5. Verify application was created
|
||||
application = Application.objects.get(person__email='sarah@example.com')
|
||||
self.assertEqual(application.stage, 'Applied')
|
||||
self.assertEqual(application.job, job)
|
||||
|
||||
# 6. Move candidate to Exam stage
|
||||
candidate.stage = 'Exam'
|
||||
candidate.save()
|
||||
# 6. Move application to Exam stage
|
||||
application.stage = 'Exam'
|
||||
application.save()
|
||||
|
||||
# 7. Move candidate to Interview stage
|
||||
candidate.stage = 'Interview'
|
||||
candidate.save()
|
||||
# 7. Move application to Interview stage
|
||||
application.stage = 'Interview'
|
||||
application.save()
|
||||
|
||||
# 8. Create interview schedule
|
||||
scheduled_interview = ScheduledInterview.objects.create(
|
||||
candidate=candidate,
|
||||
application=application,
|
||||
job=job,
|
||||
interview_date=timezone.now().date() + timedelta(days=7),
|
||||
interview_time=time(14, 0),
|
||||
@ -773,7 +806,7 @@ class AdvancedIntegrationTests(TransactionTestCase):
|
||||
|
||||
# 9. Create Zoom meeting
|
||||
zoom_meeting = ZoomMeeting.objects.create(
|
||||
topic=f'Interview: {job.title} with {candidate.name}',
|
||||
topic=f'Interview: {job.title} with {application.person.get_full_name()}',
|
||||
start_time=timezone.now() + timedelta(days=7, hours=14),
|
||||
duration=60,
|
||||
timezone='UTC',
|
||||
@ -786,16 +819,16 @@ class AdvancedIntegrationTests(TransactionTestCase):
|
||||
scheduled_interview.save()
|
||||
|
||||
# 11. Verify all relationships
|
||||
self.assertEqual(candidate.scheduled_interviews.count(), 1)
|
||||
self.assertEqual(application.scheduled_interviews.count(), 1)
|
||||
self.assertEqual(zoom_meeting.interview, scheduled_interview)
|
||||
self.assertEqual(job.candidates.count(), 1)
|
||||
self.assertEqual(job.applications.count(), 1)
|
||||
|
||||
# 12. Complete hire process
|
||||
candidate.stage = 'Offer'
|
||||
candidate.save()
|
||||
application.stage = 'Offer'
|
||||
application.save()
|
||||
|
||||
# 13. Verify final state
|
||||
self.assertEqual(Candidate.objects.filter(stage='Offer').count(), 1)
|
||||
self.assertEqual(Application.objects.filter(stage='Offer').count(), 1)
|
||||
|
||||
def test_data_integrity_across_operations(self):
|
||||
"""Test data integrity across multiple operations"""
|
||||
@ -811,18 +844,20 @@ class AdvancedIntegrationTests(TransactionTestCase):
|
||||
max_applications=5
|
||||
)
|
||||
|
||||
# Create multiple candidates
|
||||
candidates = []
|
||||
# Create multiple applications
|
||||
applications = []
|
||||
for i in range(3):
|
||||
candidate = Candidate.objects.create(
|
||||
first_name=f'Data{i}',
|
||||
last_name=f'Scientist{i}',
|
||||
email=f'data{i}@example.com',
|
||||
phone=f'123456789{i}',
|
||||
application = Application.objects.create(
|
||||
person=Person.objects.create(
|
||||
first_name=f'Data{i}',
|
||||
last_name=f'Scientist{i}',
|
||||
email=f'data{i}@example.com',
|
||||
phone=f'123456789{i}'
|
||||
),
|
||||
job=job,
|
||||
stage='Applied'
|
||||
)
|
||||
candidates.append(candidate)
|
||||
applications.append(application)
|
||||
|
||||
# Create form template
|
||||
template = FormTemplate.objects.create(
|
||||
@ -832,12 +867,12 @@ class AdvancedIntegrationTests(TransactionTestCase):
|
||||
is_active=True
|
||||
)
|
||||
|
||||
# Create submissions for candidates
|
||||
for i, candidate in enumerate(candidates):
|
||||
# Create submissions for applications
|
||||
for i, application in enumerate(applications):
|
||||
submission = FormSubmission.objects.create(
|
||||
template=template,
|
||||
applicant_name=f'{candidate.first_name} {candidate.last_name}',
|
||||
applicant_email=candidate.email
|
||||
applicant_name=f'{application.person.first_name} {application.person.last_name}',
|
||||
applicant_email=application.person.email
|
||||
)
|
||||
|
||||
# Create field responses
|
||||
@ -856,12 +891,14 @@ class AdvancedIntegrationTests(TransactionTestCase):
|
||||
self.assertEqual(FieldResponse.objects.count(), 3)
|
||||
|
||||
# Test application limit
|
||||
for i in range(3): # Try to add more candidates than limit
|
||||
Candidate.objects.create(
|
||||
first_name=f'Extra{i}',
|
||||
last_name=f'Candidate{i}',
|
||||
email=f'extra{i}@example.com',
|
||||
phone=f'11111111{i}',
|
||||
for i in range(3): # Try to add more applications than limit
|
||||
Application.objects.create(
|
||||
person=Person.objects.create(
|
||||
first_name=f'Extra{i}',
|
||||
last_name=f'Candidate{i}',
|
||||
email=f'extra{i}@example.com',
|
||||
phone=f'11111111{i}'
|
||||
),
|
||||
job=job,
|
||||
stage='Applied'
|
||||
)
|
||||
@ -873,7 +910,7 @@ class AdvancedIntegrationTests(TransactionTestCase):
|
||||
@patch('recruitment.views.create_zoom_meeting')
|
||||
def test_zoom_integration_workflow(self, mock_create):
|
||||
"""Test complete Zoom integration workflow"""
|
||||
# Setup job and candidate
|
||||
# Setup job and application
|
||||
job = JobPosting.objects.create(
|
||||
title='Remote Developer',
|
||||
department='Engineering',
|
||||
@ -881,10 +918,12 @@ class AdvancedIntegrationTests(TransactionTestCase):
|
||||
created_by=self.user
|
||||
)
|
||||
|
||||
candidate = Candidate.objects.create(
|
||||
first_name='Remote',
|
||||
last_name='Developer',
|
||||
email='remote@example.com',
|
||||
application = Application.objects.create(
|
||||
person=Person.objects.create(
|
||||
first_name='Remote',
|
||||
last_name='Developer',
|
||||
email='remote@example.com'
|
||||
),
|
||||
job=job,
|
||||
stage='Interview'
|
||||
)
|
||||
@ -906,7 +945,7 @@ class AdvancedIntegrationTests(TransactionTestCase):
|
||||
# Schedule meeting via API
|
||||
with patch('recruitment.views.ScheduledInterview.objects.create') as mock_create_interview:
|
||||
mock_create_interview.return_value = ScheduledInterview(
|
||||
candidate=candidate,
|
||||
application=application,
|
||||
job=job,
|
||||
zoom_meeting=None,
|
||||
interview_date=timezone.now().date(),
|
||||
@ -916,7 +955,7 @@ class AdvancedIntegrationTests(TransactionTestCase):
|
||||
|
||||
response = self.client.post(
|
||||
reverse('api_schedule_candidate_meeting',
|
||||
kwargs={'job_slug': job.slug, 'candidate_pk': candidate.pk}),
|
||||
kwargs={'job_slug': job.slug, 'candidate_pk': application.pk}),
|
||||
data={
|
||||
'start_time': (timezone.now() + timedelta(hours=1)).isoformat(),
|
||||
'duration': 60
|
||||
@ -941,43 +980,45 @@ class AdvancedIntegrationTests(TransactionTestCase):
|
||||
created_by=self.user
|
||||
)
|
||||
|
||||
# Create candidates
|
||||
candidates = []
|
||||
# Create applications
|
||||
applications = []
|
||||
for i in range(10):
|
||||
candidate = Candidate.objects.create(
|
||||
first_name=f'Concurrent{i}',
|
||||
last_name=f'Test{i}',
|
||||
email=f'concurrent{i}@example.com',
|
||||
application = Application.objects.create(
|
||||
person=Person.objects.create(
|
||||
first_name=f'Concurrent{i}',
|
||||
last_name=f'Test{i}',
|
||||
email=f'concurrent{i}@example.com'
|
||||
),
|
||||
job=job,
|
||||
stage='Applied'
|
||||
)
|
||||
candidates.append(candidate)
|
||||
applications.append(application)
|
||||
|
||||
# Test concurrent candidate updates
|
||||
# Test concurrent application updates
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
def update_candidate(candidate_id, stage):
|
||||
def update_application(application_id, stage):
|
||||
from django.test import TestCase
|
||||
from django.db import transaction
|
||||
from recruitment.models import Candidate
|
||||
from recruitment.models import Application
|
||||
|
||||
with transaction.atomic():
|
||||
candidate = Candidate.objects.select_for_update().get(pk=candidate_id)
|
||||
candidate.stage = stage
|
||||
candidate.save()
|
||||
application = Application.objects.select_for_update().get(pk=application_id)
|
||||
application.stage = stage
|
||||
application.save()
|
||||
|
||||
# Update candidates concurrently
|
||||
# Update applications concurrently
|
||||
with ThreadPoolExecutor(max_workers=3) as executor:
|
||||
futures = [
|
||||
executor.submit(update_candidate, c.pk, 'Exam')
|
||||
for c in candidates
|
||||
executor.submit(update_application, a.pk, 'Exam')
|
||||
for a in applications
|
||||
]
|
||||
|
||||
for future in futures:
|
||||
future.result()
|
||||
|
||||
# Verify all updates completed
|
||||
self.assertEqual(Candidate.objects.filter(stage='Exam').count(), len(candidates))
|
||||
self.assertEqual(Application.objects.filter(stage='Exam').count(), len(applications))
|
||||
|
||||
|
||||
class SecurityTests(TestCase):
|
||||
|
||||
@ -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,227 @@ 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"),
|
||||
|
||||
# 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"),
|
||||
|
||||
# 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_document_review_view/",
|
||||
views.candidate_document_review_view,
|
||||
name="candidate_document_review_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 +261,157 @@ 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'),
|
||||
|
||||
# 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'),
|
||||
# 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>/calendar/",
|
||||
views.interview_calendar_view,
|
||||
name="interview_calendar",
|
||||
),
|
||||
path(
|
||||
"jobs/<slug:slug>/calendar/interview/<int:interview_id>/",
|
||||
views.interview_detail_view,
|
||||
name="interview_detail",
|
||||
),
|
||||
|
||||
# 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 +419,78 @@ 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/<int:pk>/reset/", views.portal_password_reset, name="portal_password_reset"),
|
||||
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(
|
||||
"candidate/applications/<slug:slug>/",
|
||||
views.candidate_application_detail,
|
||||
name="candidate_application_detail",
|
||||
),
|
||||
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 +499,162 @@ 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/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/<slug:slug>/", 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"),
|
||||
# Candidate Document Management URLs
|
||||
path("candidate/documents/upload/<slug:slug>/", views.document_upload, name="candidate_document_upload"),
|
||||
path("candidate/documents/<int:document_id>/delete/", views.document_delete, name="candidate_document_delete"),
|
||||
path("candidate/documents/<int:document_id>/download/", views.document_download, name="candidate_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'),
|
||||
|
||||
|
||||
# Candidate Signup
|
||||
path('candidate/signup/<slug:template_slug>/', views.candidate_signup, name='candidate_signup'),
|
||||
# Password Reset
|
||||
path('user/<int:pk>/password-reset/', views.portal_password_reset, name='portal_password_reset'),
|
||||
|
||||
# # --- SCHEDULED INTERVIEW URLS (New Centralized Management) ---
|
||||
# path('interview/list/', views.InterviewListView.as_view(), name='interview_list'),
|
||||
# path('interview/list/', views.interview_list, 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'),
|
||||
|
||||
|
||||
#interview and meeting related urls
|
||||
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(
|
||||
"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",
|
||||
),
|
||||
# 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",
|
||||
),
|
||||
# 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("interviews/meetings/", views.MeetingListView.as_view(), name="list_meetings"),
|
||||
|
||||
# 1. Onsite Reschedule URL
|
||||
path(
|
||||
'<slug:slug>/candidate/<int:candidate_id>/onsite/reschedule/<int:meeting_id>/',
|
||||
views.reschedule_onsite_meeting,
|
||||
name='reschedule_onsite_meeting'
|
||||
),
|
||||
|
||||
# 2. Onsite Delete URL
|
||||
|
||||
path(
|
||||
'job/<slug:slug>/candidates/<int:candidate_pk>/delete-onsite-meeting/<int:meeting_id>/',
|
||||
views.delete_onsite_meeting_for_candidate,
|
||||
name='delete_onsite_meeting_for_candidate'
|
||||
),
|
||||
|
||||
path(
|
||||
'job/<slug:slug>/candidate/<int:candidate_pk>/schedule/onsite/',
|
||||
views.schedule_onsite_meeting_for_candidate,
|
||||
name='schedule_onsite_meeting_for_candidate' # This is the name used in the button
|
||||
),
|
||||
|
||||
|
||||
# Detail View (assuming slug is on ScheduledInterview)
|
||||
# path("interviews/meetings/<slug:slug>/", views.MeetingDetailView.as_view(), name="meeting_details"),
|
||||
]
|
||||
|
||||
@ -594,7 +594,7 @@ def update_meeting(instance, updated_data):
|
||||
instance.topic = zoom_details.get("topic", instance.topic)
|
||||
|
||||
instance.duration = zoom_details.get("duration", instance.duration)
|
||||
instance.join_url = zoom_details.get("join_url", instance.join_url)
|
||||
instance.details_url = zoom_details.get("join_url", instance.details_url)
|
||||
instance.password = zoom_details.get("password", instance.password)
|
||||
# Corrected status assignment: instance.status, not instance.password
|
||||
instance.status = zoom_details.get("status")
|
||||
|
||||
4675
recruitment/views.py
4675
recruitment/views.py
File diff suppressed because it is too large
Load Diff
@ -8,6 +8,7 @@ from django.db.models.fields.json import KeyTextTransform
|
||||
from recruitment.utils import json_to_markdown_table
|
||||
from django.db.models import Count, Avg, F, FloatField
|
||||
from django.db.models.functions import Cast
|
||||
from django.db.models.functions import Coalesce, Cast, Replace, NullIf
|
||||
from . import models
|
||||
from django.utils.translation import get_language
|
||||
from . import forms
|
||||
@ -22,7 +23,7 @@ from django.views.generic import ListView, CreateView, UpdateView, DeleteView, D
|
||||
# JobForm removed - using JobPostingForm instead
|
||||
from django.urls import reverse_lazy
|
||||
from django.db.models import FloatField
|
||||
from django.db.models import F, IntegerField, Count, Avg, Sum, Q, ExpressionWrapper, fields
|
||||
from django.db.models import F, IntegerField, Count, Avg, Sum, Q, ExpressionWrapper, fields, Value,CharField
|
||||
from django.db.models.functions import Cast, Coalesce, TruncDate
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.shortcuts import render
|
||||
@ -30,6 +31,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 +43,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 +51,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 +61,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 +85,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 +94,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 +132,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 +143,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 +156,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 +184,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 +204,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 +230,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 +262,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 +276,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 +330,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 +342,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 +351,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 +365,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 +375,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 +409,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 +437,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(
|
||||
@ -444,15 +455,38 @@ def dashboard_view(request):
|
||||
)
|
||||
)
|
||||
|
||||
# safe_match_score_cast = Cast(
|
||||
# # 3. If the result after stripping quotes is an empty string (''), convert it to NULL.
|
||||
# NullIf(
|
||||
# # 2. Use Replace to remove the literal double quotes (") that might be present.
|
||||
# Replace(
|
||||
# # 1. Use the double-underscore path (which uses the ->> operator for the final value)
|
||||
# # and cast to CharField for text-based cleanup functions.
|
||||
# Cast(SCORE_PATH, output_field=CharField()),
|
||||
# Value('"'), Value('') # Replace the double quote character with an empty string
|
||||
# ),
|
||||
# Value('') # Value to check for (empty string)
|
||||
# ),
|
||||
# output_field=IntegerField() # 4. Cast the clean, non-empty string (or NULL) to an integer.
|
||||
# )
|
||||
|
||||
|
||||
# candidates_with_score_query= candidate_queryset.filter(is_resume_parsed=True).annotate(
|
||||
# # The Coalesce handles NULL values (from missing data, non-numeric data, or NullIf) and sets them to 0.
|
||||
# annotated_match_score=Coalesce(safe_match_score_cast, Value(0))
|
||||
# )
|
||||
|
||||
|
||||
|
||||
# A. Pipeline & Volume Metrics (Scoped)
|
||||
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 +497,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 +530,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 +543,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 +561,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 +586,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 +609,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 +619,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 +654,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,18 +684,22 @@ 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)
|
||||
|
||||
if request.method == "POST":
|
||||
if stage_type == 'exam':
|
||||
status = request.POST.get("exam_status")
|
||||
score = request.POST.get("exam_score")
|
||||
candidate.exam_status = status
|
||||
candidate.exam_score = score
|
||||
candidate.exam_date = timezone.now()
|
||||
candidate.save(update_fields=['exam_status', 'exam_date'])
|
||||
candidate.save(update_fields=['exam_status','exam_score', 'exam_date'])
|
||||
return render(request,'recruitment/partials/exam-results.html',{'candidate':candidate,'job':job})
|
||||
elif stage_type == 'interview':
|
||||
candidate.interview_status = status
|
||||
@ -709,6 +752,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 +766,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 +892,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 +931,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 +966,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 +1018,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 +1053,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 +1082,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 +1102,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 +1112,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'
|
||||
|
||||
@ -1,3 +0,0 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
@ -1,6 +0,0 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ApplicantConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'applicant'
|
||||
@ -1,22 +0,0 @@
|
||||
from django import forms
|
||||
from .models import ApplicantForm, FormField
|
||||
|
||||
class ApplicantFormCreateForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = ApplicantForm
|
||||
fields = ['name', 'description']
|
||||
widgets = {
|
||||
'name': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'description': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
|
||||
}
|
||||
|
||||
class FormFieldForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = FormField
|
||||
fields = ['label', 'field_type', 'required', 'help_text', 'choices']
|
||||
widgets = {
|
||||
'label': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'field_type': forms.Select(attrs={'class': 'form-control'}),
|
||||
'help_text': forms.Textarea(attrs={'class': 'form-control', 'rows': 2}),
|
||||
'choices': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Option1, Option2, Option3'}),
|
||||
}
|
||||
@ -1,49 +0,0 @@
|
||||
from django import forms
|
||||
from .models import FormField
|
||||
|
||||
# applicant/forms_builder.py
|
||||
def create_dynamic_form(form_instance):
|
||||
fields = {}
|
||||
|
||||
for field in form_instance.fields.all():
|
||||
field_kwargs = {
|
||||
'label': field.label,
|
||||
'required': field.required,
|
||||
'help_text': field.help_text
|
||||
}
|
||||
|
||||
# Use stable field_name instead of database ID
|
||||
field_key = field.field_name
|
||||
|
||||
if field.field_type == 'text':
|
||||
fields[field_key] = forms.CharField(**field_kwargs)
|
||||
elif field.field_type == 'email':
|
||||
fields[field_key] = forms.EmailField(**field_kwargs)
|
||||
elif field.field_type == 'phone':
|
||||
fields[field_key] = forms.CharField(**field_kwargs)
|
||||
elif field.field_type == 'number':
|
||||
fields[field_key] = forms.IntegerField(**field_kwargs)
|
||||
elif field.field_type == 'date':
|
||||
fields[field_key] = forms.DateField(**field_kwargs)
|
||||
elif field.field_type == 'textarea':
|
||||
fields[field_key] = forms.CharField(
|
||||
widget=forms.Textarea,
|
||||
**field_kwargs
|
||||
)
|
||||
elif field.field_type in ['select', 'radio']:
|
||||
choices = [(c.strip(), c.strip()) for c in field.choices.split(',') if c.strip()]
|
||||
if not choices:
|
||||
choices = [('', '---')]
|
||||
if field.field_type == 'select':
|
||||
fields[field_key] = forms.ChoiceField(choices=choices, **field_kwargs)
|
||||
else:
|
||||
fields[field_key] = forms.ChoiceField(
|
||||
choices=choices,
|
||||
widget=forms.RadioSelect,
|
||||
**field_kwargs
|
||||
)
|
||||
elif field.field_type == 'checkbox':
|
||||
field_kwargs['required'] = False
|
||||
fields[field_key] = forms.BooleanField(**field_kwargs)
|
||||
|
||||
return type('DynamicApplicantForm', (forms.Form,), fields)
|
||||
@ -1,70 +0,0 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-01 21:41
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('jobs', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ApplicantForm',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(help_text="Form version name (e.g., 'Version A', 'Version B' etc)", max_length=200)),
|
||||
('description', models.TextField(blank=True, help_text='Optional description of this form version')),
|
||||
('is_active', models.BooleanField(default=False, help_text='Only one form can be active per job')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('job_posting', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='applicant_forms', to='jobs.jobposting')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Application Form',
|
||||
'verbose_name_plural': 'Application Forms',
|
||||
'ordering': ['-created_at'],
|
||||
'unique_together': {('job_posting', 'name')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ApplicantSubmission',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('submitted_at', models.DateTimeField(auto_now_add=True)),
|
||||
('data', models.JSONField()),
|
||||
('ip_address', models.GenericIPAddressField(blank=True, null=True)),
|
||||
('score', models.FloatField(default=0, help_text='Ranking score for the applicant submission')),
|
||||
('form', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='applicant.applicantform')),
|
||||
('job_posting', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='jobs.jobposting')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Applicant Submission',
|
||||
'verbose_name_plural': 'Applicant Submissions',
|
||||
'ordering': ['-submitted_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='FormField',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('label', models.CharField(max_length=255)),
|
||||
('field_type', models.CharField(choices=[('text', 'Text'), ('email', 'Email'), ('phone', 'Phone'), ('number', 'Number'), ('date', 'Date'), ('select', 'Dropdown'), ('radio', 'Radio Buttons'), ('checkbox', 'Checkbox'), ('textarea', 'Paragraph Text'), ('file', 'File Upload'), ('image', 'Image Upload')], max_length=20)),
|
||||
('required', models.BooleanField(default=True)),
|
||||
('help_text', models.TextField(blank=True)),
|
||||
('choices', models.TextField(blank=True, help_text='Comma-separated options for select/radio fields')),
|
||||
('order', models.IntegerField(default=0)),
|
||||
('field_name', models.CharField(blank=True, max_length=100)),
|
||||
('form', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fields', to='applicant.applicantform')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Form Field',
|
||||
'verbose_name_plural': 'Form Fields',
|
||||
'ordering': ['order'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -1,144 +0,0 @@
|
||||
# models.py
|
||||
from django.db import models
|
||||
from django.core.exceptions import ValidationError
|
||||
from jobs.models import JobPosting
|
||||
from django.urls import reverse
|
||||
|
||||
class ApplicantForm(models.Model):
|
||||
"""Multiple dynamic forms per job posting, only one active at a time"""
|
||||
job_posting = models.ForeignKey(
|
||||
JobPosting,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='applicant_forms'
|
||||
)
|
||||
name = models.CharField(
|
||||
max_length=200,
|
||||
help_text="Form version name (e.g., 'Version A', 'Version B' etc)"
|
||||
)
|
||||
description = models.TextField(
|
||||
blank=True,
|
||||
help_text="Optional description of this form version"
|
||||
)
|
||||
is_active = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Only one form can be active per job"
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ('job_posting', 'name')
|
||||
ordering = ['-created_at']
|
||||
verbose_name = "Application Form"
|
||||
verbose_name_plural = "Application Forms"
|
||||
|
||||
def __str__(self):
|
||||
status = "(Active)" if self.is_active else "(Inactive)"
|
||||
return f"{self.name} for {self.job_posting.title} {status}"
|
||||
|
||||
def clean(self):
|
||||
"""Ensure only one active form per job"""
|
||||
if self.is_active:
|
||||
existing_active = self.job_posting.applicant_forms.filter(
|
||||
is_active=True
|
||||
).exclude(pk=self.pk)
|
||||
if existing_active.exists():
|
||||
raise ValidationError(
|
||||
"Only one active application form is allowed per job posting."
|
||||
)
|
||||
super().clean()
|
||||
|
||||
def activate(self):
|
||||
"""Set this form as active and deactivate others"""
|
||||
self.is_active = True
|
||||
self.save()
|
||||
# Deactivate other forms
|
||||
self.job_posting.applicant_forms.exclude(pk=self.pk).update(
|
||||
is_active=False
|
||||
)
|
||||
|
||||
def get_public_url(self):
|
||||
"""Returns the public application URL for this job's active form"""
|
||||
return reverse('applicant:apply_form', args=[self.job_posting.internal_job_id])
|
||||
|
||||
|
||||
class FormField(models.Model):
|
||||
FIELD_TYPES = [
|
||||
('text', 'Text'),
|
||||
('email', 'Email'),
|
||||
('phone', 'Phone'),
|
||||
('number', 'Number'),
|
||||
('date', 'Date'),
|
||||
('select', 'Dropdown'),
|
||||
('radio', 'Radio Buttons'),
|
||||
('checkbox', 'Checkbox'),
|
||||
('textarea', 'Paragraph Text'),
|
||||
('file', 'File Upload'),
|
||||
('image', 'Image Upload'),
|
||||
]
|
||||
|
||||
form = models.ForeignKey(
|
||||
ApplicantForm,
|
||||
related_name='fields',
|
||||
on_delete=models.CASCADE
|
||||
)
|
||||
label = models.CharField(max_length=255)
|
||||
field_type = models.CharField(max_length=20, choices=FIELD_TYPES)
|
||||
required = models.BooleanField(default=True)
|
||||
help_text = models.TextField(blank=True)
|
||||
choices = models.TextField(
|
||||
blank=True,
|
||||
help_text="Comma-separated options for select/radio fields"
|
||||
)
|
||||
order = models.IntegerField(default=0)
|
||||
field_name = models.CharField(max_length=100, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['order']
|
||||
verbose_name = "Form Field"
|
||||
verbose_name_plural = "Form Fields"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.label} ({self.field_type}) in {self.form.name}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.field_name:
|
||||
# Create a stable field name from label (e.g., "Full Name" → "full_name")
|
||||
import re
|
||||
# Use Unicode word characters, including Arabic, for field_name
|
||||
self.field_name = re.sub(
|
||||
r'[^\w]+',
|
||||
'_',
|
||||
self.label.lower(),
|
||||
flags=re.UNICODE
|
||||
).strip('_')
|
||||
# Ensure uniqueness within the form
|
||||
base_name = self.field_name
|
||||
counter = 1
|
||||
while FormField.objects.filter(
|
||||
form=self.form,
|
||||
field_name=self.field_name
|
||||
).exists():
|
||||
self.field_name = f"{base_name}_{counter}"
|
||||
counter += 1
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class ApplicantSubmission(models.Model):
|
||||
job_posting = models.ForeignKey(JobPosting, on_delete=models.CASCADE)
|
||||
form = models.ForeignKey(ApplicantForm, on_delete=models.CASCADE)
|
||||
submitted_at = models.DateTimeField(auto_now_add=True)
|
||||
data = models.JSONField()
|
||||
ip_address = models.GenericIPAddressField(null=True, blank=True)
|
||||
score = models.FloatField(
|
||||
default=0,
|
||||
help_text="Ranking score for the applicant submission"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-submitted_at']
|
||||
verbose_name = "Applicant Submission"
|
||||
verbose_name_plural = "Applicant Submissions"
|
||||
|
||||
def __str__(self):
|
||||
return f"Submission for {self.job_posting.title} at {self.submitted_at}"
|
||||
@ -1,94 +0,0 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}
|
||||
Apply: {{ job.title }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container my-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
|
||||
{# --- 1. Job Header and Overview (Fixed/Static Info) --- #}
|
||||
<div class="card bg-light-subtle mb-4 p-4 border-0 rounded-3 shadow-sm">
|
||||
<h1 class="h2 fw-bold text-primary mb-1">{{ job.title }}</h1>
|
||||
|
||||
<p class="mb-3 text-muted">
|
||||
Your final step to apply for this position.
|
||||
</p>
|
||||
|
||||
<div class="d-flex gap-4 small text-secondary">
|
||||
<div>
|
||||
<i class="fas fa-building me-1"></i>
|
||||
<strong>Department:</strong> {{ job.department|default:"Not specified" }}
|
||||
</div>
|
||||
<div>
|
||||
<i class="fas fa-map-marker-alt me-1"></i>
|
||||
<strong>Location:</strong> {{ job.get_location_display }}
|
||||
</div>
|
||||
<div>
|
||||
<i class="fas fa-briefcase me-1"></i>
|
||||
<strong>Type:</strong> {{ job.get_job_type_display }} • {{ job.get_workplace_type_display }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# --- 2. Application Form Section --- #}
|
||||
<div class="card p-5 border-0 rounded-3 shadow">
|
||||
<h2 class="h3 fw-semibold mb-3">Application Details</h2>
|
||||
|
||||
{% if applicant_form.description %}
|
||||
<p class="text-muted mb-4">{{ applicant_form.description }}</p>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" novalidate>
|
||||
{% csrf_token %}
|
||||
|
||||
{% for field in form %}
|
||||
<div class="form-group mb-4">
|
||||
{# Label Tag #}
|
||||
<label for="{{ field.id_for_label }}" class="form-label">
|
||||
{{ field.label }}
|
||||
{% if field.field.required %}<span class="text-danger">*</span>{% endif %}
|
||||
</label>
|
||||
|
||||
{# The Field Widget (Assumes form-control is applied in backend) #}
|
||||
{{ field }}
|
||||
|
||||
{# Field Errors #}
|
||||
{% if field.errors %}
|
||||
<div class="invalid-feedback d-block">{{ field.errors }}</div>
|
||||
{% endif %}
|
||||
|
||||
{# Help Text #}
|
||||
{% if field.help_text %}
|
||||
<div class="form-text">{{ field.help_text }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{# General Form Errors (Non-field errors) #}
|
||||
{% if form.non_field_errors %}
|
||||
<div class="alert alert-danger mb-4">
|
||||
{{ form.non_field_errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-lg mt-3 w-100">
|
||||
<i class="fas fa-paper-plane me-2"></i> Submit Application
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<footer class="mt-4 text-center">
|
||||
<a href="{% url 'applicant:review_job_detail' job.internal_job_id %}"
|
||||
class="btn btn-link text-secondary">
|
||||
← Review Job Details
|
||||
</a>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@ -1,68 +0,0 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}
|
||||
Define Form for {{ job.title }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container my-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8 col-md-10">
|
||||
|
||||
<div class="card shadow-lg border-0 p-4 p-md-5">
|
||||
|
||||
<h2 class="card-title text-center mb-4 text-dark">
|
||||
🛠️ New Application Form Configuration
|
||||
</h2>
|
||||
|
||||
<p class="text-center text-muted mb-4 border-bottom pb-3">
|
||||
You are creating a new form structure for job: <strong>{{ job.title }}</strong>
|
||||
</p>
|
||||
|
||||
<form method="post" novalidate>
|
||||
{% csrf_token %}
|
||||
|
||||
<fieldset class="mb-5">
|
||||
<legend class="h5 mb-3 text-secondary">Form Metadata</legend>
|
||||
|
||||
<div class="form-group mb-4">
|
||||
<label for="{{ form.name.id_for_label }}" class="form-label required">
|
||||
Form Name
|
||||
</label>
|
||||
{# The field should already have form-control applied from the backend #}
|
||||
{{ form.name }}
|
||||
|
||||
{% if form.name.errors %}
|
||||
<div class="text-danger small mt-1">{{ form.name.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="form-group mb-4">
|
||||
<label for="{{ form.description.id_for_label }}" class="form-label">
|
||||
Description
|
||||
</label>
|
||||
{# The field should already have form-control applied from the backend #}
|
||||
{{ form.description}}
|
||||
|
||||
{% if form.description.errors %}
|
||||
<div class="text-danger small mt-1">{{ form.description.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="d-flex justify-content-end gap-3 pt-3">
|
||||
<a href="{% url 'applicant:job_forms_list' job.internal_job_id %}"
|
||||
class="btn btn-outline-secondary">
|
||||
Cancel
|
||||
</a>
|
||||
<button type="submit" class="btn univ-color btn-lg">
|
||||
Create Form & Continue →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,103 +0,0 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}
|
||||
Manage Forms | {{ job.title }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-10">
|
||||
|
||||
<header class="mb-5 pb-3 border-bottom d-flex flex-column flex-md-row justify-content-between align-items-md-center">
|
||||
<div>
|
||||
<h2 class="h3 mb-1 ">
|
||||
<i class="fas fa-clipboard-list me-2 text-secondary"></i>
|
||||
Application Forms for <span class="text-success fw-bold">"{{ job.title }}"</span>
|
||||
</h2>
|
||||
<p class="text-muted small">
|
||||
Internal Job ID: **{{ job.internal_job_id }}**
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{# Primary Action Button using the theme color #}
|
||||
<a href="{% url 'applicant:create_form' job_id=job.internal_job_id %}"
|
||||
class="btn univ-color btn-lg shadow-sm mt-3 mt-md-0">
|
||||
<i class="fas fa-plus me-1"></i> Create New Form
|
||||
</a>
|
||||
</header>
|
||||
|
||||
{% if forms %}
|
||||
|
||||
<div class="list-group">
|
||||
{% for form in forms %}
|
||||
|
||||
{# Custom styling based on active state #}
|
||||
<div class="list-group-item d-flex flex-column flex-sm-row justify-content-between align-items-start align-items-sm-center p-3 mb-3 rounded shadow-sm
|
||||
{% if form.is_active %}border-success border-3 bg-light{% else %}border-secondary border-1{% endif %}">
|
||||
|
||||
{# Left Section: Form Details #}
|
||||
<div class="flex-grow-1 me-4 mb-2 mb-sm-0">
|
||||
<h4 class="h5 mb-1 d-inline-block">
|
||||
{{ form.name }}
|
||||
</h4>
|
||||
|
||||
{# Status Badge #}
|
||||
{% if form.is_active %}
|
||||
<span class="badge bg-success ms-2">
|
||||
<i class="fas fa-check-circle me-1"></i> Active Form
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary ms-2">
|
||||
<i class="fas fa-times-circle me-1"></i> Inactive
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
<p class="text-muted mt-1 mb-1 small">
|
||||
{{ form.description|default:"— No description provided. —" }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{# Right Section: Actions #}
|
||||
<div class="d-flex gap-2 align-items-center flex-wrap">
|
||||
|
||||
{# Edit Structure Button #}
|
||||
<a href="{% url 'applicant:edit_form' form.id %}"
|
||||
class="btn btn-sm btn-outline-secondary">
|
||||
<i class="fas fa-pen me-1"></i> Edit Structure
|
||||
</a>
|
||||
|
||||
{# Conditional Activation Button #}
|
||||
{% if not form.is_active %}
|
||||
<a href="{% url 'applicant:activate_form' form.id %}"
|
||||
class="btn btn-sm univ-color">
|
||||
<i class="fas fa-bolt me-1"></i> Activate Form
|
||||
</a>
|
||||
{% else %}
|
||||
{# Active indicator/Deactivate button placeholder #}
|
||||
<a href="#" class="btn btn-sm btn-outline-success" disabled>
|
||||
<i class="fas fa-star me-1"></i> Current Form
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<div class="text-center py-5 bg-light rounded shadow-sm">
|
||||
<i class="fas fa-file-alt fa-4x text-muted mb-3"></i>
|
||||
<p class="lead mb-0">No application forms have been created yet for this job.</p>
|
||||
<p class="mt-2 mb-0 text-secondary">Click the button above to define a new form structure.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<footer class="text-end mt-5 pt-3 border-top">
|
||||
<a href="{% url 'jobs:job_detail' job.internal_job_id %}"
|
||||
class="btn btn-outline-secondary">
|
||||
<i class="fas fa-arrow-left me-1"></i> Back to Job Details
|
||||
</a>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -1,129 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ job.title }} - University ATS{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-5">
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h2>{{ job.title }}</h2>
|
||||
<span class="badge bg-{{ job.status|lower }} status-badge">
|
||||
{{ job.get_status_display }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Job Details -->
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<strong>Department:</strong> {{ job.department|default:"Not specified" }}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<strong>Position Number:</strong> {{ job.position_number|default:"Not specified" }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<strong>Job Type:</strong> {{ job.get_job_type_display }}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<strong>Workplace:</strong> {{ job.get_workplace_type_display }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<strong>Location:</strong> {{ job.get_location_display }}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<strong>Created By:</strong> {{ job.created_by|default:"Not specified" }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if job.salary_range %}
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
<strong>Salary Range:</strong> {{ job.salary_range }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if job.start_date %}
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
<strong>Start Date:</strong> {{ job.start_date }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if job.application_deadline %}
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
<strong>Application Deadline:</strong> {{ job.application_deadline }}
|
||||
{% if job.is_expired %}
|
||||
<span class="badge bg-danger">EXPIRED</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Description -->
|
||||
{% if job.description %}
|
||||
<div class="mb-3">
|
||||
<h5>Description</h5>
|
||||
<div>{{ job.description|linebreaks }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if job.qualifications %}
|
||||
<div class="mb-3">
|
||||
<h5>Qualifications</h5>
|
||||
<div>{{ job.qualifications|linebreaks }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if job.benefits %}
|
||||
<div class="mb-3">
|
||||
<h5>Benefits</h5>
|
||||
<div>{{ job.benefits|linebreaks }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if job.application_instructions %}
|
||||
<div class="mb-3">
|
||||
<h5>Application Instructions</h5>
|
||||
<div>{{ job.application_instructions|linebreaks }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
|
||||
<!-- Add this section below your existing job details -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-header bg-success text-white">
|
||||
<h5><i class="fas fa-file-signature"></i> Ready to Apply?</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p>Review the job details on the left, then click the button below to submit your application.</p>
|
||||
<a href="{% url 'applicant:apply_form' job.internal_job_id %}" class="btn btn-success btn-lg w-100">
|
||||
<i class="fas fa-paper-plane"></i> Apply for this Position
|
||||
</a>
|
||||
<p class="text-muted mt-2">
|
||||
<small>You'll be redirected to our secure application form where you can upload your resume and provide additional details.</small>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{% endblock %}
|
||||
@ -1,35 +0,0 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Application Submitted - {{ job.title }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div style="text-align: center; padding: 30px 0;">
|
||||
<div style="width: 80px; height: 80px; background: #d4edda; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 20px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" fill="#28a745" viewBox="0 0 16 16">
|
||||
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h1 style="color: #28a745; margin-bottom: 15px;">Thank You!</h1>
|
||||
<h2>Your application has been submitted successfully</h2>
|
||||
|
||||
<div style="background: #f8f9fa; padding: 20px; border-radius: 8px; margin: 25px 0; text-align: left;">
|
||||
<p><strong>Position:</strong> {{ job.title }}</p>
|
||||
<p><strong>Job ID:</strong> {{ job.internal_job_id }}</p>
|
||||
<p><strong>Department:</strong> {{ job.department|default:"Not specified" }}</p>
|
||||
{% if job.application_deadline %}
|
||||
<p><strong>Application Deadline:</strong> {{ job.application_deadline|date:"F j, Y" }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<p style="font-size: 18px; line-height: 1.6;">
|
||||
We appreciate your interest in joining our team. Our hiring team will review your application
|
||||
and contact you if there's a potential match for this position.
|
||||
</p>
|
||||
|
||||
{% comment %} <div style="margin-top: 30px;">
|
||||
<a href="/" class="btn btn-primary" style="margin-right: 10px;">Apply to Another Position</a>
|
||||
<a href="{% url 'jobs:job_detail' job.internal_job_id %}" class="btn btn-outline">View Job Details</a>
|
||||
</div> {% endcomment %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -1,24 +0,0 @@
|
||||
import json
|
||||
from django import template
|
||||
|
||||
register = template.Library()
|
||||
|
||||
@register.filter(name='from_json')
|
||||
def from_json(json_string):
|
||||
"""
|
||||
Safely loads a JSON string into a Python object (list or dict).
|
||||
"""
|
||||
try:
|
||||
# The JSON string comes from the context and needs to be parsed
|
||||
return json.loads(json_string)
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
# Handle cases where the string is invalid or None/empty
|
||||
return []
|
||||
|
||||
|
||||
@register.filter(name='split')
|
||||
def split_string(value, key=None):
|
||||
"""Splits a string by the given key (default is space)."""
|
||||
if key is None:
|
||||
return value.split()
|
||||
return value.split(key)
|
||||
@ -1,14 +0,0 @@
|
||||
# from django.db.models.signals import post_save
|
||||
# from django.dispatch import receiver
|
||||
# from . import models
|
||||
#
|
||||
# @receiver(post_save, sender=models.Candidate)
|
||||
# def parse_resume(sender, instance, created, **kwargs):
|
||||
# if instance.resume and not instance.summary:
|
||||
# from .utils import extract_summary_from_pdf,match_resume_with_job_description
|
||||
# summary = extract_summary_from_pdf(instance.resume.path)
|
||||
# if 'error' not in summary:
|
||||
# instance.summary = summary
|
||||
# instance.save()
|
||||
#
|
||||
# # match_resume_with_job_description
|
||||
@ -1,3 +0,0 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@ -1,18 +0,0 @@
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
app_name = 'applicant'
|
||||
|
||||
urlpatterns = [
|
||||
# Form Management
|
||||
path('job/<str:job_id>/forms/', views.job_forms_list, name='job_forms_list'),
|
||||
path('job/<str:job_id>/forms/create/', views.create_form_for_job, name='create_form'),
|
||||
path('form/<int:form_id>/edit/', views.edit_form, name='edit_form'),
|
||||
path('field/<int:field_id>/delete/', views.delete_field, name='delete_field'),
|
||||
path('form/<int:form_id>/activate/', views.activate_form, name='activate_form'),
|
||||
|
||||
# Public Application
|
||||
path('apply/<str:job_id>/', views.apply_form_view, name='apply_form'),
|
||||
path('review/job/detail/<str:job_id>/',views.review_job_detail, name="review_job_detail"),
|
||||
path('apply/<str:job_id>/thank-you/', views.thank_you_view, name='thank_you'),
|
||||
]
|
||||
@ -1,34 +0,0 @@
|
||||
import os
|
||||
import fitz # PyMuPDF
|
||||
import spacy
|
||||
import requests
|
||||
from recruitment import models
|
||||
from django.conf import settings
|
||||
|
||||
nlp = spacy.load("en_core_web_sm")
|
||||
|
||||
def extract_text_from_pdf(pdf_path):
|
||||
text = ""
|
||||
with fitz.open(pdf_path) as doc:
|
||||
for page in doc:
|
||||
text += page.get_text()
|
||||
return text
|
||||
|
||||
def extract_summary_from_pdf(pdf_path):
|
||||
if not os.path.exists(pdf_path):
|
||||
return {'error': 'File not found'}
|
||||
|
||||
text = extract_text_from_pdf(pdf_path)
|
||||
doc = nlp(text)
|
||||
summary = {
|
||||
'name': doc.ents[0].text if doc.ents else '',
|
||||
'skills': [chunk.text for chunk in doc.noun_chunks if len(chunk.text.split()) > 1],
|
||||
'summary': text[:500]
|
||||
}
|
||||
return summary
|
||||
|
||||
def match_resume_with_job_description(resume, job_description,prompt=""):
|
||||
resume_doc = nlp(resume)
|
||||
job_doc = nlp(job_description)
|
||||
similarity = resume_doc.similarity(job_doc)
|
||||
return similarity
|
||||
@ -1,175 +0,0 @@
|
||||
# applicant/views.py (Updated edit_form function)
|
||||
|
||||
from django.shortcuts import render, get_object_or_404, redirect
|
||||
from django.contrib import messages
|
||||
from django.http import Http404, JsonResponse # <-- Import JsonResponse
|
||||
from django.views.decorators.csrf import csrf_exempt # <-- Needed for JSON POST if not using FormData
|
||||
import json # <-- Import json
|
||||
from django.db import transaction # <-- Import transaction
|
||||
|
||||
# (Keep all your existing imports)
|
||||
from .models import ApplicantForm, FormField, ApplicantSubmission
|
||||
from .forms import ApplicantFormCreateForm, FormFieldForm
|
||||
from jobs.models import JobPosting
|
||||
from .forms_builder import create_dynamic_form
|
||||
|
||||
# ... (Keep all other functions like job_forms_list, create_form_for_job, etc.)
|
||||
# ...
|
||||
|
||||
|
||||
|
||||
# === FORM MANAGEMENT VIEWS ===
|
||||
|
||||
def job_forms_list(request, job_id):
|
||||
"""List all forms for a specific job"""
|
||||
job = get_object_or_404(JobPosting, internal_job_id=job_id)
|
||||
forms = job.applicant_forms.all()
|
||||
return render(request, 'applicant/job_forms_list.html', {
|
||||
'job': job,
|
||||
'forms': forms
|
||||
})
|
||||
|
||||
def create_form_for_job(request, job_id):
|
||||
"""Create a new form for a job"""
|
||||
job = get_object_or_404(JobPosting, internal_job_id=job_id)
|
||||
|
||||
if request.method == 'POST':
|
||||
form = ApplicantFormCreateForm(request.POST)
|
||||
if form.is_valid():
|
||||
applicant_form = form.save(commit=False)
|
||||
applicant_form.job_posting = job
|
||||
applicant_form.save()
|
||||
messages.success(request, 'Form created successfully!')
|
||||
return redirect('applicant:job_forms_list', job_id=job_id)
|
||||
else:
|
||||
form = ApplicantFormCreateForm()
|
||||
|
||||
return render(request, 'applicant/create_form.html', {
|
||||
'job': job,
|
||||
'form': form
|
||||
})
|
||||
|
||||
|
||||
@transaction.atomic # Ensures all fields are saved or none are
|
||||
def edit_form(request, form_id):
|
||||
"""Edit form details and manage fields, including dynamic builder save."""
|
||||
applicant_form = get_object_or_404(ApplicantForm, id=form_id)
|
||||
job = applicant_form.job_posting
|
||||
|
||||
if request.method == 'POST':
|
||||
# --- 1. Handle JSON data from the Form Builder (JavaScript) ---
|
||||
if request.content_type == 'application/json':
|
||||
try:
|
||||
field_data = json.loads(request.body)
|
||||
|
||||
# Clear existing fields for this form
|
||||
applicant_form.fields.all().delete()
|
||||
|
||||
# Create new fields from the JSON data
|
||||
for field_config in field_data:
|
||||
# Sanitize/ensure required fields are present
|
||||
FormField.objects.create(
|
||||
form=applicant_form,
|
||||
label=field_config.get('label', 'New Field'),
|
||||
field_type=field_config.get('field_type', 'text'),
|
||||
required=field_config.get('required', True),
|
||||
help_text=field_config.get('help_text', ''),
|
||||
choices=field_config.get('choices', ''),
|
||||
order=field_config.get('order', 0),
|
||||
# field_name will be auto-generated/re-generated on save() if needed
|
||||
)
|
||||
|
||||
return JsonResponse({'status': 'success', 'message': 'Form structure saved successfully!'})
|
||||
except json.JSONDecodeError:
|
||||
return JsonResponse({'status': 'error', 'message': 'Invalid JSON data.'}, status=400)
|
||||
except Exception as e:
|
||||
return JsonResponse({'status': 'error', 'message': f'Server error: {str(e)}'}, status=500)
|
||||
|
||||
# --- 2. Handle standard POST requests (e.g., saving form details) ---
|
||||
elif 'save_form_details' in request.POST: # Changed the button name for clarity
|
||||
form_details = ApplicantFormCreateForm(request.POST, instance=applicant_form)
|
||||
if form_details.is_valid():
|
||||
form_details.save()
|
||||
messages.success(request, 'Form details updated successfully!')
|
||||
return redirect('applicant:edit_form', form_id=form_id)
|
||||
|
||||
# Note: The 'add_field' branch is now redundant since we use the builder,
|
||||
# but you can keep it if you want the old manual way too.
|
||||
|
||||
# --- GET Request (or unsuccessful POST) ---
|
||||
form_details = ApplicantFormCreateForm(instance=applicant_form)
|
||||
# Get initial fields to load into the JS builder
|
||||
initial_fields_json = list(applicant_form.fields.values(
|
||||
'label', 'field_type', 'required', 'help_text', 'choices', 'order', 'field_name'
|
||||
))
|
||||
|
||||
return render(request, 'applicant/edit_form.html', {
|
||||
'applicant_form': applicant_form,
|
||||
'job': job,
|
||||
'form_details': form_details,
|
||||
'initial_fields_json': json.dumps(initial_fields_json)
|
||||
})
|
||||
|
||||
def delete_field(request, field_id):
|
||||
"""Delete a form field"""
|
||||
field = get_object_or_404(FormField, id=field_id)
|
||||
form_id = field.form.id
|
||||
field.delete()
|
||||
messages.success(request, 'Field deleted successfully!')
|
||||
return redirect('applicant:edit_form', form_id=form_id)
|
||||
|
||||
def activate_form(request, form_id):
|
||||
"""Activate a form (deactivates others automatically)"""
|
||||
applicant_form = get_object_or_404(ApplicantForm, id=form_id)
|
||||
applicant_form.activate()
|
||||
messages.success(request, f'Form "{applicant_form.name}" is now active!')
|
||||
return redirect('applicant:job_forms_list', job_id=applicant_form.job_posting.internal_job_id)
|
||||
|
||||
# === PUBLIC VIEWS (for applicants) ===
|
||||
|
||||
def apply_form_view(request, job_id):
|
||||
"""Public application form - serves active form"""
|
||||
job = get_object_or_404(JobPosting, internal_job_id=job_id, status='ACTIVE')
|
||||
|
||||
if job.is_expired():
|
||||
raise Http404("Application deadline has passed")
|
||||
|
||||
try:
|
||||
applicant_form = job.applicant_forms.get(is_active=True)
|
||||
except ApplicantForm.DoesNotExist:
|
||||
raise Http404("No active application form configured for this job")
|
||||
|
||||
DynamicForm = create_dynamic_form(applicant_form)
|
||||
|
||||
if request.method == 'POST':
|
||||
form = DynamicForm(request.POST)
|
||||
if form.is_valid():
|
||||
ApplicantSubmission.objects.create(
|
||||
job_posting=job,
|
||||
form=applicant_form,
|
||||
data=form.cleaned_data,
|
||||
ip_address=request.META.get('REMOTE_ADDR')
|
||||
)
|
||||
return redirect('applicant:thank_you', job_id=job_id)
|
||||
else:
|
||||
form = DynamicForm()
|
||||
|
||||
return render(request, 'applicant/apply_form.html', {
|
||||
'form': form,
|
||||
'job': job,
|
||||
'applicant_form': applicant_form
|
||||
})
|
||||
|
||||
def review_job_detail(request,job_id):
|
||||
"""Public job detail view for applicants"""
|
||||
job = get_object_or_404(JobPosting, internal_job_id=job_id, status='ACTIVE')
|
||||
if job.is_expired():
|
||||
raise Http404("This job posting has expired.")
|
||||
return render(request,'applicant/review_job_detail.html',{'job':job})
|
||||
|
||||
|
||||
|
||||
|
||||
def thank_you_view(request, job_id):
|
||||
job = get_object_or_404(JobPosting, internal_job_id=job_id)
|
||||
return render(request, 'applicant/thank_you.html', {'job': job})
|
||||
@ -119,10 +119,10 @@ def create_zoom_meeting(topic, start_time, duration, host_email):
|
||||
|
||||
# Step 11: Analytics Dashboard (recruitment/dashboard.py)
|
||||
import pandas as pd
|
||||
from .models import Candidate
|
||||
from .models import Application
|
||||
|
||||
def get_dashboard_data():
|
||||
df = pd.DataFrame(list(Candidate.objects.all().values('status', 'created_at')))
|
||||
df = pd.DataFrame(list( Application.objects.all().values('status', 'created_at')))
|
||||
summary = df['status'].value_counts().to_dict()
|
||||
return summary
|
||||
|
||||
|
||||
@ -10,26 +10,26 @@
|
||||
<div class="d-flex vh-80 w-100 justify-content-center align-items-center mt-5">
|
||||
|
||||
<div class="form-card">
|
||||
|
||||
|
||||
<h2 id="form-title" class="h3 fw-bold mb-4 text-center">
|
||||
{% trans "Change Password" %}
|
||||
</h2>
|
||||
|
||||
|
||||
<p class="text-muted small mb-4 text-center">
|
||||
{% trans "Please enter your current password and a new password to secure your account." %}
|
||||
</p>
|
||||
|
||||
<form method="post" action="{% url 'account_change_password' %}" class="space-y-4">
|
||||
{% csrf_token %}
|
||||
|
||||
{{ form|crispy }}
|
||||
|
||||
<form method="post" action="{% url 'account_change_password' %}" class="space-y-4 account-password-change">
|
||||
{% csrf_token %}
|
||||
|
||||
{{ form|crispy }}
|
||||
|
||||
{% if form.non_field_errors %}
|
||||
<div class="alert alert-danger p-3 small mt-3" role="alert">
|
||||
{% for error in form.non_field_errors %}{{ error }}{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
<button type="submit" class="btn btn-danger w-100 mt-3">
|
||||
{% trans "Change Password" %}
|
||||
</button>
|
||||
|
||||
@ -143,12 +143,12 @@
|
||||
{% trans "Type" %}:
|
||||
{# Map the key back to its human-readable translation #}
|
||||
<strong class="mx-1">
|
||||
{% if selected_job_type == 'FULL_TIME' %}{% trans "Full-time" %}
|
||||
{% elif selected_job_type == 'PART_TIME' %}{% trans "Part-time" %}
|
||||
{% elif selected_job_type == 'CONTRACT' %}{% trans "Contract" %}
|
||||
{% elif selected_job_type == 'INTERNSHIP' %}{% trans "Internship" %}
|
||||
{% elif selected_job_type == 'FACULTY' %}{% trans "Faculty" %}
|
||||
{% elif selected_job_type == 'TEMPORARY' %}{% trans "Temporary" %}
|
||||
{% if selected_job_type == 'Full-time' %}{% trans "Full-time" %}
|
||||
{% elif selected_job_type == 'Part-time' %}{% trans "Part-time" %}
|
||||
{% elif selected_job_type == 'Contract' %}{% trans "Contract" %}
|
||||
{% elif selected_job_type == 'Internship' %}{% trans "Internship" %}
|
||||
{% elif selected_job_type == 'Faculty' %}{% trans "Faculty" %}
|
||||
{% elif selected_job_type == 'Temporary' %}{% trans "Temporary" %}
|
||||
{% endif %}
|
||||
</strong>
|
||||
{# Link to clear this specific filter: use current URL but remove `employment_type` parameter #}
|
||||
@ -159,15 +159,15 @@
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{# --- Active Workplace Type Filter Chip --- #}
|
||||
{# --- Active Workplace Type Filter Chip --- #}
|
||||
{% if selected_workplace_type %}
|
||||
<span class="filter-chip badge bg-primary-theme-subtle text-primary-theme fw-normal p-2 active-filter-chip">
|
||||
{% trans "Workplace" %}:
|
||||
{# Map the key back to its human-readable translation #}
|
||||
<strong class="mx-1">
|
||||
{% if selected_workplace_type == 'ON_SITE' %}{% trans "On-site" %}
|
||||
{% elif selected_workplace_type == 'REMOTE' %}{% trans "Remote" %}
|
||||
{% elif selected_workplace_type == 'HYBRID' %}{% trans "Hybrid" %}
|
||||
{% if selected_workplace_type == 'On-site' %}{% trans "On-site" %}
|
||||
{% elif selected_workplace_type == 'Remote' %}{% trans "Remote" %}
|
||||
{% elif selected_workplace_type == 'Hybrid' %}{% trans "Hybrid" %}
|
||||
{% endif %}
|
||||
</strong>
|
||||
{# Link to clear this specific filter: use current URL but remove `workplace_type` parameter #}
|
||||
|
||||
@ -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
|
||||
@ -133,8 +138,8 @@
|
||||
data-bs-auto-close="outside"
|
||||
data-bs-offset="0, 16" {# Vertical offset remains 16px to prevent clipping #}
|
||||
>
|
||||
{% if user.profile and user.profile.profile_image %}
|
||||
<img src="{{ user.profile.profile_image.url }}" alt="{{ user.username }}" class="profile-avatar"
|
||||
{% if user.profile_image %}
|
||||
<img src="{{ user.profile_image.url }}" alt="{{ user.username }}" class="profile-avatar"
|
||||
style="width: 36px; height: 36px; object-fit: cover; background-color: var(--kaauh-teal); display: inline-block; vertical-align: middle;"
|
||||
title="{% trans 'Your account' %}">
|
||||
{% else %}
|
||||
@ -151,8 +156,8 @@
|
||||
<li class="px-4 py-3 ">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="me-3 d-flex align-items-center justify-content-center" style="min-width: 48px;">
|
||||
{% if user.profile and user.profile.profile_image %}
|
||||
<img src="{{ user.profile.profile_image.url }}" alt="{{ user.username }}" class="profile-avatar shadow-sm border"
|
||||
{% if user.profile_image %}
|
||||
<img src="{{ user.profile_image.url }}" alt="{{ user.username }}" class="profile-avatar shadow-sm border"
|
||||
style="width: 44px; height: 44px; object-fit: cover; background-color: var(--kaauh-teal); display: block;"
|
||||
title="{% trans 'Your account' %}">
|
||||
{% else %}
|
||||
@ -208,7 +213,7 @@
|
||||
<i class="fas fa-sign-out-alt me-3 fs-5 " style="color:red;"></i>
|
||||
<span style="color:red;">{% trans "Sign Out" %}</span>
|
||||
</button>
|
||||
</form>
|
||||
</form>
|
||||
{% comment %} <a class="d-inline text-decoration-none px-4 d-flex align-items-center border-0 bg-transparent text-start text-center" href={% url "account_logout" %}>
|
||||
<i class="fas fa-sign-out-alt me-3 fs-5 " style="color:red;"></i>
|
||||
<span style="color:red;">{% trans "Sign Out" %}</span>
|
||||
@ -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>
|
||||
@ -312,7 +325,7 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
</main>
|
||||
@ -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>
|
||||
|
||||
@ -1,10 +1,34 @@
|
||||
{% load i18n %}
|
||||
<div class="d-flex justify-content-center align-items-center gap-2" hx-swap='outerHTML' hx-target="#status-result-{{ candidate.pk }}"
|
||||
<form id="exam-update-form" hx-post="{% url 'update_candidate_status' job.slug candidate.slug 'exam' 'Failed' %}" hx-swap='outerHTML' hx-target="#status-result-{{ candidate.pk }}"
|
||||
hx-on::after-request="const modal = bootstrap.Modal.getInstance(document.getElementById('candidateviewModal')); if (modal) { modal.hide(); }">
|
||||
<a hx-post="{% url 'update_candidate_status' job.slug candidate.slug 'exam' 'Passed' %}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-check me-1"></i> {% trans "Passed" %}
|
||||
</a>
|
||||
<a hx-post="{% url 'update_candidate_status' job.slug candidate.slug 'exam' 'Failed' %}" class="btn btn-danger">
|
||||
<i class="fas fa-times me-1"></i> {% trans "Failed" %}
|
||||
</a>
|
||||
</div>
|
||||
<div class="d-flex justify-content-center align-items-center gap-2">
|
||||
<div class="form-check d-flex align-items-center gap-2">
|
||||
<input class="form-check-input" type="radio" name="exam_status" id="exam_passed" value="Passed" {% if candidate.exam_status == 'Passed' %}checked{% endif %}>
|
||||
<label class="form-check-label" for="exam_passed">
|
||||
<i class="fas fa-check me-1"></i> {% trans "Passed" %}
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check d-flex align-items-center gap-2">
|
||||
<input class="form-check-input" type="radio" name="exam_status" id="exam_failed" value="Failed" {% if candidate.exam_status == 'Failed' %}checked{% endif %}>
|
||||
<label class="form-check-label" for="exam_failed">
|
||||
<i class="fas fa-times me-1"></i> {% trans "Failed" %}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-center align-items-center mt-3 gap-2">
|
||||
<div class="w-25 text-end pe-none">
|
||||
<label for="exam_score" class="form-label small text-muted">{% trans "Exam Score" %}</label>
|
||||
</div>
|
||||
<div class="w-25">
|
||||
<input type="number" class="form-control form-control-sm" id="exam_score" name="exam_score" min="0" max="100" required value="{{ candidate.exam_score }}">
|
||||
</div>
|
||||
<div class="w-25 text-start ps-none">
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center mt-3">
|
||||
<button type="submit" class="btn btn-success btn-sm">
|
||||
<i class="fas fa-check me-1"></i> {% trans "Update" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
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 %}
|
||||
@ -11,6 +11,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{interviews}}
|
||||
<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;">
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
<h3 class="text-center font-weight-light my-4">Set Interview Location</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" action="{% url 'schedule_interview_location_form' schedule.slug %}" enctype="multipart/form-data">
|
||||
<form method="post" action="{% url 'confirm_schedule_interviews_view' job.slug %}" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
|
||||
{# Renders the single 'location' field using the crispy filter #}
|
||||
@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
{% load static crispy_forms_tags %}
|
||||
{%load i18n %}
|
||||
|
||||
{% block customCSS %}
|
||||
@ -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>
|
||||
@ -119,10 +119,10 @@
|
||||
{% if not forloop.last %}, {% endif %}
|
||||
{% endfor %}
|
||||
</p>
|
||||
<p class="mb-2"><strong><i class="fas fa-calendar-day me-2 text-primary-theme"></i> Interview Type:</strong> {{interview_type}}</p>
|
||||
<p class="mb-2"><strong><i class="fas fa-calendar-day me-2 text-primary-theme"></i> Interview Type:</strong> {{schedule_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,24 +162,58 @@
|
||||
{% 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.application.name }}</td>
|
||||
<td>{{ item.application.email }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{% url 'confirm_schedule_interviews_view' slug=job.slug %}" class="mt-4 d-flex justify-content-end gap-3">
|
||||
{% csrf_token %}
|
||||
<a href="{% url 'schedule_interviews' slug=job.slug %}" class="btn btn-secondary px-4">
|
||||
<i class="fas fa-arrow-left me-2"></i> {% trans "Back to Edit" %}
|
||||
</a>
|
||||
<button type="submit" name="confirm_schedule" class="btn btn-teal-primary px-4">
|
||||
{% if schedule_interview_type == "Onsite" %}
|
||||
<button type="submit" name="confirm_schedule" class="btn btn-teal-primary px-4" data-bs-toggle="modal" data-bs-target="#interviewDetailsModal" data-placement="top">
|
||||
<i class="fas fa-check me-2"></i> {% trans "Confirm Schedule" %}
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="post" action="{% url 'confirm_schedule_interviews_view' slug=job.slug %}" class="mt-4 d-flex justify-content-end gap-3">
|
||||
{% csrf_token %}
|
||||
<a href="{% url 'schedule_interviews' slug=job.slug %}" class="btn btn-secondary px-4">
|
||||
<i class="fas fa-arrow-left me-2"></i> {% trans "Back to Edit" %}
|
||||
</a>
|
||||
<button type="submit" name="confirm_schedule" class="btn btn-teal-primary px-4">
|
||||
<i class="fas fa-check me-2"></i> {% trans "Confirm Schedule" %}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="interviewDetailsModal" tabindex="-1" aria-labelledby="interviewDetailsModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="interviewDetailsModalLabel">{% trans "Interview Details" %}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body"> <form method="post" action="{% url 'confirm_schedule_interviews_view' job.slug %}" enctype="multipart/form-data" id="onsite-form">
|
||||
{% csrf_token %}
|
||||
|
||||
{# Renders the single 'location' field using the crispy filter #}
|
||||
{{ form|crispy }}
|
||||
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<div class="d-flex align-items-center justify-content-between mt-4 mb-0">
|
||||
<a href="{% url 'list_meetings' %}" class="btn btn-secondary me-2">
|
||||
<i class="fas fa-times me-1"></i> Close
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary" form="onsite-form">
|
||||
<i class="fas fa-save me-1"></i> Save Location
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -200,13 +234,13 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
events: [
|
||||
{% for item in schedule %}
|
||||
{
|
||||
title: '{{ item.candidate.name }}',
|
||||
title: '{{ item.application.name }}',
|
||||
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 }}',
|
||||
email: '{{ item.application.email }}',
|
||||
time: '{{ item.time|time:"g:i A" }}'
|
||||
}
|
||||
},
|
||||
@ -214,7 +248,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>
|
||||
@ -142,8 +142,8 @@
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="form-group mb-3">
|
||||
<label for="{{ form.start_date.id_for_label }}">{% trans "Interview Type" %}</label>
|
||||
{{ form.interview_type }}
|
||||
<label for="{{ form.schedule_interview_type.id_for_label }}">{% trans "Interview Type" %}</label>
|
||||
{{ form.schedule_interview_type }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
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