Compare commits

...

19 Commits

Author SHA1 Message Date
f1499f7be0 littele ui fix 2025-11-18 13:13:16 +03:00
64e04a011d scheduled interview 2025-11-17 13:49:50 +03:00
d0235bfefe changes 2025-11-17 12:48:32 +03:00
a28bfc11f3 changes from ismail 2025-11-17 12:20:56 +03:00
06436a3b9e changes to interview 2025-11-17 09:33:47 +03:00
1babb1be63 person,agency dashborads custom sign up for candidate and alot more.. 2025-11-16 16:42:43 +03:00
0213bd6e11 ... 2025-11-13 16:28:57 +03:00
5285335498 merge main with person update 2025-11-13 16:14:59 +03:00
9497bf102e Merge branch 'update1' 2025-11-13 15:56:59 +03:00
cbace0274a ui consitant 2025-11-13 15:31:45 +03:00
e4b6a359ea update 2025-11-13 14:46:58 +03:00
552c6e4d64 person update1 2025-11-13 14:16:12 +03:00
870988424b Merge pull request 'frontend' (#31) from frontend into main
Reviewed-on: #31
2025-11-13 14:15:49 +03:00
da555c1460 person update 2025-11-13 14:05:59 +03:00
eb79173e26 pre person model change 2025-11-10 16:21:29 +03:00
caa7ed88aa add login to candidate and agency 2025-11-05 18:27:43 +03:00
9830b1173f s 2025-11-04 16:01:07 +03:00
08ecea8934 merge 2025-11-03 13:00:12 +03:00
15f8cb2650 Merge pull request 'frontend' (#28) from frontend into main
Reviewed-on: #28
2025-11-03 12:58:32 +03:00
169 changed files with 34931 additions and 10762 deletions

3
.env Normal file
View File

@ -0,0 +1,3 @@
DB_NAME=haikal_db
DB_USER=faheed
DB_PASSWORD=Faheed@215

6
.gitignore vendored
View File

@ -110,4 +110,8 @@ settings.py
# If a rule in .gitignore ends with a directory separator (i.e. `/` # If a rule in .gitignore ends with a directory separator (i.e. `/`
# character), then remove the file in the remaining pattern string and all # character), then remove the file in the remaining pattern string and all
# files with the same name in subdirectories. # files with the same name in subdirectories.
db.sqlite3 db.sqlite3
.opencode
openspec
AGENTS.md

View File

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

View File

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

View File

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

113
debug_test.py Normal file
View 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()

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -3,12 +3,14 @@ from django.utils.html import format_html
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from .models import ( from .models import (
JobPosting, Candidate, TrainingMaterial, ZoomMeeting, JobPosting, Application, TrainingMaterial, ZoomMeetingDetails,
FormTemplate, FormStage, FormField, FormSubmission, FieldResponse, FormTemplate, FormStage, FormField, FormSubmission, FieldResponse,
SharedFormTemplate, Source, HiringAgency, IntegrationLog,InterviewSchedule,Profile,JobPostingImage,MeetingComment, SharedFormTemplate, Source, HiringAgency, IntegrationLog,InterviewSchedule,JobPostingImage,InterviewNote,
AgencyAccessLink, AgencyJobAssignment AgencyAccessLink, AgencyJobAssignment
) )
from django.contrib.auth import get_user_model
User = get_user_model()
class FormFieldInline(admin.TabularInline): class FormFieldInline(admin.TabularInline):
model = FormField model = FormField
extra = 1 extra = 1
@ -82,17 +84,10 @@ class HiringAgencyAdmin(admin.ModelAdmin):
readonly_fields = ['slug', 'created_at', 'updated_at'] readonly_fields = ['slug', 'created_at', 'updated_at']
fieldsets = ( fieldsets = (
('Basic Information', { ('Basic Information', {
'fields': ('name', 'slug', 'contact_person', 'email', 'phone', 'website') 'fields': ('name','contact_person', 'email', 'phone', 'website','user')
}),
('Location Details', {
'fields': ('country', 'city', 'address')
}),
('Additional Information', {
'fields': ('description', 'created_at', 'updated_at')
}), }),
) )
save_on_top = True save_on_top = True
prepopulated_fields = {'slug': ('name',)}
@admin.register(JobPosting) @admin.register(JobPosting)
@ -143,43 +138,6 @@ class JobPostingAdmin(admin.ModelAdmin):
mark_as_closed.short_description = 'Mark selected jobs as closed' 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) @admin.register(TrainingMaterial)
class TrainingMaterialAdmin(admin.ModelAdmin): class TrainingMaterialAdmin(admin.ModelAdmin):
list_display = ['title', 'created_by', 'created_at'] list_display = ['title', 'created_by', 'created_at']
@ -200,7 +158,7 @@ class TrainingMaterialAdmin(admin.ModelAdmin):
save_on_top = True save_on_top = True
@admin.register(ZoomMeeting) @admin.register(ZoomMeetingDetails)
class ZoomMeetingAdmin(admin.ModelAdmin): class ZoomMeetingAdmin(admin.ModelAdmin):
list_display = ['topic', 'meeting_id', 'start_time', 'duration', 'created_at'] list_display = ['topic', 'meeting_id', 'start_time', 'duration', 'created_at']
list_filter = ['timezone', 'created_at'] list_filter = ['timezone', 'created_at']
@ -223,24 +181,24 @@ class ZoomMeetingAdmin(admin.ModelAdmin):
save_on_top = True save_on_top = True
@admin.register(MeetingComment) # @admin.register(InterviewNote)
class MeetingCommentAdmin(admin.ModelAdmin): # class MeetingCommentAdmin(admin.ModelAdmin):
list_display = ['meeting', 'author', 'created_at', 'updated_at'] # list_display = ['meeting', 'author', 'created_at', 'updated_at']
list_filter = ['created_at', 'author', 'meeting'] # list_filter = ['created_at', 'author', 'meeting']
search_fields = ['content', 'meeting__topic', 'author__username'] # search_fields = ['content', 'meeting__topic', 'author__username']
readonly_fields = ['created_at', 'updated_at', 'slug'] # readonly_fields = ['created_at', 'updated_at', 'slug']
fieldsets = ( # fieldsets = (
('Meeting Information', { # ('Meeting Information', {
'fields': ('meeting', 'author') # 'fields': ('meeting', 'author')
}), # }),
('Comment Content', { # ('Comment Content', {
'fields': ('content',) # 'fields': ('content',)
}), # }),
('Timestamps', { # ('Timestamps', {
'fields': ('created_at', 'updated_at', 'slug') # 'fields': ('created_at', 'updated_at', 'slug')
}), # }),
) # )
save_on_top = True # save_on_top = True
@admin.register(FormTemplate) @admin.register(FormTemplate)
@ -280,13 +238,14 @@ class FormSubmissionAdmin(admin.ModelAdmin):
# Register other models # Register other models
admin.site.register(FormStage) admin.site.register(FormStage)
admin.site.register(Application)
admin.site.register(FormField) admin.site.register(FormField)
admin.site.register(FieldResponse) admin.site.register(FieldResponse)
admin.site.register(InterviewSchedule) admin.site.register(InterviewSchedule)
admin.site.register(Profile)
admin.site.register(AgencyAccessLink) admin.site.register(AgencyAccessLink)
admin.site.register(AgencyJobAssignment) admin.site.register(AgencyJobAssignment)
# AgencyMessage admin removed - model has been deleted # AgencyMessage admin removed - model has been deleted
admin.site.register(JobPostingImage) admin.site.register(JobPostingImage)
admin.site.register(User)

36
recruitment/backends.py Normal file
View 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)

View File

@ -1,17 +1,164 @@
from functools import wraps from functools import wraps
from datetime import date from datetime import date
from django.shortcuts import redirect, get_object_or_404 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): def job_not_expired(view_func):
@wraps(view_func) @wraps(view_func)
def _wrapped_view(request, job_id, *args, **kwargs): def _wrapped_view(request, job_id, *args, **kwargs):
from .models import JobPosting from .models import JobPosting
job = get_object_or_404(JobPosting, pk=job_id) job = get_object_or_404(JobPosting, pk=job_id)
if job.expiration_date and job.application_deadline< date.today(): if job.expiration_date and job.application_deadline< date.today():
return redirect('expired_job_page') return redirect('expired_job_page')
return view_func(request, job_id, *args, **kwargs) 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)

View File

@ -224,11 +224,8 @@ def send_interview_invitation_email(candidate, job, meeting_details=None, recipi
logger.error(error_msg, exc_info=True) logger.error(error_msg, exc_info=True)
return {'success': False, 'error': error_msg} 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 from django.shortcuts import get_object_or_404
import logging import logging
from django.conf import settings from django.conf import settings
@ -262,15 +259,16 @@ def send_bulk_email(subject, message, recipient_list, request=None, attachments=
email = email.strip().lower() email = email.strip().lower()
try: try:
candidate = get_object_or_404(Candidate, email=email) candidate = get_object_or_404(Application, person__email=email)
except Exception: except Exception:
logger.warning(f"Candidate not found for email: {email}") logger.warning(f"Candidate not found for email: {email}")
continue continue
candidate_name = candidate.first_name candidate_name = candidate.person.full_name
# --- Candidate belongs to an agency (Final Recipient: Agency) --- # --- 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_email = candidate.hiring_agency.email
agency_message = f"Hi, {candidate_name}" + "\n" + message 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: if not from_interview:
# Send Emails - Pure Candidates # Send Emails - Pure Candidates
for email in pure_candidate_emails: 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 candidate_message = f"Hi, {candidate_name}" + "\n" + message
send_individual_email(email, candidate_message) send_individual_email(email, candidate_message)
@ -403,7 +401,7 @@ def send_bulk_email(subject, message, recipient_list, request=None, attachments=
i = 0 i = 0
for email in agency_emails: for email in agency_emails:
candidate_email = candidate_through_agency_emails[i] 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 agency_message = f"Hi, {candidate_name}" + "\n" + message
send_individual_email(email, agency_message) send_individual_email(email, agency_message)
i += 1 i += 1

File diff suppressed because it is too large Load Diff

View File

@ -1,11 +1,11 @@
from .models import Candidate from .models import Application
from time import sleep from time import sleep
def callback_ai_parsing(task): def callback_ai_parsing(task):
if task.success: if task.success:
try: try:
pk = task.args[0] 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: if c.retry and not c.is_resume_parsed:
sleep(30) sleep(30)
c.retry -= 1 c.retry -= 1

View File

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

View File

@ -1,7 +1,10 @@
# Generated by Django 5.2.7 on 2025-11-05 13:05 # Generated by Django 5.2.7 on 2025-11-17 09:52
import django.contrib.auth.models
import django.contrib.auth.validators
import django.core.validators import django.core.validators
import django.db.models.deletion import django.db.models.deletion
import django.utils.timezone
import django_ckeditor_5.fields import django_ckeditor_5.fields
import django_countries.fields import django_countries.fields
import django_extensions.db.fields import django_extensions.db.fields
@ -15,7 +18,8 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL), ('auth', '0012_alter_user_first_name_max_length'),
('contenttypes', '0002_remove_content_type_name'),
] ]
operations = [ operations = [
@ -45,25 +49,20 @@ class Migration(migrations.Migration):
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='HiringAgency', name='InterviewLocation',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('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')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated 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')), ('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')), ('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')),
('contact_person', models.CharField(blank=True, max_length=150, verbose_name='Contact Person')), ('details_url', models.URLField(blank=True, max_length=2048, null=True, verbose_name='Meeting/Location URL')),
('email', models.EmailField(blank=True, max_length=254)), ('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')),
('phone', models.CharField(blank=True, max_length=20)), ('timezone', models.CharField(default='UTC', max_length=50, verbose_name='Timezone')),
('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)),
], ],
options={ options={
'verbose_name': 'Hiring Agency', 'verbose_name': 'Interview Location',
'verbose_name_plural': 'Hiring Agencies', 'verbose_name_plural': 'Interview Locations',
'ordering': ['name'],
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
@ -113,29 +112,33 @@ class Migration(migrations.Migration):
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='ZoomMeeting', name='CustomUser',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('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')), ('password', models.CharField(max_length=128, verbose_name='password')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('topic', models.CharField(max_length=255, verbose_name='Topic')), ('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')),
('meeting_id', models.CharField(db_index=True, max_length=20, unique=True, verbose_name='Meeting ID')), ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
('start_time', models.DateTimeField(db_index=True, verbose_name='Start Time')), ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('duration', models.PositiveIntegerField(verbose_name='Duration')), ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
('timezone', models.CharField(max_length=50, verbose_name='Timezone')), ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('join_url', models.URLField(verbose_name='Join URL')), ('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')),
('participant_video', models.BooleanField(default=True, verbose_name='Participant Video')), ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('password', models.CharField(blank=True, max_length=20, null=True, verbose_name='Password')), ('user_type', models.CharField(choices=[('staff', 'Staff'), ('agency', 'Agency'), ('candidate', 'Candidate')], default='staff', max_length=20, verbose_name='User Type')),
('join_before_host', models.BooleanField(default=False, verbose_name='Join Before Host')), ('phone', models.CharField(blank=True, max_length=20, null=True, verbose_name='Phone')),
('mute_upon_entry', models.BooleanField(default=False, verbose_name='Mute Upon Entry')), ('profile_image', models.ImageField(blank=True, null=True, upload_to='profile_pic/', validators=[recruitment.validators.validate_image_size], verbose_name='Profile Image')),
('waiting_room', models.BooleanField(default=False, verbose_name='Waiting Room')), ('designation', models.CharField(blank=True, max_length=100, null=True, verbose_name='Designation')),
('zoom_gateway_response', models.JSONField(blank=True, null=True, verbose_name='Zoom Gateway Response')), ('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')),
('status', models.CharField(blank=True, db_index=True, default='waiting', max_length=20, null=True, verbose_name='Status')), ('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={ options={
'abstract': False, 'verbose_name': 'User',
'verbose_name_plural': 'Users',
}, },
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
), ),
migrations.CreateModel( migrations.CreateModel(
name='FormField', 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'), field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stages', to='recruitment.formtemplate'),
), ),
migrations.CreateModel( migrations.CreateModel(
name='Candidate', name='HiringAgency',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('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')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated 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')), ('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')), ('name', models.CharField(max_length=200, unique=True, verbose_name='Agency Name')),
('last_name', models.CharField(max_length=255, verbose_name='Last Name')), ('contact_person', models.CharField(blank=True, max_length=150, verbose_name='Contact Person')),
('email', models.EmailField(db_index=True, max_length=254, verbose_name='Email')), ('email', models.EmailField(blank=True, max_length=254)),
('phone', models.CharField(max_length=20, verbose_name='Phone')), ('phone', models.CharField(blank=True, max_length=20)),
('address', models.TextField(max_length=200, verbose_name='Address')), ('website', models.URLField(blank=True)),
('resume', models.FileField(upload_to='resumes/', verbose_name='Resume')), ('notes', models.TextField(blank=True, help_text='Internal notes about the agency')),
('is_resume_parsed', models.BooleanField(default=False, verbose_name='Resume Parsed')), ('country', django_countries.fields.CountryField(blank=True, max_length=2, null=True)),
('is_potential_candidate', models.BooleanField(default=False, verbose_name='Potential Candidate')), ('address', models.TextField(blank=True, null=True)),
('parsed_summary', models.TextField(blank=True, verbose_name='Parsed Summary')), ('generated_password', models.CharField(blank=True, help_text='Generated password for agency user account', max_length=255, null=True)),
('applied', models.BooleanField(default=False, verbose_name='Applied')), ('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')),
('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')),
], ],
options={ options={
'verbose_name': 'Candidate', 'verbose_name': 'Hiring Agency',
'verbose_name_plural': 'Candidates', '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( migrations.CreateModel(
name='JobPosting', name='JobPosting',
fields=[ 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')), ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('title', models.CharField(max_length=200)), ('title', models.CharField(max_length=200)),
('department', models.CharField(blank=True, max_length=100)), ('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)), ('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)), ('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_city', models.CharField(blank=True, max_length=100)),
('location_state', 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)), ('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')), ('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_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)), ('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')), ('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')), ('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={ options={
@ -299,6 +360,7 @@ class Migration(migrations.Migration):
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated 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')), ('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')), ('start_date', models.DateField(db_index=True, verbose_name='Start Date')),
('end_date', models.DateField(db_index=True, verbose_name='End Date')), ('end_date', models.DateField(db_index=True, verbose_name='End Date')),
('working_days', models.JSONField(verbose_name='Working Days')), ('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')), ('break_end_time', models.TimeField(blank=True, null=True, verbose_name='Break End Time')),
('interview_duration', models.PositiveIntegerField(verbose_name='Interview Duration (minutes)')), ('interview_duration', models.PositiveIntegerField(verbose_name='Interview Duration (minutes)')),
('buffer_time', models.PositiveIntegerField(default=0, verbose_name='Buffer Time (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)), ('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')), ('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interview_schedules', to='recruitment.jobposting')),
], ],
options={
'abstract': False,
},
), ),
migrations.AddField( migrations.AddField(
model_name='formtemplate', 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'), field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='form_template', to='recruitment.jobposting'),
), ),
migrations.AddField( migrations.AddField(
model_name='candidate', model_name='application',
name='job', 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( migrations.CreateModel(
name='AgencyJobAssignment', name='AgencyJobAssignment',
@ -357,14 +423,114 @@ class Migration(migrations.Migration):
], ],
), ),
migrations.CreateModel( migrations.CreateModel(
name='Profile', name='Message',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('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])), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('designation', models.CharField(blank=True, max_length=100, null=True)), ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('phone', models.CharField(blank=True, max_length=12, null=True, verbose_name='Phone Number')), ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)), ('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( migrations.CreateModel(
name='SharedFormTemplate', name='SharedFormTemplate',
@ -425,60 +591,6 @@ class Migration(migrations.Migration):
'verbose_name_plural': 'Training Materials', '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( migrations.CreateModel(
name='AgencyAccessLink', name='AgencyAccessLink',
fields=[ 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')], '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( migrations.CreateModel(
name='FieldResponse', name='FieldResponse',
fields=[ fields=[
@ -523,17 +656,10 @@ class Migration(migrations.Migration):
model_name='formsubmission', model_name='formsubmission',
index=models.Index(fields=['submitted_at'], name='recruitment_submitt_7946c8_idx'), index=models.Index(fields=['submitted_at'], name='recruitment_submitt_7946c8_idx'),
), ),
migrations.AddIndex( migrations.AddField(
model_name='interviewschedule', model_name='notification',
index=models.Index(fields=['start_date'], name='recruitment_start_d_15d55e_idx'), 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='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.AddIndex( migrations.AddIndex(
model_name='formtemplate', model_name='formtemplate',
@ -543,14 +669,6 @@ class Migration(migrations.Migration):
model_name='formtemplate', model_name='formtemplate',
index=models.Index(fields=['is_active'], name='recruitment_is_acti_ae5efb_idx'), 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( migrations.AddIndex(
model_name='agencyjobassignment', model_name='agencyjobassignment',
index=models.Index(fields=['agency', 'status'], name='recruitment_agency__491a54_idx'), index=models.Index(fields=['agency', 'status'], name='recruitment_agency__491a54_idx'),
@ -572,12 +690,52 @@ class Migration(migrations.Migration):
unique_together={('agency', 'job')}, unique_together={('agency', 'job')},
), ),
migrations.AddIndex( migrations.AddIndex(
model_name='jobposting', model_name='message',
index=models.Index(fields=['status', 'created_at', 'title'], name='recruitment_status_8b77aa_idx'), index=models.Index(fields=['sender', 'created_at'], name='recruitment_sender__49d984_idx'),
), ),
migrations.AddIndex( migrations.AddIndex(
model_name='jobposting', model_name='message',
index=models.Index(fields=['slug'], name='recruitment_slug_004045_idx'), 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( migrations.AddIndex(
model_name='scheduledinterview', model_name='scheduledinterview',
@ -589,7 +747,15 @@ class Migration(migrations.Migration):
), ),
migrations.AddIndex( migrations.AddIndex(
model_name='scheduledinterview', 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( migrations.AddIndex(
model_name='notification', model_name='notification',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -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))

View File

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

View File

@ -1,44 +1,62 @@
import logging import logging
import random
from datetime import datetime, timedelta from datetime import datetime, timedelta
from django.db import transaction from django.db import transaction
from django_q.models import Schedule from django_q.models import Schedule
from django_q.tasks import schedule from django_q.tasks import schedule
from django.dispatch import receiver from django.dispatch import receiver
from django_q.tasks import async_task from django_q.tasks import async_task
from django.db.models.signals import post_save from django.db.models.signals import post_save
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.utils import timezone 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__) logger = logging.getLogger(__name__)
User = get_user_model()
@receiver(post_save, sender=JobPosting) @receiver(post_save, sender=JobPosting)
def format_job(sender, instance, created, **kwargs): def format_job(sender, instance, created, **kwargs):
if created: if created or not instance.ai_parsed:
FormTemplate.objects.create(job=instance, is_active=False, name=instance.title) try:
form_template = instance.form_template
except FormTemplate.DoesNotExist:
FormTemplate.objects.get_or_create(
job=instance, is_active=False, name=instance.title
)
async_task( async_task(
'recruitment.tasks.format_job_description', "recruitment.tasks.format_job_description",
instance.pk, instance.pk,
# hook='myapp.tasks.email_sent_callback' # Optional callback # hook='myapp.tasks.email_sent_callback' # Optional callback
) )
else: else:
existing_schedule = Schedule.objects.filter( existing_schedule = Schedule.objects.filter(
func='recruitment.tasks.form_close', func="recruitment.tasks.form_close",
args=f'[{instance.pk}]', args=f"[{instance.pk}]",
schedule_type=Schedule.ONCE schedule_type=Schedule.ONCE,
).first() ).first()
if instance.STATUS_CHOICES=='ACTIVE' and instance.application_deadline: if instance.STATUS_CHOICES == "ACTIVE" and instance.application_deadline:
if not existing_schedule: if not existing_schedule:
# Create a new schedule if one does not exist # Create a new schedule if one does not exist
schedule( schedule(
'recruitment.tasks.form_close', "recruitment.tasks.form_close",
instance.pk, instance.pk,
schedule_type=Schedule.ONCE, schedule_type=Schedule.ONCE,
next_run=instance.application_deadline, next_run=instance.application_deadline,
repeats=-1, # Ensure the schedule is deleted after it runs repeats=-1, # Ensure the schedule is deleted after it runs
name=f'job_closing_{instance.pk}' # Add a name for easier lookup name=f"job_closing_{instance.pk}", # Add a name for easier lookup
) )
elif existing_schedule.next_run != instance.application_deadline: elif existing_schedule.next_run != instance.application_deadline:
# Update an existing schedule's run time # 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 # If the instance is no longer active, delete the scheduled task
existing_schedule.delete() existing_schedule.delete()
# @receiver(post_save, sender=JobPosting) # @receiver(post_save, sender=JobPosting)
# def update_form_template_status(sender, instance, created, **kwargs): # def update_form_template_status(sender, instance, created, **kwargs):
# if not created: # if not created:
@ -57,16 +76,18 @@ def format_job(sender, instance, created, **kwargs):
# instance.form_template.is_active = False # instance.form_template.is_active = False
# instance.save() # instance.save()
@receiver(post_save, sender=Candidate)
@receiver(post_save, sender=Application)
def score_candidate_resume(sender, instance, created, **kwargs): 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}") logger.info(f"Scoring resume for candidate {instance.pk}")
async_task( async_task(
'recruitment.tasks.handle_reume_parsing_and_scoring', "recruitment.tasks.handle_reume_parsing_and_scoring",
instance.pk, instance.pk,
hook='recruitment.hooks.callback_ai_parsing' hook="recruitment.hooks.callback_ai_parsing",
) )
@receiver(post_save, sender=FormTemplate) @receiver(post_save, sender=FormTemplate)
def create_default_stages(sender, instance, created, **kwargs): def create_default_stages(sender, instance, created, **kwargs):
""" """
@ -77,67 +98,75 @@ def create_default_stages(sender, instance, created, **kwargs):
# Stage 1: Contact Information # Stage 1: Contact Information
contact_stage = FormStage.objects.create( contact_stage = FormStage.objects.create(
template=instance, template=instance,
name='Contact Information', name="Contact Information",
order=0, 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( FormField.objects.create(
stage=contact_stage, stage=contact_stage,
label='First Name', label="GPA",
field_type='text', field_type="text",
required=True, required=False,
order=0,
is_predefined=True
)
FormField.objects.create(
stage=contact_stage,
label='Last Name',
field_type='text',
required=True,
order=1, order=1,
is_predefined=True is_predefined=True,
) )
FormField.objects.create( FormField.objects.create(
stage=contact_stage, stage=contact_stage,
label='Email Address', label="Resume Upload",
field_type='email', field_type="file",
required=True, required=True,
order=2, 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, is_predefined=True,
file_types='.pdf,.doc,.docx', file_types=".pdf,.doc,.docx",
max_file_size=1 max_file_size=1,
) )
# # Stage 2: Resume Objective # # 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 for real-time updates
SSE_NOTIFICATION_CACHE = {} SSE_NOTIFICATION_CACHE = {}
@receiver(post_save, sender=Notification) @receiver(post_save, sender=Notification)
def notification_created(sender, instance, created, **kwargs): def notification_created(sender, instance, created, **kwargs):
"""Signal handler for when a notification is created""" """Signal handler for when a notification is created"""
if 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 # Store notification in cache for SSE
user_id = instance.recipient.id user_id = instance.recipient.id
@ -383,12 +415,13 @@ def notification_created(sender, instance, created, **kwargs):
SSE_NOTIFICATION_CACHE[user_id] = [] SSE_NOTIFICATION_CACHE[user_id] = []
notification_data = { notification_data = {
'id': instance.id, "id": instance.id,
'message': instance.message[:100] + ('...' if len(instance.message) > 100 else ''), "message": instance.message[:100]
'type': instance.get_notification_type_display(), + ("..." if len(instance.message) > 100 else ""),
'status': instance.get_status_display(), "type": instance.get_notification_type_display(),
'time_ago': 'Just now', "status": instance.get_status_display(),
'url': f"/notifications/{instance.id}/" "time_ago": "Just now",
"url": f"/notifications/{instance.id}/",
} }
SSE_NOTIFICATION_CACHE[user_id].append(notification_data) 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}") 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: if created:
link=AgencyAccessLink(assignment=instance) logger.info(f"New hiring agency created: {instance.pk} - {instance.name}")
link.access_password = link.generate_password() password = generate_random_password()
link.unique_token = link.generate_token() user = User.objects.create_user(
link.expires_at = datetime.now() + timedelta(days=4) username=instance.name, email=instance.email, user_type="agency"
link.save() )
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()

View File

@ -7,12 +7,12 @@ from PyPDF2 import PdfReader
from datetime import datetime from datetime import datetime
from django.db import transaction from django.db import transaction
from .utils import create_zoom_meeting from .utils import create_zoom_meeting
from recruitment.models import Candidate from recruitment.models import Application
from . linkedin_service import LinkedInService from . linkedin_service import LinkedInService
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from . models import JobPosting from . models import JobPosting
from django.utils import timezone 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 # Add python-docx import for Word document processing
try: try:
@ -25,10 +25,10 @@ except ImportError:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
OPENROUTER_API_KEY ='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' OPENROUTER_API_KEY ='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'
OPENROUTER_MODEL = 'qwen/qwen-2.5-72b-instruct:free' # 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 = 'openai/gpt-oss-20b'
# OPENROUTER_MODEL = 'mistralai/mistral-small-3.2-24b-instruct:free' # 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.benefits=data.get('html_benefits')
job_posting.application_instructions=data.get('html_application_instruction') job_posting.application_instructions=data.get('html_application_instruction')
job_posting.linkedin_post_formated_data=data.get('linkedin_post_data') 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): def ai_handler(prompt):
@ -244,8 +245,8 @@ def handle_reume_parsing_and_scoring(pk):
# --- 1. Robust Object Retrieval (Prevents looping on DoesNotExist) --- # --- 1. Robust Object Retrieval (Prevents looping on DoesNotExist) ---
try: try:
instance = Candidate.objects.get(pk=pk) instance = Application.objects.get(pk=pk)
except Candidate.DoesNotExist: except Application.DoesNotExist:
# Exit gracefully if the candidate was deleted after the task was queued # 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.") 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.") 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}") print(f"Successfully scored and saved analysis for candidate {instance.id}")
from django.utils import timezone
def create_interview_and_meeting( def create_interview_and_meeting(
candidate_id, candidate_id,
job_id, job_id,
@ -453,43 +454,45 @@ def create_interview_and_meeting(
Synchronous task for a single interview slot, dispatched by django-q. Synchronous task for a single interview slot, dispatched by django-q.
""" """
try: try:
candidate = Candidate.objects.get(pk=candidate_id) candidate = Application.objects.get(pk=candidate_id)
job = JobPosting.objects.get(pk=job_id) job = JobPosting.objects.get(pk=job_id)
schedule = InterviewSchedule.objects.get(pk=schedule_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}" meeting_topic = f"Interview for {job.title} - {candidate.name}"
# 1. External API Call (Slow) # 1. External API Call (Slow)
result = create_zoom_meeting(meeting_topic, interview_datetime, duration) result = create_zoom_meeting(meeting_topic, interview_datetime, duration)
if result["status"] == "success": if result["status"] == "success":
# 2. Database Writes (Slow) # 2. Database Writes (Slow)
zoom_meeting = ZoomMeeting.objects.create( zoom_meeting = ZoomMeetingDetails.objects.create(
topic=meeting_topic, topic=meeting_topic,
start_time=interview_datetime, start_time=interview_datetime,
duration=duration, duration=duration,
meeting_id=result["meeting_details"]["meeting_id"], 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"], zoom_gateway_response=result["zoom_gateway_response"],
host_email=result["meeting_details"]["host_email"], host_email=result["meeting_details"]["host_email"],
password=result["meeting_details"]["password"] password=result["meeting_details"]["password"],
location_type="Remote"
) )
ScheduledInterview.objects.create( ScheduledInterview.objects.create(
candidate=candidate, application=candidate,
job=job, job=job,
zoom_meeting=zoom_meeting, interview_location=zoom_meeting,
schedule=schedule, schedule=schedule,
interview_date=slot_date, interview_date=slot_date,
interview_time=slot_time interview_time=slot_time
) )
# Log success or use Django-Q result system for monitoring # 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 return True # Task succeeded
else: else:
# Handle Zoom API failure (e.g., log it or notify administrator) # 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 return False # Task failed
except Exception as e: except Exception as e:
@ -517,7 +520,7 @@ def handle_zoom_webhook_event(payload):
try: try:
# Use filter().first() to avoid exceptions if the meeting doesn't exist yet, # Use filter().first() to avoid exceptions if the meeting doesn't exist yet,
# and to simplify the logic flow. # 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) print(meeting_instance)
# --- 1. Creation and Update Events --- # --- 1. Creation and Update Events ---
if event_type == 'meeting.updated': 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 dict: Sync result for this specific candidate-source pair
""" """
from .candidate_sync_service import CandidateSyncService 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}") logger.info(f"Starting sync task for candidate {candidate_id} to source {source_id}")
try: try:
# Get the candidate and source # 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) source = Source.objects.get(pk=source_id)
# Initialize sync service # Initialize sync service
sync_service = CandidateSyncService() sync_service = CandidateSyncService()
# Perform the sync operation # 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 # Log the operation
IntegrationLog.objects.create( IntegrationLog.objects.create(
@ -719,7 +722,7 @@ def sync_candidate_to_source_task(candidate_id, source_id):
action=IntegrationLog.ActionChoices.SYNC, action=IntegrationLog.ActionChoices.SYNC,
endpoint=source.sync_endpoint or "unknown", endpoint=source.sync_endpoint or "unknown",
method=source.sync_method or "POST", 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, response_data=result,
status_code="SUCCESS" if result.get('success') else "ERROR", status_code="SUCCESS" if result.get('success') else "ERROR",
error_message=result.get('error') if not result.get('success') else None, 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}") logger.info(f"Sync completed for candidate {candidate_id} to source {source_id}: {result}")
return result return result
except Candidate.DoesNotExist: except Application.DoesNotExist:
error_msg = f"Candidate not found: {candidate_id}" error_msg = f"Application not found: {candidate_id}"
logger.error(error_msg) logger.error(error_msg)
return {"success": False, "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): def _task_send_individual_email(subject, body_message, recipient, attachments):
"""Internal helper to create and send a single email.""" """Internal helper to create and send a single email."""
from_email = getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@kaauh.edu.sa') from_email = getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@kaauh.edu.sa')
is_html = '<' in body_message and '>' in body_message is_html = '<' in body_message and '>' in body_message
if is_html: if is_html:
plain_message = strip_tags(body_message) plain_message = strip_tags(body_message)
email_obj = EmailMultiAlternatives(subject=subject, body=plain_message, from_email=from_email, to=[recipient]) email_obj = EmailMultiAlternatives(subject=subject, body=plain_message, from_email=from_email, to=[recipient])
email_obj.attach_alternative(body_message, "text/html") email_obj.attach_alternative(body_message, "text/html")
else: else:
email_obj = EmailMultiAlternatives(subject=subject, body=body_message, from_email=from_email, to=[recipient]) email_obj = EmailMultiAlternatives(subject=subject, body=body_message, from_email=from_email, to=[recipient])
if attachments: if attachments:
for attachment in attachments: for attachment in attachments:
if isinstance(attachment, tuple) and len(attachment) == 3: if isinstance(attachment, tuple) and len(attachment) == 3:
filename, content, content_type = attachment filename, content, content_type = attachment
email_obj.attach(filename, content, content_type) email_obj.attach(filename, content, content_type)
try: try:
email_obj.send(fail_silently=False) email_obj.send(fail_silently=False)
return True 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. # The 'message' is the custom message specific to this recipient.
if _task_send_individual_email(subject, message, recipient, attachments): if _task_send_individual_email(subject, message, recipient, attachments):
successful_sends += 1 successful_sends += 1
if successful_sends > 0: if successful_sends > 0:
logger.info(f"Bulk email task completed successfully. Sent to {successful_sends}/{total_recipients} recipients.") logger.info(f"Bulk email task completed successfully. Sent to {successful_sends}/{total_recipients} recipients.")
return { return {
@ -817,4 +820,3 @@ def email_success_hook(task):
logger.info(f"Task ID {task.id} succeeded. Result: {task.result}") logger.info(f"Task ID {task.id} succeeded. Result: {task.result}")
else: else:
logger.error(f"Task ID {task.id} failed. Error: {task.result}") logger.error(f"Task ID {task.id} failed. Error: {task.result}")

View File

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

View File

@ -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)

View File

@ -1,5 +1,5 @@
from django.test import TestCase, Client 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.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.core.files.uploadedfile import SimpleUploadedFile from django.core.files.uploadedfile import SimpleUploadedFile
@ -7,18 +7,20 @@ from datetime import datetime, time, timedelta
import json import json
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
User = get_user_model()
from .models import ( from .models import (
JobPosting, Candidate, ZoomMeeting, FormTemplate, FormStage, FormField, JobPosting, Application, Person, ZoomMeeting, FormTemplate, FormStage, FormField,
FormSubmission, FieldResponse, InterviewSchedule, ScheduledInterview, FormSubmission, FieldResponse, InterviewSchedule, ScheduledInterview,
TrainingMaterial, Source, HiringAgency, Profile, MeetingComment TrainingMaterial, Source, HiringAgency, MeetingComment
) )
from .forms import ( from .forms import (
JobPostingForm, CandidateForm, ZoomMeetingForm, MeetingCommentForm, JobPostingForm, CandidateForm, ZoomMeetingForm, MeetingCommentForm,
CandidateStageForm, InterviewScheduleForm CandidateStageForm, InterviewScheduleForm, CandidateSignupForm
) )
from .views import ( from .views import (
ZoomMeetingListView, ZoomMeetingCreateView, job_detail, candidate_screening_view, 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 .views_frontend import CandidateListView, JobListView
from .utils import create_zoom_meeting, get_candidates_from_request from .utils import create_zoom_meeting, get_candidates_from_request
@ -35,7 +37,6 @@ class BaseTestCase(TestCase):
password='testpass123', password='testpass123',
is_staff=True is_staff=True
) )
self.profile = Profile.objects.create(user=self.user)
# Create test data # Create test data
self.job = JobPosting.objects.create( self.job = JobPosting.objects.create(
@ -46,14 +47,20 @@ class BaseTestCase(TestCase):
location_country='Saudi Arabia', location_country='Saudi Arabia',
description='Job description', description='Job description',
qualifications='Job qualifications', qualifications='Job qualifications',
application_deadline=timezone.now() + timedelta(days=30),
created_by=self.user created_by=self.user
) )
self.candidate = Candidate.objects.create( # Create a person first
person = Person.objects.create(
first_name='John', first_name='John',
last_name='Doe', last_name='Doe',
email='john@example.com', 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'), resume=SimpleUploadedFile('resume.pdf', b'file_content', content_type='application/pdf'),
job=self.job, job=self.job,
stage='Applied' stage='Applied'
@ -231,28 +238,6 @@ class ViewTests(BaseTestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, 'success') 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): class FormTests(BaseTestCase):
@ -268,13 +253,13 @@ class FormTests(BaseTestCase):
'location_city': 'Riyadh', 'location_city': 'Riyadh',
'location_state': 'Riyadh', 'location_state': 'Riyadh',
'location_country': 'Saudi Arabia', 'location_country': 'Saudi Arabia',
'description': 'Job description', 'description': 'Job description with at least 20 characters to meet validation requirements',
'qualifications': 'Job qualifications', 'qualifications': 'Job qualifications',
'salary_range': '5000-7000', 'salary_range': '5000-7000',
'application_deadline': '2025-12-31', 'application_deadline': '2025-12-31',
'max_applications': '100', 'max_applications': '100',
'open_positions': '2', 'open_positions': '2',
'hash_tags': '#hiring, #jobopening' 'hash_tags': '#hiring,#jobopening'
} }
form = JobPostingForm(data=form_data) form = JobPostingForm(data=form_data)
self.assertTrue(form.is_valid()) self.assertTrue(form.is_valid())
@ -315,24 +300,51 @@ class FormTests(BaseTestCase):
form_data = { form_data = {
'stage': 'Exam' 'stage': 'Exam'
} }
form = CandidateStageForm(data=form_data, candidate=self.candidate) form = CandidateStageForm(data=form_data, instance=self.candidate)
self.assertTrue(form.is_valid()) self.assertTrue(form.is_valid())
def test_interview_schedule_form(self): def test_interview_schedule_form(self):
"""Test InterviewScheduleForm""" """Test InterviewScheduleForm"""
# Update candidate to Interview stage first
self.candidate.stage = 'Interview'
self.candidate.save()
form_data = { form_data = {
'candidates': [self.candidate.id], 'candidates': [self.candidate.id],
'start_date': (timezone.now() + timedelta(days=1)).date(), 'start_date': (timezone.now() + timedelta(days=1)).date(),
'end_date': (timezone.now() + timedelta(days=7)).date(), 'end_date': (timezone.now() + timedelta(days=7)).date(),
'working_days': [0, 1, 2, 3, 4], # Monday to Friday '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) form = InterviewScheduleForm(slug=self.job.slug, data=form_data)
self.assertTrue(form.is_valid()) 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): class IntegrationTests(BaseTestCase):
"""Integration tests for multiple components""" """Integration tests for multiple components"""
@ -340,11 +352,14 @@ class IntegrationTests(BaseTestCase):
def test_candidate_journey(self): def test_candidate_journey(self):
"""Test the complete candidate journey from application to interview""" """Test the complete candidate journey from application to interview"""
# 1. Create candidate # 1. Create candidate
candidate = Candidate.objects.create( person = Person.objects.create(
first_name='Jane', first_name='Jane',
last_name='Smith', last_name='Smith',
email='jane@example.com', 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'), resume=SimpleUploadedFile('resume.pdf', b'file_content', content_type='application/pdf'),
job=self.job, job=self.job,
stage='Applied' stage='Applied'
@ -369,7 +384,7 @@ class IntegrationTests(BaseTestCase):
) )
# 5. Verify all stages and relationships # 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(ScheduledInterview.objects.count(), 1)
self.assertEqual(candidate.stage, 'Interview') self.assertEqual(candidate.stage, 'Interview')
self.assertEqual(scheduled_interview.candidate, candidate) self.assertEqual(scheduled_interview.candidate, candidate)
@ -439,7 +454,7 @@ class IntegrationTests(BaseTestCase):
) )
# Verify candidate was created # 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): class PerformanceTests(BaseTestCase):
@ -449,11 +464,15 @@ class PerformanceTests(BaseTestCase):
"""Test pagination with large datasets""" """Test pagination with large datasets"""
# Create many candidates # Create many candidates
for i in range(100): for i in range(100):
Candidate.objects.create( person = Person.objects.create(
first_name=f'Candidate{i}', first_name=f'Candidate{i}',
last_name=f'Test{i}', last_name=f'Test{i}',
email=f'candidate{i}@example.com', 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, job=self.job,
stage='Applied' stage='Applied'
) )
@ -594,16 +613,20 @@ class TestFactories:
@staticmethod @staticmethod
def create_candidate(**kwargs): def create_candidate(**kwargs):
job = TestFactories.create_job_posting() job = TestFactories.create_job_posting()
person = Person.objects.create(
first_name='Test',
last_name='Candidate',
email='test@example.com',
phone='1234567890'
)
defaults = { defaults = {
'first_name': 'Test', 'person': person,
'last_name': 'Candidate',
'email': 'test@example.com',
'phone': '1234567890',
'job': job, 'job': job,
'stage': 'Applied' 'stage': 'Applied',
'resume': SimpleUploadedFile('resume.pdf', b'file_content', content_type='application/pdf')
} }
defaults.update(kwargs) defaults.update(kwargs)
return Candidate.objects.create(**defaults) return Application.objects.create(**defaults)
@staticmethod @staticmethod
def create_zoom_meeting(**kwargs): def create_zoom_meeting(**kwargs):

View File

@ -23,28 +23,28 @@ from io import BytesIO
from PIL import Image from PIL import Image
from .models import ( from .models import (
JobPosting, Candidate, ZoomMeeting, FormTemplate, FormStage, FormField, JobPosting, Application, Person, ZoomMeeting, FormTemplate, FormStage, FormField,
FormSubmission, FieldResponse, InterviewSchedule, ScheduledInterview, FormSubmission, FieldResponse, InterviewSchedule, ScheduledInterview,
TrainingMaterial, Source, HiringAgency, Profile, MeetingComment, JobPostingImage, TrainingMaterial, Source, HiringAgency, MeetingComment, JobPostingImage,
BreakTime BreakTime
) )
from .forms import ( from .forms import (
JobPostingForm, CandidateForm, ZoomMeetingForm, MeetingCommentForm, JobPostingForm, ApplicationForm, ZoomMeetingForm, MeetingCommentForm,
CandidateStageForm, InterviewScheduleForm, BreakTimeFormSet ApplicationStageForm, InterviewScheduleForm, BreakTimeFormSet
) )
from .views import ( from .views import (
ZoomMeetingListView, ZoomMeetingCreateView, job_detail, candidate_screening_view, 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, schedule_interviews_view, confirm_schedule_interviews_view, _handle_preview_submission,
_handle_confirm_schedule, _handle_get_request _handle_confirm_schedule, _handle_get_request
) )
from .views_frontend import CandidateListView, JobListView, JobCreateView # from .views_frontend import CandidateListView, JobListView, JobCreateView
from .utils import ( from .utils import (
create_zoom_meeting, delete_zoom_meeting, update_zoom_meeting, create_zoom_meeting, delete_zoom_meeting, update_zoom_meeting,
get_zoom_meeting_details, get_candidates_from_request, get_zoom_meeting_details, get_candidates_from_request,
get_available_time_slots get_available_time_slots
) )
from .zoom_api import ZoomAPIError # from .zoom_api import ZoomAPIError
class AdvancedModelTests(TestCase): class AdvancedModelTests(TestCase):
@ -57,7 +57,6 @@ class AdvancedModelTests(TestCase):
password='testpass123', password='testpass123',
is_staff=True is_staff=True
) )
self.profile = Profile.objects.create(user=self.user)
self.job = JobPosting.objects.create( self.job = JobPosting.objects.create(
title='Software Engineer', title='Software Engineer',
@ -121,11 +120,13 @@ class AdvancedModelTests(TestCase):
def test_candidate_stage_transition_validation(self): def test_candidate_stage_transition_validation(self):
"""Test advanced candidate stage transition validation""" """Test advanced candidate stage transition validation"""
candidate = Candidate.objects.create( application = Application.objects.create(
first_name='John', person=Person.objects.create(
last_name='Doe', first_name='John',
email='john@example.com', last_name='Doe',
phone='1234567890', email='john@example.com',
phone='1234567890'
),
job=self.job, job=self.job,
stage='Applied' stage='Applied'
) )
@ -133,17 +134,19 @@ class AdvancedModelTests(TestCase):
# Test valid transitions # Test valid transitions
valid_transitions = ['Exam', 'Interview', 'Offer'] valid_transitions = ['Exam', 'Interview', 'Offer']
for stage in valid_transitions: for stage in valid_transitions:
candidate.stage = stage application.stage = stage
candidate.save() application.save()
form = CandidateStageForm(data={'stage': stage}, candidate=candidate) # Note: CandidateStageForm may need to be updated for Application model
self.assertTrue(form.is_valid()) # form = CandidateStageForm(data={'stage': stage}, candidate=application)
# self.assertTrue(form.is_valid())
# Test invalid transition (e.g., from Offer back to Applied) # Test invalid transition (e.g., from Offer back to Applied)
candidate.stage = 'Offer' application.stage = 'Offer'
candidate.save() application.save()
form = CandidateStageForm(data={'stage': 'Applied'}, candidate=candidate) # 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 # 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): def test_zoom_meeting_conflict_detection(self):
"""Test conflict detection for overlapping meetings""" """Test conflict detection for overlapping meetings"""
@ -195,19 +198,25 @@ class AdvancedModelTests(TestCase):
def test_interview_schedule_complex_validation(self): def test_interview_schedule_complex_validation(self):
"""Test interview schedule validation with complex constraints""" """Test interview schedule validation with complex constraints"""
# Create candidates # Create applications
candidate1 = Candidate.objects.create( application1 = Application.objects.create(
first_name='John', last_name='Doe', email='john@example.com', person=Person.objects.create(
phone='1234567890', job=self.job, stage='Interview' first_name='John', last_name='Doe', email='john@example.com',
phone='1234567890'
),
job=self.job, stage='Interview'
) )
candidate2 = Candidate.objects.create( application2 = Application.objects.create(
first_name='Jane', last_name='Smith', email='jane@example.com', person=Person.objects.create(
phone='9876543210', job=self.job, stage='Interview' first_name='Jane', last_name='Smith', email='jane@example.com',
phone='9876543210'
),
job=self.job, stage='Interview'
) )
# Create schedule with valid data # Create schedule with valid data
schedule_data = { schedule_data = {
'candidates': [candidate1.id, candidate2.id], 'candidates': [application1.id, application2.id],
'start_date': date.today() + timedelta(days=1), 'start_date': date.today() + timedelta(days=1),
'end_date': date.today() + timedelta(days=7), 'end_date': date.today() + timedelta(days=7),
'working_days': [0, 1, 2, 3, 4], # Mon-Fri 'working_days': [0, 1, 2, 3, 4], # Mon-Fri
@ -279,7 +288,6 @@ class AdvancedViewTests(TestCase):
password='testpass123', password='testpass123',
is_staff=True is_staff=True
) )
self.profile = Profile.objects.create(user=self.user)
self.job = JobPosting.objects.create( self.job = JobPosting.objects.create(
title='Software Engineer', title='Software Engineer',
@ -293,11 +301,13 @@ class AdvancedViewTests(TestCase):
status='ACTIVE' status='ACTIVE'
) )
self.candidate = Candidate.objects.create( self.application = Application.objects.create(
first_name='John', person=Person.objects.create(
last_name='Doe', first_name='John',
email='john@example.com', last_name='Doe',
phone='1234567890', email='john@example.com',
phone='1234567890'
),
job=self.job, job=self.job,
stage='Applied' stage='Applied'
) )
@ -313,18 +323,27 @@ class AdvancedViewTests(TestCase):
def test_job_detail_with_multiple_candidates(self): def test_job_detail_with_multiple_candidates(self):
"""Test job detail view with multiple candidates at different stages""" """Test job detail view with multiple candidates at different stages"""
# Create more candidates at different stages # Create more applications at different stages
Candidate.objects.create( Application.objects.create(
first_name='Jane', last_name='Smith', email='jane@example.com', person=Person.objects.create(
phone='9876543210', job=self.job, stage='Exam' first_name='Jane', last_name='Smith', email='jane@example.com',
phone='9876543210'
),
job=self.job, stage='Exam'
) )
Candidate.objects.create( Application.objects.create(
first_name='Bob', last_name='Johnson', email='bob@example.com', person=Person.objects.create(
phone='5555555555', job=self.job, stage='Interview' first_name='Bob', last_name='Johnson', email='bob@example.com',
phone='5555555555'
),
job=self.job, stage='Interview'
) )
Candidate.objects.create( Application.objects.create(
first_name='Alice', last_name='Brown', email='alice@example.com', person=Person.objects.create(
phone='4444444444', job=self.job, stage='Offer' 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})) response = self.client.get(reverse('job_detail', kwargs={'slug': self.job.slug}))
@ -352,7 +371,7 @@ class AdvancedViewTests(TestCase):
# Create scheduled interviews # Create scheduled interviews
ScheduledInterview.objects.create( ScheduledInterview.objects.create(
candidate=self.candidate, application=self.application,
job=self.job, job=self.job,
zoom_meeting=self.zoom_meeting, zoom_meeting=self.zoom_meeting,
interview_date=timezone.now().date(), interview_date=timezone.now().date(),
@ -361,9 +380,12 @@ class AdvancedViewTests(TestCase):
) )
ScheduledInterview.objects.create( ScheduledInterview.objects.create(
candidate=Candidate.objects.create( application=Application.objects.create(
first_name='Jane', last_name='Smith', email='jane@example.com', person=Person.objects.create(
phone='9876543210', job=self.job, stage='Interview' first_name='Jane', last_name='Smith', email='jane@example.com',
phone='9876543210'
),
job=self.job, stage='Interview'
), ),
job=self.job, job=self.job,
zoom_meeting=meeting2, zoom_meeting=meeting2,
@ -382,14 +404,20 @@ class AdvancedViewTests(TestCase):
def test_candidate_list_advanced_search(self): def test_candidate_list_advanced_search(self):
"""Test candidate list view with advanced search functionality""" """Test candidate list view with advanced search functionality"""
# Create more candidates for testing # Create more applications for testing
Candidate.objects.create( Application.objects.create(
first_name='Jane', last_name='Smith', email='jane@example.com', person=Person.objects.create(
phone='9876543210', job=self.job, stage='Exam' first_name='Jane', last_name='Smith', email='jane@example.com',
phone='9876543210'
),
job=self.job, stage='Exam'
) )
Candidate.objects.create( Application.objects.create(
first_name='Bob', last_name='Johnson', email='bob@example.com', person=Person.objects.create(
phone='5555555555', job=self.job, stage='Interview' first_name='Bob', last_name='Johnson', email='bob@example.com',
phone='5555555555'
),
job=self.job, stage='Interview'
) )
# Test search by name # Test search by name
@ -420,18 +448,20 @@ class AdvancedViewTests(TestCase):
def test_interview_scheduling_workflow(self): def test_interview_scheduling_workflow(self):
"""Test the complete interview scheduling workflow""" """Test the complete interview scheduling workflow"""
# Create candidates for scheduling # Create applications for scheduling
candidates = [] applications = []
for i in range(3): for i in range(3):
candidate = Candidate.objects.create( application = Application.objects.create(
first_name=f'Candidate{i}', person=Person.objects.create(
last_name=f'Test{i}', first_name=f'Candidate{i}',
email=f'candidate{i}@example.com', last_name=f'Test{i}',
phone=f'123456789{i}', email=f'candidate{i}@example.com',
phone=f'123456789{i}'
),
job=self.job, job=self.job,
stage='Interview' stage='Interview'
) )
candidates.append(candidate) applications.append(application)
# Test GET request (initial form) # Test GET request (initial form)
request = self.client.get(reverse('schedule_interviews', kwargs={'slug': self.job.slug})) request = self.client.get(reverse('schedule_interviews', kwargs={'slug': self.job.slug}))
@ -449,7 +479,7 @@ class AdvancedViewTests(TestCase):
# Test _handle_preview_submission # Test _handle_preview_submission
self.client.login(username='testuser', password='testpass123') self.client.login(username='testuser', password='testpass123')
post_data = { post_data = {
'candidates': [c.pk for c in candidates], 'candidates': [a.pk for a in applications],
'start_date': (date.today() + timedelta(days=1)).isoformat(), 'start_date': (date.today() + timedelta(days=1)).isoformat(),
'end_date': (date.today() + timedelta(days=7)).isoformat(), 'end_date': (date.today() + timedelta(days=7)).isoformat(),
'working_days': [0, 1, 2, 3, 4], 'working_days': [0, 1, 2, 3, 4],
@ -505,38 +535,40 @@ class AdvancedViewTests(TestCase):
def test_bulk_operations(self): def test_bulk_operations(self):
"""Test bulk operations on candidates""" """Test bulk operations on candidates"""
# Create multiple candidates # Create multiple applications
candidates = [] applications = []
for i in range(5): for i in range(5):
candidate = Candidate.objects.create( application = Application.objects.create(
first_name=f'Bulk{i}', person=Person.objects.create(
last_name=f'Test{i}', first_name=f'Bulk{i}',
email=f'bulk{i}@example.com', last_name=f'Test{i}',
phone=f'123456789{i}', email=f'bulk{i}@example.com',
phone=f'123456789{i}'
),
job=self.job, job=self.job,
stage='Applied' stage='Applied'
) )
candidates.append(candidate) applications.append(application)
# Test bulk status update # 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') self.client.login(username='testuser', password='testpass123')
# This would be tested via a form submission # This would be tested via a form submission
# For now, we test the view logic directly # For now, we test the view logic directly
request = self.client.post( request = self.client.post(
reverse('candidate_update_status', kwargs={'slug': self.job.slug}), 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 # Should redirect back to the view
self.assertEqual(request.status_code, 302) self.assertEqual(request.status_code, 302)
# Verify candidates were updated # Verify applications were updated
updated_count = Candidate.objects.filter( updated_count = Application.objects.filter(
pk__in=candidate_ids, pk__in=application_ids,
stage='Exam' stage='Exam'
).count() ).count()
self.assertEqual(updated_count, len(candidates)) self.assertEqual(updated_count, len(applications))
class AdvancedFormTests(TestCase): class AdvancedFormTests(TestCase):
@ -627,7 +659,7 @@ class AdvancedFormTests(TestCase):
'resume': valid_file 'resume': valid_file
} }
form = CandidateForm(data=candidate_data, files=candidate_data) form = ApplicationForm(data=candidate_data, files=candidate_data)
self.assertTrue(form.is_valid()) self.assertTrue(form.is_valid())
# Test invalid file type (would need custom validator) # Test invalid file type (would need custom validator)
@ -636,25 +668,27 @@ class AdvancedFormTests(TestCase):
def test_dynamic_form_fields(self): def test_dynamic_form_fields(self):
"""Test forms with dynamically populated fields""" """Test forms with dynamically populated fields"""
# Test InterviewScheduleForm with dynamic candidate queryset # Test InterviewScheduleForm with dynamic candidate queryset
# Create candidates in Interview stage # Create applications in Interview stage
candidates = [] applications = []
for i in range(3): for i in range(3):
candidate = Candidate.objects.create( application = Application.objects.create(
first_name=f'Interview{i}', person=Person.objects.create(
last_name=f'Candidate{i}', first_name=f'Interview{i}',
email=f'interview{i}@example.com', last_name=f'Candidate{i}',
phone=f'123456789{i}', email=f'interview{i}@example.com',
phone=f'123456789{i}'
),
job=self.job, job=self.job,
stage='Interview' 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) form = InterviewScheduleForm(slug=self.job.slug)
self.assertEqual(form.fields['candidates'].queryset.count(), 3) self.assertEqual(form.fields['candidates'].queryset.count(), 3)
for candidate in candidates: for application in applications:
self.assertIn(candidate, form.fields['candidates'].queryset) self.assertIn(application, form.fields['candidates'].queryset)
class AdvancedIntegrationTests(TransactionTestCase): class AdvancedIntegrationTests(TransactionTestCase):
@ -668,7 +702,6 @@ class AdvancedIntegrationTests(TransactionTestCase):
password='testpass123', password='testpass123',
is_staff=True is_staff=True
) )
self.profile = Profile.objects.create(user=self.user)
def test_complete_hiring_workflow(self): def test_complete_hiring_workflow(self):
"""Test the complete hiring workflow from job posting to hire""" """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 self.assertEqual(response.status_code, 302) # Redirect to success page
# 5. Verify candidate was created # 5. Verify application was created
candidate = Candidate.objects.get(email='sarah@example.com') application = Application.objects.get(person__email='sarah@example.com')
self.assertEqual(candidate.stage, 'Applied') self.assertEqual(application.stage, 'Applied')
self.assertEqual(candidate.job, job) self.assertEqual(application.job, job)
# 6. Move candidate to Exam stage # 6. Move application to Exam stage
candidate.stage = 'Exam' application.stage = 'Exam'
candidate.save() application.save()
# 7. Move candidate to Interview stage # 7. Move application to Interview stage
candidate.stage = 'Interview' application.stage = 'Interview'
candidate.save() application.save()
# 8. Create interview schedule # 8. Create interview schedule
scheduled_interview = ScheduledInterview.objects.create( scheduled_interview = ScheduledInterview.objects.create(
candidate=candidate, application=application,
job=job, job=job,
interview_date=timezone.now().date() + timedelta(days=7), interview_date=timezone.now().date() + timedelta(days=7),
interview_time=time(14, 0), interview_time=time(14, 0),
@ -773,7 +806,7 @@ class AdvancedIntegrationTests(TransactionTestCase):
# 9. Create Zoom meeting # 9. Create Zoom meeting
zoom_meeting = ZoomMeeting.objects.create( 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), start_time=timezone.now() + timedelta(days=7, hours=14),
duration=60, duration=60,
timezone='UTC', timezone='UTC',
@ -786,16 +819,16 @@ class AdvancedIntegrationTests(TransactionTestCase):
scheduled_interview.save() scheduled_interview.save()
# 11. Verify all relationships # 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(zoom_meeting.interview, scheduled_interview)
self.assertEqual(job.candidates.count(), 1) self.assertEqual(job.applications.count(), 1)
# 12. Complete hire process # 12. Complete hire process
candidate.stage = 'Offer' application.stage = 'Offer'
candidate.save() application.save()
# 13. Verify final state # 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): def test_data_integrity_across_operations(self):
"""Test data integrity across multiple operations""" """Test data integrity across multiple operations"""
@ -811,18 +844,20 @@ class AdvancedIntegrationTests(TransactionTestCase):
max_applications=5 max_applications=5
) )
# Create multiple candidates # Create multiple applications
candidates = [] applications = []
for i in range(3): for i in range(3):
candidate = Candidate.objects.create( application = Application.objects.create(
first_name=f'Data{i}', person=Person.objects.create(
last_name=f'Scientist{i}', first_name=f'Data{i}',
email=f'data{i}@example.com', last_name=f'Scientist{i}',
phone=f'123456789{i}', email=f'data{i}@example.com',
phone=f'123456789{i}'
),
job=job, job=job,
stage='Applied' stage='Applied'
) )
candidates.append(candidate) applications.append(application)
# Create form template # Create form template
template = FormTemplate.objects.create( template = FormTemplate.objects.create(
@ -832,12 +867,12 @@ class AdvancedIntegrationTests(TransactionTestCase):
is_active=True is_active=True
) )
# Create submissions for candidates # Create submissions for applications
for i, candidate in enumerate(candidates): for i, application in enumerate(applications):
submission = FormSubmission.objects.create( submission = FormSubmission.objects.create(
template=template, template=template,
applicant_name=f'{candidate.first_name} {candidate.last_name}', applicant_name=f'{application.person.first_name} {application.person.last_name}',
applicant_email=candidate.email applicant_email=application.person.email
) )
# Create field responses # Create field responses
@ -856,12 +891,14 @@ class AdvancedIntegrationTests(TransactionTestCase):
self.assertEqual(FieldResponse.objects.count(), 3) self.assertEqual(FieldResponse.objects.count(), 3)
# Test application limit # Test application limit
for i in range(3): # Try to add more candidates than limit for i in range(3): # Try to add more applications than limit
Candidate.objects.create( Application.objects.create(
first_name=f'Extra{i}', person=Person.objects.create(
last_name=f'Candidate{i}', first_name=f'Extra{i}',
email=f'extra{i}@example.com', last_name=f'Candidate{i}',
phone=f'11111111{i}', email=f'extra{i}@example.com',
phone=f'11111111{i}'
),
job=job, job=job,
stage='Applied' stage='Applied'
) )
@ -873,7 +910,7 @@ class AdvancedIntegrationTests(TransactionTestCase):
@patch('recruitment.views.create_zoom_meeting') @patch('recruitment.views.create_zoom_meeting')
def test_zoom_integration_workflow(self, mock_create): def test_zoom_integration_workflow(self, mock_create):
"""Test complete Zoom integration workflow""" """Test complete Zoom integration workflow"""
# Setup job and candidate # Setup job and application
job = JobPosting.objects.create( job = JobPosting.objects.create(
title='Remote Developer', title='Remote Developer',
department='Engineering', department='Engineering',
@ -881,10 +918,12 @@ class AdvancedIntegrationTests(TransactionTestCase):
created_by=self.user created_by=self.user
) )
candidate = Candidate.objects.create( application = Application.objects.create(
first_name='Remote', person=Person.objects.create(
last_name='Developer', first_name='Remote',
email='remote@example.com', last_name='Developer',
email='remote@example.com'
),
job=job, job=job,
stage='Interview' stage='Interview'
) )
@ -906,7 +945,7 @@ class AdvancedIntegrationTests(TransactionTestCase):
# Schedule meeting via API # Schedule meeting via API
with patch('recruitment.views.ScheduledInterview.objects.create') as mock_create_interview: with patch('recruitment.views.ScheduledInterview.objects.create') as mock_create_interview:
mock_create_interview.return_value = ScheduledInterview( mock_create_interview.return_value = ScheduledInterview(
candidate=candidate, application=application,
job=job, job=job,
zoom_meeting=None, zoom_meeting=None,
interview_date=timezone.now().date(), interview_date=timezone.now().date(),
@ -916,7 +955,7 @@ class AdvancedIntegrationTests(TransactionTestCase):
response = self.client.post( response = self.client.post(
reverse('api_schedule_candidate_meeting', reverse('api_schedule_candidate_meeting',
kwargs={'job_slug': job.slug, 'candidate_pk': candidate.pk}), kwargs={'job_slug': job.slug, 'candidate_pk': application.pk}),
data={ data={
'start_time': (timezone.now() + timedelta(hours=1)).isoformat(), 'start_time': (timezone.now() + timedelta(hours=1)).isoformat(),
'duration': 60 'duration': 60
@ -941,43 +980,45 @@ class AdvancedIntegrationTests(TransactionTestCase):
created_by=self.user created_by=self.user
) )
# Create candidates # Create applications
candidates = [] applications = []
for i in range(10): for i in range(10):
candidate = Candidate.objects.create( application = Application.objects.create(
first_name=f'Concurrent{i}', person=Person.objects.create(
last_name=f'Test{i}', first_name=f'Concurrent{i}',
email=f'concurrent{i}@example.com', last_name=f'Test{i}',
email=f'concurrent{i}@example.com'
),
job=job, job=job,
stage='Applied' stage='Applied'
) )
candidates.append(candidate) applications.append(application)
# Test concurrent candidate updates # Test concurrent application updates
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
def update_candidate(candidate_id, stage): def update_application(application_id, stage):
from django.test import TestCase from django.test import TestCase
from django.db import transaction from django.db import transaction
from recruitment.models import Candidate from recruitment.models import Application
with transaction.atomic(): with transaction.atomic():
candidate = Candidate.objects.select_for_update().get(pk=candidate_id) application = Application.objects.select_for_update().get(pk=application_id)
candidate.stage = stage application.stage = stage
candidate.save() application.save()
# Update candidates concurrently # Update applications concurrently
with ThreadPoolExecutor(max_workers=3) as executor: with ThreadPoolExecutor(max_workers=3) as executor:
futures = [ futures = [
executor.submit(update_candidate, c.pk, 'Exam') executor.submit(update_application, a.pk, 'Exam')
for c in candidates for a in applications
] ]
for future in futures: for future in futures:
future.result() future.result()
# Verify all updates completed # 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): class SecurityTests(TestCase):

View File

@ -5,13 +5,22 @@ from . import views_integration
from . import views_source from . import views_source
urlpatterns = [ urlpatterns = [
path('', views_frontend.dashboard_view, name='dashboard'), path("", views_frontend.dashboard_view, name="dashboard"),
# Job URLs (using JobPosting model) # Job URLs (using JobPosting model)
path('jobs/', views_frontend.JobListView.as_view(), name='job_list'), path("persons/", views.PersonListView.as_view(), name="person_list"),
path('jobs/create/', views.create_job, name='job_create'), path("persons/create/", views.PersonCreateView.as_view(), name="person_create"),
path('job/<slug:slug>/upload_image_simple/', views.job_image_upload, name='job_image_upload'), path("persons/<slug:slug>/", views.PersonDetailView.as_view(), name="person_detail"),
path('jobs/<slug:slug>/update/', views.edit_job, name='job_update'), 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>/delete/', views., name='job_delete'),
path('jobs/<slug:slug>/', views.job_detail, name='job_detail'), path('jobs/<slug:slug>/', views.job_detail, name='job_detail'),
path('jobs/<slug:slug>/download/cvs/', views.job_cvs_download, name='job_cvs_download'), 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'), path('careers/',views.kaauh_career,name='kaauh_career'),
# LinkedIn Integration URLs # LinkedIn Integration URLs
path('jobs/<slug:slug>/post-to-linkedin/', views.post_to_linkedin, name='post_to_linkedin'), path(
path('jobs/linkedin/login/', views.linkedin_login, name='linkedin_login'), "jobs/<slug:slug>/post-to-linkedin/",
path('jobs/linkedin/callback/', views.linkedin_callback, name='linkedin_callback'), views.post_to_linkedin,
name="post_to_linkedin",
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/linkedin/login/", views.linkedin_login, name="linkedin_login"),
path("jobs/linkedin/callback/", views.linkedin_callback, name="linkedin_callback"),
# Candidate URLs # Candidate URLs
path('candidates/', views_frontend.CandidateListView.as_view(), name='candidate_list'), path(
path('candidates/create/', views_frontend.CandidateCreateView.as_view(), name='candidate_create'), "candidates/", views_frontend.ApplicationListView.as_view(), name="candidate_list"
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(
path('candidates/<slug:slug>/update/', views_frontend.CandidateUpdateView.as_view(), name='candidate_update'), "candidates/create/",
path('candidates/<slug:slug>/delete/', views_frontend.CandidateDeleteView.as_view(), name='candidate_delete'), views_frontend.ApplicationCreateView.as_view(),
path('candidate/<slug:slug>/view/', views_frontend.candidate_detail, name='candidate_detail'), name="candidate_create",
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(
path('candidate/<slug:slug>/retry-scoring/', views_frontend.retry_scoring_view, name='candidate_retry_scoring'), "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 # Training URLs
path('training/', views_frontend.TrainingListView.as_view(), name='training_list'), path("training/", views_frontend.TrainingListView.as_view(), name="training_list"),
path('training/create/', views_frontend.TrainingCreateView.as_view(), name='training_create'), path(
path('training/<slug:slug>/', views_frontend.TrainingDetailView.as_view(), name='training_detail'), "training/create/",
path('training/<slug:slug>/update/', views_frontend.TrainingUpdateView.as_view(), name='training_update'), views_frontend.TrainingCreateView.as_view(),
path('training/<slug:slug>/delete/', views_frontend.TrainingDeleteView.as_view(), name='training_delete'), 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 # Meeting URLs
path('meetings/', views.ZoomMeetingListView.as_view(), name='list_meetings'), # path("meetings/", views.ZoomMeetingListView.as_view(), name="list_meetings"),
path('meetings/create-meeting/', views.ZoomMeetingCreateView.as_view(), name='create_meeting'),
path('meetings/meeting-details/<slug:slug>/', views.ZoomMeetingDetailsView.as_view(), name='meeting_details'),
path('meetings/update-meeting/<slug:slug>/', views.ZoomMeetingUpdateView.as_view(), name='update_meeting'),
path('meetings/delete-meeting/<slug:slug>/', views.ZoomMeetingDeleteView, name='delete_meeting'),
# JobPosting functional views URLs (keeping for compatibility) # JobPosting functional views URLs (keeping for compatibility)
path('api/create/', views.create_job, name='create_job_api'), path("api/create/", views.create_job, name="create_job_api"),
path('api/<slug:slug>/edit/', views.edit_job, name='edit_job_api'), path("api/<slug:slug>/edit/", views.edit_job, name="edit_job_api"),
# ERP Integration URLs # ERP Integration URLs
path('integration/erp/', views_integration.ERPIntegrationView.as_view(), name='erp_integration'), path(
path('integration/erp/create-job/', views_integration.erp_create_job_view, name='erp_create_job'), "integration/erp/",
path('integration/erp/update-job/', views_integration.erp_update_job_view, name='erp_update_job'), views_integration.ERPIntegrationView.as_view(),
path('integration/erp/health/', views_integration.erp_integration_health, name='erp_integration_health'), 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 # Form Preview URLs
# path('forms/', views.form_list, name='form_list'), # path('forms/', views.form_list, name='form_list'),
path("forms/builder/", views.form_builder, name="form_builder"),
path('forms/builder/', views.form_builder, name='form_builder'), path(
path('forms/builder/<slug:template_slug>/', views.form_builder, name='form_builder'), "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("forms/", views.form_templates_list, name="form_templates_list"),
path(
path('jobs/<slug:slug>/edit_linkedin_post_content/',views.edit_linkedin_post_content,name='edit_linkedin_post_content'), "forms/create-template/",
path('jobs/<slug:slug>/candidate_screening_view/', views.candidate_screening_view, name='candidate_screening_view'), views.create_form_template,
path('jobs/<slug:slug>/candidate_exam_view/', views.candidate_exam_view, name='candidate_exam_view'), name="create_form_template",
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(
path('jobs/<slug:slug>/candidate_hired_view/', views_frontend.candidate_hired_view, name='candidate_hired_view'), "jobs/<slug:slug>/edit_linkedin_post_content/",
path('jobs/<slug:job_slug>/export/<str:stage>/csv/', views_frontend.export_candidates_csv, name='export_candidates_csv'), views.edit_linkedin_post_content,
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'), 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 # Sync URLs
path('jobs/<slug:job_slug>/sync-hired-candidates/', views_frontend.sync_hired_candidates, name='sync_hired_candidates'), path(
path('sources/<int:source_id>/test-connection/', views_frontend.test_source_connection, name='test_source_connection'), "jobs/<slug:job_slug>/sync-hired-candidates/",
views_frontend.sync_hired_candidates,
path('jobs/<slug:slug>/<int:candidate_id>/reschedule_meeting_for_candidate/<int:meeting_id>/', views.reschedule_meeting_for_candidate, name='reschedule_meeting_for_candidate'), name="sync_hired_candidates",
),
path('jobs/<slug:slug>/update_candidate_exam_status/', views.update_candidate_exam_status, name='update_candidate_exam_status'), path(
path('jobs/<slug:slug>/bulk_update_candidate_exam_status/', views.bulk_update_candidate_exam_status, name='bulk_update_candidate_exam_status'), "sources/<int:source_id>/test-connection/",
views_frontend.test_source_connection,
path('htmx/<int:pk>/candidate_criteria_view/', views.candidate_criteria_view_htmx, name='candidate_criteria_view_htmx'), name="test_source_connection",
path('htmx/<slug:slug>/candidate_set_exam_date/', views.candidate_set_exam_date, name='candidate_set_exam_date'), ),
path(
path('htmx/<slug:slug>/candidate_update_status/', views.candidate_update_status, name='candidate_update_status'), "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>/submit/', views.submit_form, name='submit_form'),
# path('forms/form/<slug:template_slug>/', views.form_wizard_view, name='form_wizard'), # 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(
path('forms/template/<slug:slug>/submissions/', views.form_template_submissions_list, name='form_template_submissions_list'), "forms/<int:template_id>/submissions/<slug:slug>/",
path('forms/template/<int:template_id>/all-submissions/', views.form_template_all_submissions, name='form_template_all_submissions'), 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>/', views.form_preview, name='form_preview'),
# path('forms/<int:form_id>/submit/', views.form_submit, name='form_submit'), # path('forms/<int:form_id>/submit/', views.form_submit, name='form_submit'),
# path('forms/<int:form_id>/embed/', views.form_embed, name='form_embed'), # path('forms/<int:form_id>/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/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>/', views.load_form_template, name='load_form_template'),
# path('api/templates/<slug:template_slug>/delete/', views.delete_form_template, name='delete_form_template'), # path('api/templates/<slug:template_slug>/delete/', views.delete_form_template, name='delete_form_template'),
path(
"jobs/<slug:slug>/calendar/",
path('jobs/<slug:slug>/calendar/', views.interview_calendar_view, name='interview_calendar'), views.interview_calendar_view,
path('jobs/<slug:slug>/calendar/interview/<int:interview_id>/', views.interview_detail_view, name='interview_detail'), name="interview_calendar",
),
# Candidate Meeting Scheduling/Rescheduling URLs path(
path('jobs/<slug:job_slug>/candidates/<int:candidate_pk>/schedule-meeting/', views.schedule_candidate_meeting, name='schedule_candidate_meeting'), "jobs/<slug:slug>/calendar/interview/<int:interview_id>/",
path('api/jobs/<slug:job_slug>/candidates/<int:candidate_pk>/schedule-meeting/', views.api_schedule_candidate_meeting, name='api_schedule_candidate_meeting'), views.interview_detail_view,
path('jobs/<slug:job_slug>/candidates/<int:candidate_pk>/reschedule-meeting/<int:interview_pk>/', views.reschedule_candidate_meeting, name='reschedule_candidate_meeting'), name="interview_detail",
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'),
# users urls # users urls
path('user/<int:pk>',views.user_detail,name='user_detail'), 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(
path('easy_logs/',views.easy_logs,name='easy_logs'), "user/user_profile_image_update/<int:pk>",
path('settings/',views.admin_settings,name='admin_settings'), views.user_profile_image_update,
path('staff/create',views.create_staff_user,name='create_staff_user'), name="user_profile_image_update",
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("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 # Source URLs
path('sources/', views_source.SourceListView.as_view(), name='source_list'), path("sources/", views_source.SourceListView.as_view(), name="source_list"),
path('sources/create/', views_source.SourceCreateView.as_view(), name='source_create'), path(
path('sources/<int:pk>/', views_source.SourceDetailView.as_view(), name='source_detail'), "sources/create/", views_source.SourceCreateView.as_view(), name="source_create"
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(
path('sources/<int:pk>/generate-keys/', views_source.generate_api_keys_view, name='generate_api_keys'), "sources/<int:pk>/",
path('sources/<int:pk>/toggle-status/', views_source.toggle_source_status_view, name='toggle_source_status'), views_source.SourceDetailView.as_view(),
path('sources/api/copy-to-clipboard/', views_source.copy_to_clipboard_view, name='copy_to_clipboard'), 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 # Meeting Comments URLs
path('meetings/<slug:slug>/comments/add/', views.add_meeting_comment, name='add_meeting_comment'), path(
path('meetings/<slug:slug>/comments/<int:comment_id>/edit/', views.edit_meeting_comment, name='edit_meeting_comment'), "meetings/<slug:slug>/comments/add/",
views.add_meeting_comment,
path('meetings/<slug:slug>/comments/<int:comment_id>/delete/', views.delete_meeting_comment, name='delete_meeting_comment'), name="add_meeting_comment",
),
path('meetings/<slug:slug>/set_meeting_candidate/', views.set_meeting_candidate, name='set_meeting_candidate'), 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 # Hiring Agency URLs
path('agencies/', views.agency_list, name='agency_list'), path("agencies/", views.agency_list, name="agency_list"),
path('agencies/create/', views.agency_create, name='agency_create'), path("agencies/create/", views.agency_create, name="agency_create"),
path('agencies/<slug:slug>/', views.agency_detail, name='agency_detail'), 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>/update/", views.agency_update, name="agency_update"),
path('agencies/<slug:slug>/delete/', views.agency_delete, name='agency_delete'), 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>/candidates/",
views.agency_candidates,
name="agency_candidates",
),
# path('agencies/<slug:slug>/send-message/', views.agency_detail_send_message, name='agency_detail_send_message'), # path('agencies/<slug:slug>/send-message/', views.agency_detail_send_message, name='agency_detail_send_message'),
# Agency Assignment Management URLs # Agency Assignment Management URLs
path('agency-assignments/', views.agency_assignment_list, name='agency_assignment_list'), path(
path('agency-assignments/create/', views.agency_assignment_create, name='agency_assignment_create'), "agency-assignments/",
path('agency-assignments/<slug:slug>/create/', views.agency_assignment_create, name='agency_assignment_create'), views.agency_assignment_list,
path('agency-assignments/<slug:slug>/', views.agency_assignment_detail, name='agency_assignment_detail'), name="agency_assignment_list",
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/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 # Agency Access Link URLs
path('agency-access-links/create/', views.agency_access_link_create, name='agency_access_link_create'), path(
path('agency-access-links/<slug:slug>/', views.agency_access_link_detail, name='agency_access_link_detail'), "agency-access-links/create/",
path('agency-access-links/<slug:slug>/deactivate/', views.agency_access_link_deactivate, name='agency_access_link_deactivate'), views.agency_access_link_create,
path('agency-access-links/<slug:slug>/reactivate/', views.agency_access_link_reactivate, name='agency_access_link_reactivate'), 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) # Admin Message Center URLs (messaging functionality removed)
# path('admin/messages/', views.admin_message_center, name='admin_message_center'), # path('admin/messages/', views.admin_message_center, name='admin_message_center'),
# path('admin/messages/compose/', views.admin_compose_message, name='admin_compose_message'), # 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>/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>/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'), # path('admin/messages/<int:message_id>/delete/', views.admin_delete_message, name='admin_delete_message'),
# Agency Portal URLs (for external agencies) # Agency Portal URLs (for external agencies)
path('portal/login/', views.agency_portal_login, name='agency_portal_login'), path("portal/login/", views.agency_portal_login, name="agency_portal_login"),
path('portal/dashboard/', views.agency_portal_dashboard, name='agency_portal_dashboard'), path("portal/<int:pk>/reset/", views.portal_password_reset, name="portal_password_reset"),
path('portal/assignment/<slug:slug>/', views.agency_portal_assignment_detail, name='agency_portal_assignment_detail'), path(
path('portal/assignment/<slug:slug>/submit-candidate/', views.agency_portal_submit_candidate_page, name='agency_portal_submit_candidate_page'), "portal/dashboard/",
path('portal/submit-candidate/', views.agency_portal_submit_candidate, name='agency_portal_submit_candidate'), views.agency_portal_dashboard,
path('portal/logout/', views.agency_portal_logout, name='agency_portal_logout'), 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 # Agency Portal Candidate Management URLs
path('portal/candidates/<int:candidate_id>/edit/', views.agency_portal_edit_candidate, name='agency_portal_edit_candidate'), path(
path('portal/candidates/<int:candidate_id>/delete/', views.agency_portal_delete_candidate, name='agency_portal_delete_candidate'), "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) # 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>/', 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'), # 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 # 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 # # Admin Notification API
# path('api/admin/notification-count/', views.api_notification_count, name='admin_notification_count'), # path('api/admin/notification-count/', views.api_notification_count, name='admin_notification_count'),
# # Agency Notification API # # Agency Notification API
# path('api/agency/notification-count/', views.api_notification_count, name='api_agency_notification_count'), # path('api/agency/notification-count/', views.api_notification_count, name='api_agency_notification_count'),
# # SSE Notification Stream # # SSE Notification Stream
# path('api/notifications/stream/', views.notification_stream, name='notification_stream'), # path('api/notifications/stream/', views.notification_stream, name='notification_stream'),
# # Notification URLs # # Notification URLs
# path('notifications/', views.notification_list, name='notification_list'), # path('notifications/', views.notification_list, name='notification_list'),
# path('notifications/<int:notification_id>/', views.notification_detail, name='notification_detail'), # 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/<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('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'), # path('api/notification-count/', views.api_notification_count, name='api_notification_count'),
# participants urls
path(
#participants urls "participants/",
path('participants/', views_frontend.ParticipantsListView.as_view(), name='participants_list'), views_frontend.ParticipantsListView.as_view(),
path('participants/create/', views_frontend.ParticipantsCreateView.as_view(), name='participants_create'), name="participants_list",
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(
path('participants/<slug:slug>/delete/', views_frontend.ParticipantsDeleteView.as_view(), name='participants_delete'), "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 # 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('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/partcipants/<slug:slug>/',views.create_interview_participants,name='create_interview_participants'),
path('interview/email/<slug:slug>/',views.send_interview_email,name='send_interview_email'), 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) --- # # --- 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>/', 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>/update/', views.ScheduledInterviewUpdateView.as_view(), name='update_scheduled_interview'),
# path('interviews/<slug:slug>/delete/', views.ScheduledInterviewDeleteView.as_view(), name='delete_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"),
] ]

View File

@ -594,7 +594,7 @@ def update_meeting(instance, updated_data):
instance.topic = zoom_details.get("topic", instance.topic) instance.topic = zoom_details.get("topic", instance.topic)
instance.duration = zoom_details.get("duration", instance.duration) 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) instance.password = zoom_details.get("password", instance.password)
# Corrected status assignment: instance.status, not instance.password # Corrected status assignment: instance.status, not instance.password
instance.status = zoom_details.get("status") instance.status = zoom_details.get("status")

File diff suppressed because it is too large Load Diff

View File

@ -8,6 +8,7 @@ from django.db.models.fields.json import KeyTextTransform
from recruitment.utils import json_to_markdown_table from recruitment.utils import json_to_markdown_table
from django.db.models import Count, Avg, F, FloatField from django.db.models import Count, Avg, F, FloatField
from django.db.models.functions import Cast from django.db.models.functions import Cast
from django.db.models.functions import Coalesce, Cast, Replace, NullIf
from . import models from . import models
from django.utils.translation import get_language from django.utils.translation import get_language
from . import forms from . import forms
@ -22,7 +23,7 @@ from django.views.generic import ListView, CreateView, UpdateView, DeleteView, D
# JobForm removed - using JobPostingForm instead # JobForm removed - using JobPostingForm instead
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.db.models import FloatField 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.db.models.functions import Cast, Coalesce, TruncDate
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.shortcuts import render from django.shortcuts import render
@ -30,6 +31,9 @@ from django.utils import timezone
from datetime import timedelta from datetime import timedelta
import json import json
# Add imports for user type restrictions
from recruitment.decorators import StaffRequiredMixin, staff_user_required
from datastar_py.django import ( from datastar_py.django import (
DatastarResponse, DatastarResponse,
@ -39,7 +43,7 @@ from datastar_py.django import (
# from rich import print # from rich import print
from rich.markdown import CodeBlock from rich.markdown import CodeBlock
class JobListView(LoginRequiredMixin, ListView): class JobListView(LoginRequiredMixin, StaffRequiredMixin, ListView):
model = models.JobPosting model = models.JobPosting
template_name = 'jobs/job_list.html' template_name = 'jobs/job_list.html'
context_object_name = 'jobs' context_object_name = 'jobs'
@ -47,7 +51,6 @@ class JobListView(LoginRequiredMixin, ListView):
def get_queryset(self): def get_queryset(self):
queryset = super().get_queryset().order_by('-created_at') queryset = super().get_queryset().order_by('-created_at')
# Handle search # Handle search
search_query = self.request.GET.get('search', '') search_query = self.request.GET.get('search', '')
if search_query: if search_query:
@ -58,24 +61,23 @@ class JobListView(LoginRequiredMixin, ListView):
) )
# Filter for non-staff users # Filter for non-staff users
if not self.request.user.is_staff: # if not self.request.user.is_staff:
queryset = queryset.filter(status='Published') # queryset = queryset.filter(status='Published')
status=self.request.GET.get('status') status = self.request.GET.get('status')
if status: if status:
queryset=queryset.filter(status=status) queryset = queryset.filter(status=status)
return queryset return queryset
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context['search_query'] = self.request.GET.get('search', '') context['search_query'] = self.request.GET.get('search', '')
context['lang'] = get_language() context['lang'] = get_language()
return context return context
class JobCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView): class JobCreateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, CreateView):
model = models.JobPosting model = models.JobPosting
form_class = forms.JobPostingForm form_class = forms.JobPostingForm
template_name = 'jobs/create_job.html' template_name = 'jobs/create_job.html'
@ -83,7 +85,7 @@ class JobCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView):
success_message = 'Job created successfully.' success_message = 'Job created successfully.'
class JobUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView): class JobUpdateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, UpdateView):
model = models.JobPosting model = models.JobPosting
form_class = forms.JobPostingForm form_class = forms.JobPostingForm
template_name = 'jobs/edit_job.html' template_name = 'jobs/edit_job.html'
@ -92,27 +94,25 @@ class JobUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView):
slug_url_kwarg = 'slug' slug_url_kwarg = 'slug'
class JobDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView): class JobDeleteView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, DeleteView):
model = models.JobPosting model = models.JobPosting
template_name = 'jobs/partials/delete_modal.html' template_name = 'jobs/partials/delete_modal.html'
success_url = reverse_lazy('job_list') success_url = reverse_lazy('job_list')
success_message = 'Job deleted successfully.' success_message = 'Job deleted successfully.'
slug_url_kwarg = 'slug' slug_url_kwarg = 'slug'
class JobCandidatesListView(LoginRequiredMixin, ListView): class JobApplicationListView(LoginRequiredMixin, StaffRequiredMixin, ListView):
model = models.Candidate model = models.Application
template_name = 'jobs/job_candidates_list.html' template_name = 'jobs/job_candidates_list.html'
context_object_name = 'candidates' context_object_name = 'applications'
paginate_by = 10 paginate_by = 10
def get_queryset(self): def get_queryset(self):
# Get the job by slug # Get the job by slug
self.job = get_object_or_404(models.JobPosting, slug=self.kwargs['slug']) self.job = get_object_or_404(models.JobPosting, slug=self.kwargs['slug'])
# Filter candidates for this specific job # 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'): if self.request.GET.get('stage'):
stage=self.request.GET.get('stage') stage=self.request.GET.get('stage')
@ -132,7 +132,7 @@ class JobCandidatesListView(LoginRequiredMixin, ListView):
# Filter for non-staff users # Filter for non-staff users
if not self.request.user.is_staff: 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') return queryset.order_by('-created_at')
@ -143,10 +143,10 @@ class JobCandidatesListView(LoginRequiredMixin, ListView):
return context return context
class CandidateListView(LoginRequiredMixin, ListView): class ApplicationListView(LoginRequiredMixin, StaffRequiredMixin, ListView):
model = models.Candidate model = models.Application
template_name = 'recruitment/candidate_list.html' template_name = 'recruitment/candidate_list.html'
context_object_name = 'candidates' context_object_name = 'applications'
paginate_by = 100 paginate_by = 100
def get_queryset(self): def get_queryset(self):
@ -156,22 +156,22 @@ class CandidateListView(LoginRequiredMixin, ListView):
search_query = self.request.GET.get('search', '') search_query = self.request.GET.get('search', '')
job = self.request.GET.get('job', '') job = self.request.GET.get('job', '')
stage = self.request.GET.get('stage', '') stage = self.request.GET.get('stage', '')
if search_query: # if search_query:
queryset = queryset.filter( # queryset = queryset.filter(
Q(first_name__icontains=search_query) | # Q(first_name__icontains=search_query) |
Q(last_name__icontains=search_query) | # Q(last_name__icontains=search_query) |
Q(email__icontains=search_query) | # Q(email__icontains=search_query) |
Q(phone__icontains=search_query) | # Q(phone__icontains=search_query) |
Q(stage__icontains=search_query) | # Q(stage__icontains=search_query) |
Q(job__title__icontains=search_query) # Q(job__title__icontains=search_query)
) # )
if job: if job:
queryset = queryset.filter(job__slug=job) queryset = queryset.filter(job__slug=job)
if stage: if stage:
queryset = queryset.filter(stage=stage) queryset = queryset.filter(stage=stage)
# Filter for non-staff users # Filter for non-staff users
if not self.request.user.is_staff: # 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') return queryset.order_by('-created_at')
@ -184,9 +184,9 @@ class CandidateListView(LoginRequiredMixin, ListView):
return context return context
class CandidateCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView): class ApplicationCreateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, CreateView):
model = models.Candidate model = models.Application
form_class = forms.CandidateForm form_class = forms.ApplicationForm
template_name = 'recruitment/candidate_create.html' template_name = 'recruitment/candidate_create.html'
success_url = reverse_lazy('candidate_list') success_url = reverse_lazy('candidate_list')
success_message = 'Candidate created successfully.' success_message = 'Candidate created successfully.'
@ -204,18 +204,23 @@ class CandidateCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView):
form.instance.job = job form.instance.job = job
return super().form_valid(form) 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): class ApplicationUpdateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, UpdateView):
model = models.Candidate model = models.Application
form_class = forms.CandidateForm form_class = forms.ApplicationForm
template_name = 'recruitment/candidate_update.html' template_name = 'recruitment/candidate_update.html'
success_url = reverse_lazy('candidate_list') success_url = reverse_lazy('candidate_list')
success_message = 'Candidate updated successfully.' success_message = 'Candidate updated successfully.'
slug_url_kwarg = 'slug' slug_url_kwarg = 'slug'
class CandidateDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView): class ApplicationDeleteView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, DeleteView):
model = models.Candidate model = models.Application
template_name = 'recruitment/candidate_delete.html' template_name = 'recruitment/candidate_delete.html'
success_url = reverse_lazy('candidate_list') success_url = reverse_lazy('candidate_list')
success_message = 'Candidate deleted successfully.' success_message = 'Candidate deleted successfully.'
@ -225,28 +230,30 @@ class CandidateDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView):
def retry_scoring_view(request,slug): def retry_scoring_view(request,slug):
from django_q.tasks import async_task 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( async_task(
'recruitment.tasks.handle_reume_parsing_and_scoring', 'recruitment.tasks.handle_reume_parsing_and_scoring',
candidate.pk, application.pk,
hook='recruitment.hooks.callback_ai_parsing', hook='recruitment.hooks.callback_ai_parsing',
sync=True, sync=True,
) )
return redirect('candidate_detail', slug=candidate.slug) return redirect('candidate_detail', slug=application.slug)
@login_required @login_required
@staff_user_required
def training_list(request): def training_list(request):
materials = models.TrainingMaterial.objects.all().order_by('-created_at') materials = models.TrainingMaterial.objects.all().order_by('-created_at')
return render(request, 'recruitment/training_list.html', {'materials': materials}) return render(request, 'recruitment/training_list.html', {'materials': materials})
@login_required @login_required
@staff_user_required
def candidate_detail(request, slug): def candidate_detail(request, slug):
from rich.json import JSON from rich.json import JSON
candidate = get_object_or_404(models.Candidate, slug=slug) candidate = get_object_or_404(models.Application, slug=slug)
try: try:
parsed = ast.literal_eval(candidate.parsed_summary) parsed = ast.literal_eval(candidate.parsed_summary)
except: except:
@ -255,9 +262,10 @@ def candidate_detail(request, slug):
# Create stage update form for staff users # Create stage update form for staff users
stage_form = None stage_form = None
if request.user.is_staff: 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(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]) # parsed = json_to_markdown_table([parsed])
return render(request, 'recruitment/candidate_detail.html', { return render(request, 'recruitment/candidate_detail.html', {
@ -268,31 +276,33 @@ def candidate_detail(request, slug):
@login_required @login_required
@staff_user_required
def candidate_resume_template_view(request, slug): def candidate_resume_template_view(request, slug):
"""Display formatted resume template for a candidate""" """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: if not request.user.is_staff:
messages.error(request, _("You don't have permission to view this page.")) messages.error(request, _("You don't have permission to view this page."))
return redirect('candidate_list') return redirect('candidate_list')
return render(request, 'recruitment/candidate_resume_template.html', { return render(request, 'recruitment/candidate_resume_template.html', {
'candidate': candidate 'application': application
}) })
@login_required @login_required
@staff_user_required
def candidate_update_stage(request, slug): def candidate_update_stage(request, slug):
"""Handle HTMX stage update requests""" """Handle HTMX stage update requests"""
candidate = get_object_or_404(models.Candidate, slug=slug) application = get_object_or_404(models.Application, slug=slug)
form = forms.CandidateStageForm(request.POST, instance=candidate) form = forms.ApplicationStageForm(request.POST, instance=application)
if form.is_valid(): if form.is_valid():
stage_value = form.cleaned_data['stage'] stage_value = form.cleaned_data['stage']
candidate.stage = stage_value application.stage = stage_value
candidate.save(update_fields=['stage']) application.save(update_fields=['stage'])
messages.success(request,"Candidate Stage Updated") messages.success(request,"application Stage Updated")
return redirect("candidate_detail",slug=candidate.slug) return redirect("candidate_detail",slug=application.slug)
class TrainingListView(LoginRequiredMixin, ListView): class TrainingListView(LoginRequiredMixin, StaffRequiredMixin, ListView):
model = models.TrainingMaterial model = models.TrainingMaterial
template_name = 'recruitment/training_list.html' template_name = 'recruitment/training_list.html'
context_object_name = 'materials' context_object_name = 'materials'
@ -320,7 +330,7 @@ class TrainingListView(LoginRequiredMixin, ListView):
return context return context
class TrainingCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView): class TrainingCreateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, CreateView):
model = models.TrainingMaterial model = models.TrainingMaterial
form_class = forms.TrainingMaterialForm form_class = forms.TrainingMaterialForm
template_name = 'recruitment/training_create.html' template_name = 'recruitment/training_create.html'
@ -332,7 +342,7 @@ class TrainingCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView):
return super().form_valid(form) return super().form_valid(form)
class TrainingUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView): class TrainingUpdateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, UpdateView):
model = models.TrainingMaterial model = models.TrainingMaterial
form_class = forms.TrainingMaterialForm form_class = forms.TrainingMaterialForm
template_name = 'recruitment/training_update.html' template_name = 'recruitment/training_update.html'
@ -341,13 +351,13 @@ class TrainingUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView):
slug_url_kwarg = 'slug' slug_url_kwarg = 'slug'
class TrainingDetailView(LoginRequiredMixin, DetailView): class TrainingDetailView(LoginRequiredMixin, StaffRequiredMixin, DetailView):
model = models.TrainingMaterial model = models.TrainingMaterial
template_name = 'recruitment/training_detail.html' template_name = 'recruitment/training_detail.html'
context_object_name = 'material' context_object_name = 'material'
slug_url_kwarg = 'slug' slug_url_kwarg = 'slug'
class TrainingDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView): class TrainingDeleteView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, DeleteView):
model = models.TrainingMaterial model = models.TrainingMaterial
template_name = 'recruitment/training_delete.html' template_name = 'recruitment/training_delete.html'
success_url = reverse_lazy('training_list') 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 # IMPORTANT: Ensure 'models' correctly refers to your Django models file
# Example: from . import models # Example: from . import models
# --- Constants --- # --- Constants ---
SCORE_PATH = 'ai_analysis_data__analysis_data__match_score' 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 @login_required
@staff_user_required
def dashboard_view(request): def dashboard_view(request):
selected_job_pk = request.GET.get('selected_job_pk') selected_job_pk = request.GET.get('selected_job_pk')
today = timezone.now().date() today = timezone.now().date()
# --- 1. BASE QUERYSETS & GLOBAL METRICS (UNFILTERED) --- # --- 1. BASE QUERYSETS & GLOBAL METRICS (UNFILTERED) ---
all_jobs_queryset = models.JobPosting.objects.all().order_by('-created_at') 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 # Global KPI Card Metrics
total_jobs_global = all_jobs_queryset.count() total_jobs_global = all_jobs_queryset.count()
total_participants = models.Participants.objects.count() total_participants = models.Participants.objects.count()
total_jobs_posted_linkedin = all_jobs_queryset.filter(linkedin_post_id__isnull=False).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) # Data for Job App Count Chart (always for ALL jobs)
job_titles = [job.title for job in all_jobs_queryset] 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 --- # --- 2. TIME SERIES: GLOBAL DAILY APPLICANTS ---
# Group ALL candidates by creation date # Group ALL candidates by creation date
global_daily_applications_qs = all_candidates_queryset.annotate( global_daily_applications_qs = all_candidates_queryset.annotate(
date=TruncDate('created_at') date=TruncDate('created_at')
@ -398,22 +409,22 @@ def dashboard_view(request):
# --- 3. FILTERING LOGIC: Determine the scope for scoped metrics --- # --- 3. FILTERING LOGIC: Determine the scope for scoped metrics ---
candidate_queryset = all_candidates_queryset candidate_queryset = all_candidates_queryset
job_scope_queryset = all_jobs_queryset job_scope_queryset = all_jobs_queryset
interview_queryset = models.ScheduledInterview.objects.all() interview_queryset = models.ScheduledInterview.objects.all()
current_job = None current_job = None
if selected_job_pk: if selected_job_pk:
# Filter all base querysets # Filter all base querysets
candidate_queryset = candidate_queryset.filter(job__pk=selected_job_pk) candidate_queryset = candidate_queryset.filter(job__pk=selected_job_pk)
interview_queryset = interview_queryset.filter(job__pk=selected_job_pk) interview_queryset = interview_queryset.filter(job__pk=selected_job_pk)
try: try:
current_job = all_jobs_queryset.get(pk=selected_job_pk) current_job = all_jobs_queryset.get(pk=selected_job_pk)
job_scope_queryset = models.JobPosting.objects.filter(pk=selected_job_pk) job_scope_queryset = models.JobPosting.objects.filter(pk=selected_job_pk)
except models.JobPosting.DoesNotExist: except models.JobPosting.DoesNotExist:
pass pass
# --- 4. TIME SERIES: SCOPED DAILY APPLICANTS --- # --- 4. TIME SERIES: SCOPED DAILY APPLICANTS ---
@ -426,15 +437,15 @@ def dashboard_view(request):
).values('date').annotate( ).values('date').annotate(
count=Count('pk') count=Count('pk')
).order_by('date') ).order_by('date')
scoped_dates = [item['date'].strftime('%Y-%m-%d') for item in scoped_daily_applications_qs] 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] scoped_counts = [item['count'] for item in scoped_daily_applications_qs]
# --- 5. SCOPED CORE AGGREGATIONS (FILTERED OR ALL) --- # --- 5. SCOPED CORE AGGREGATIONS (FILTERED OR ALL) ---
total_candidates = candidate_queryset.count() total_candidates = candidate_queryset.count()
candidates_with_score_query = candidate_queryset.filter( candidates_with_score_query = candidate_queryset.filter(
is_resume_parsed=True is_resume_parsed=True
).annotate( ).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) # A. Pipeline & Volume Metrics (Scoped)
total_active_jobs = job_scope_queryset.filter(status="ACTIVE").count() total_active_jobs = job_scope_queryset.filter(status="ACTIVE").count()
last_week = timezone.now() - timedelta(days=7) last_week = timezone.now() - timedelta(days=7)
new_candidates_7days = candidate_queryset.filter(created_at__gte=last_week).count() 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')) 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 total_open_positions = open_positions_agg['total_open'] or 0
average_applications_result = job_scope_queryset.annotate( 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'] ).aggregate(avg_apps=Avg('candidate_count'))['avg_apps']
average_applications = round(average_applications_result or 0, 2) 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] lst=[c.time_to_hire_days for c in hired_candidates]
time_to_hire_query = hired_candidates.annotate( time_to_hire_query = hired_candidates.annotate(
time_diff=ExpressionWrapper( time_diff=ExpressionWrapper(
F('hired_date') - F('created_at__date'), F('join_date') - F('created_at__date'),
output_field=fields.DurationField() output_field=fields.DurationField()
) )
).aggregate(avg_time_to_hire=Avg('time_diff')) ).aggregate(avg_time_to_hire=Avg('time_diff'))
print(time_to_hire_query)
avg_time_to_hire_days = ( 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 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() applied_count = candidate_queryset.filter(stage='Applied').count()
advanced_count = candidate_queryset.filter(stage__in=['Exam', 'Interview', 'Offer']).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 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( meetings_scheduled_this_week = interview_queryset.filter(
interview_date__week=current_week, interview_date__year=current_year interview_date__week=current_week, interview_date__year=current_year
).count() ).count()
avg_match_score_result = candidates_with_score_query.aggregate(avg_score=Avg('annotated_match_score'))['avg_score'] 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 = 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_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 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() total_scored_candidates = candidates_with_score_query.count()
@ -506,15 +543,15 @@ def dashboard_view(request):
# A. Pipeline Funnel (Scoped) # A. Pipeline Funnel (Scoped)
stage_counts = candidate_queryset.values('stage').annotate(count=Count('stage')) stage_counts = candidate_queryset.values('stage').annotate(count=Count('stage'))
stage_map = {item['stage']: item['count'] for item in stage_counts} 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 = [ 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) stage_map.get('Offer', 0), stage_map.get('Hired',0)
] ]
# --- 7. GAUGE CHART CALCULATION (Time-to-Hire) --- # --- 7. GAUGE CHART CALCULATION (Time-to-Hire) ---
current_days = avg_time_to_hire_days 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_percent = current_days / MAX_TIME_TO_HIRE_DAYS if MAX_TIME_TO_HIRE_DAYS > 0 else 0
rotation_degrees = rotation_percent * 180 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')) 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} source_map= {item['hiring_source']: item['count'] for item in hiring_source_counts}
candidates_count_in_each_source = [ 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 --- # --- 8. CONTEXT RETURN ---
context = { context = {
# Global KPIs # Global KPIs
'total_jobs_global': total_jobs_global, 'total_jobs_global': total_jobs_global,
'total_participants': total_participants, 'total_participants': total_participants,
'total_jobs_posted_linkedin': total_jobs_posted_linkedin, 'total_jobs_posted_linkedin': total_jobs_posted_linkedin,
# Scoped KPIs # Scoped KPIs
'total_active_jobs': total_active_jobs, 'total_active_jobs': total_active_jobs,
'total_candidates': total_candidates, 'total_candidates': total_candidates,
@ -549,16 +586,16 @@ def dashboard_view(request):
'offers_accepted_rate': offers_accepted_rate, 'offers_accepted_rate': offers_accepted_rate,
'vacancy_fill_rate': vacancy_fill_rate, 'vacancy_fill_rate': vacancy_fill_rate,
'meetings_scheduled_this_week': meetings_scheduled_this_week, '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_count': high_potential_count,
'high_potential_ratio': high_potential_ratio, 'high_potential_ratio': high_potential_ratio,
'scored_ratio': scored_ratio, 'scored_ratio': scored_ratio,
# Chart Data # Chart Data
'candidate_stage': json.dumps(candidate_stage), 'candidate_stage': json.dumps(candidate_stage),
'candidates_count': json.dumps(candidates_count), 'candidates_count': json.dumps(candidates_count),
'job_titles': json.dumps(job_titles), 'job_titles': json.dumps(job_titles),
'job_app_counts': json.dumps(job_app_counts), 'job_app_counts': json.dumps(job_app_counts),
# 'source_volume_chart_data' is intentionally REMOVED # 'source_volume_chart_data' is intentionally REMOVED
# Time Series Data # Time Series Data
@ -572,7 +609,7 @@ def dashboard_view(request):
'gauge_max_days': MAX_TIME_TO_HIRE_DAYS, 'gauge_max_days': MAX_TIME_TO_HIRE_DAYS,
'gauge_target_days': TARGET_TIME_TO_HIRE_DAYS, 'gauge_target_days': TARGET_TIME_TO_HIRE_DAYS,
'gauge_rotation_degrees': rotation_degrees_final, 'gauge_rotation_degrees': rotation_degrees_final,
# UI Control # UI Control
'jobs': all_jobs_queryset, 'jobs': all_jobs_queryset,
'current_job_id': selected_job_pk, '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), 'candidates_count_in_each_source': json.dumps(candidates_count_in_each_source),
'all_hiring_sources': json.dumps(all_hiring_sources), 'all_hiring_sources': json.dumps(all_hiring_sources),
} }
return render(request, 'recruitment/dashboard.html', context) return render(request, 'recruitment/dashboard.html', context)
@login_required @login_required
@staff_user_required
def candidate_offer_view(request, slug): def candidate_offer_view(request, slug):
"""View for candidates in the Offer stage""" """View for candidates in the Offer stage"""
job = get_object_or_404(models.JobPosting, slug=slug) job = get_object_or_404(models.JobPosting, slug=slug)
@ -616,6 +654,7 @@ def candidate_offer_view(request, slug):
@login_required @login_required
@staff_user_required
def candidate_hired_view(request, slug): def candidate_hired_view(request, slug):
"""View for hired candidates""" """View for hired candidates"""
job = get_object_or_404(models.JobPosting, slug=slug) job = get_object_or_404(models.JobPosting, slug=slug)
@ -645,18 +684,22 @@ def candidate_hired_view(request, slug):
@login_required @login_required
@staff_user_required
def update_candidate_status(request, job_slug, candidate_slug, stage_type, status): def update_candidate_status(request, job_slug, candidate_slug, stage_type, status):
"""Handle exam/interview/offer status updates""" """Handle exam/interview/offer status updates"""
from django.utils import timezone from django.utils import timezone
job = get_object_or_404(models.JobPosting, slug=job_slug) 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 request.method == "POST":
if stage_type == 'exam': if stage_type == 'exam':
status = request.POST.get("exam_status")
score = request.POST.get("exam_score")
candidate.exam_status = status candidate.exam_status = status
candidate.exam_score = score
candidate.exam_date = timezone.now() 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}) return render(request,'recruitment/partials/exam-results.html',{'candidate':candidate,'job':job})
elif stage_type == 'interview': elif stage_type == 'interview':
candidate.interview_status = status candidate.interview_status = status
@ -709,6 +752,7 @@ STAGE_CONFIG = {
@login_required @login_required
@staff_user_required
def export_candidates_csv(request, job_slug, stage): def export_candidates_csv(request, job_slug, stage):
"""Export candidates for a specific stage as CSV""" """Export candidates for a specific stage as CSV"""
job = get_object_or_404(models.JobPosting, slug=job_slug) 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 # Filter candidates based on stage
if stage == 'hired': if stage == 'hired':
candidates = job.candidates.filter(**config['filter']) candidates = job.applications.filter(**config['filter'])
else: else:
candidates = job.candidates.filter(**config['filter']) candidates = job.applications.filter(**config['filter'])
# Handle search if provided # Handle search if provided
search_query = request.GET.get('search', '') search_query = request.GET.get('search', '')
@ -848,6 +892,7 @@ def export_candidates_csv(request, job_slug, stage):
@login_required @login_required
@staff_user_required
def sync_hired_candidates(request, job_slug): def sync_hired_candidates(request, job_slug):
"""Sync hired candidates to external sources using Django-Q""" """Sync hired candidates to external sources using Django-Q"""
from django_q.tasks import async_task from django_q.tasks import async_task
@ -886,6 +931,7 @@ def sync_hired_candidates(request, job_slug):
@login_required @login_required
@staff_user_required
def test_source_connection(request, source_id): def test_source_connection(request, source_id):
"""Test connection to an external source""" """Test connection to an external source"""
from .candidate_sync_service import CandidateSyncService from .candidate_sync_service import CandidateSyncService
@ -920,6 +966,7 @@ def test_source_connection(request, source_id):
@login_required @login_required
@staff_user_required
def sync_task_status(request, task_id): def sync_task_status(request, task_id):
"""Check the status of a sync task""" """Check the status of a sync task"""
from django_q.models import Task from django_q.models import Task
@ -971,6 +1018,7 @@ def sync_task_status(request, task_id):
@login_required @login_required
@staff_user_required
def sync_history(request, job_slug=None): def sync_history(request, job_slug=None):
"""View sync history and logs""" """View sync history and logs"""
from .models import IntegrationLog from .models import IntegrationLog
@ -1005,7 +1053,7 @@ def sync_history(request, job_slug=None):
#participants views #participants views
class ParticipantsListView(LoginRequiredMixin, ListView): class ParticipantsListView(LoginRequiredMixin, StaffRequiredMixin, ListView):
model = models.Participants model = models.Participants
template_name = 'participants/participants_list.html' template_name = 'participants/participants_list.html'
context_object_name = 'participants' context_object_name = 'participants'
@ -1034,13 +1082,13 @@ class ParticipantsListView(LoginRequiredMixin, ListView):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context['search_query'] = self.request.GET.get('search', '') context['search_query'] = self.request.GET.get('search', '')
return context return context
class ParticipantsDetailView(LoginRequiredMixin, DetailView): class ParticipantsDetailView(LoginRequiredMixin, StaffRequiredMixin, DetailView):
model = models.Participants model = models.Participants
template_name = 'participants/participants_detail.html' template_name = 'participants/participants_detail.html'
context_object_name = 'participant' context_object_name = 'participant'
slug_url_kwarg = 'slug' slug_url_kwarg = 'slug'
class ParticipantsCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView): class ParticipantsCreateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, CreateView):
model = models.Participants model = models.Participants
form_class = forms.ParticipantsForm form_class = forms.ParticipantsForm
template_name = 'participants/participants_create.html' template_name = 'participants/participants_create.html'
@ -1054,9 +1102,9 @@ class ParticipantsCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateVie
# initial['jobs'] = [job] # initial['jobs'] = [job]
# return initial # return initial
class ParticipantsUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView): class ParticipantsUpdateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, UpdateView):
model = models.Participants model = models.Participants
form_class = forms.ParticipantsForm form_class = forms.ParticipantsForm
template_name = 'participants/participants_create.html' template_name = 'participants/participants_create.html'
@ -1064,9 +1112,9 @@ class ParticipantsUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateVie
success_message = 'Participant updated successfully.' success_message = 'Participant updated successfully.'
slug_url_kwarg = 'slug' slug_url_kwarg = 'slug'
class ParticipantsDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView): class ParticipantsDeleteView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, DeleteView):
model = models.Participants model = models.Participants
success_url = reverse_lazy('participants_list') # Redirect to the participants list after success success_url = reverse_lazy('participants_list') # Redirect to the participants list after success
success_message = 'Participant deleted successfully.' success_message = 'Participant deleted successfully.'
slug_url_kwarg = 'slug' slug_url_kwarg = 'slug'

View File

@ -1,3 +0,0 @@
from django.contrib import admin
# Register your models here.

View File

@ -1,6 +0,0 @@
from django.apps import AppConfig
class ApplicantConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'applicant'

View File

@ -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'}),
}

View File

@ -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)

View File

@ -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'],
},
),
]

View File

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

View File

@ -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">
&larr; Review Job Details
</a>
</footer>
</div>
</div>
</div>
{% endblock %}

View File

@ -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 &rarr;
</button>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@ -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)

View File

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

View File

@ -1,3 +0,0 @@
from django.test import TestCase
# Create your tests here.

View File

@ -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'),
]

View File

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

View File

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

View File

@ -119,10 +119,10 @@ def create_zoom_meeting(topic, start_time, duration, host_email):
# Step 11: Analytics Dashboard (recruitment/dashboard.py) # Step 11: Analytics Dashboard (recruitment/dashboard.py)
import pandas as pd import pandas as pd
from .models import Candidate from .models import Application
def get_dashboard_data(): 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() summary = df['status'].value_counts().to_dict()
return summary return summary

View File

@ -10,26 +10,26 @@
<div class="d-flex vh-80 w-100 justify-content-center align-items-center mt-5"> <div class="d-flex vh-80 w-100 justify-content-center align-items-center mt-5">
<div class="form-card"> <div class="form-card">
<h2 id="form-title" class="h3 fw-bold mb-4 text-center"> <h2 id="form-title" class="h3 fw-bold mb-4 text-center">
{% trans "Change Password" %} {% trans "Change Password" %}
</h2> </h2>
<p class="text-muted small mb-4 text-center"> <p class="text-muted small mb-4 text-center">
{% trans "Please enter your current password and a new password to secure your account." %} {% trans "Please enter your current password and a new password to secure your account." %}
</p> </p>
<form method="post" action="{% url 'account_change_password' %}" class="space-y-4"> <form method="post" action="{% url 'account_change_password' %}" class="space-y-4 account-password-change">
{% csrf_token %} {% csrf_token %}
{{ form|crispy }} {{ form|crispy }}
{% if form.non_field_errors %} {% if form.non_field_errors %}
<div class="alert alert-danger p-3 small mt-3" role="alert"> <div class="alert alert-danger p-3 small mt-3" role="alert">
{% for error in form.non_field_errors %}{{ error }}{% endfor %} {% for error in form.non_field_errors %}{{ error }}{% endfor %}
</div> </div>
{% endif %} {% endif %}
<button type="submit" class="btn btn-danger w-100 mt-3"> <button type="submit" class="btn btn-danger w-100 mt-3">
{% trans "Change Password" %} {% trans "Change Password" %}
</button> </button>

View File

@ -143,12 +143,12 @@
{% trans "Type" %}: {% trans "Type" %}:
{# Map the key back to its human-readable translation #} {# Map the key back to its human-readable translation #}
<strong class="mx-1"> <strong class="mx-1">
{% if selected_job_type == 'FULL_TIME' %}{% trans "Full-time" %} {% if selected_job_type == 'Full-time' %}{% trans "Full-time" %}
{% elif selected_job_type == 'PART_TIME' %}{% trans "Part-time" %} {% elif selected_job_type == 'Part-time' %}{% trans "Part-time" %}
{% elif selected_job_type == 'CONTRACT' %}{% trans "Contract" %} {% elif selected_job_type == 'Contract' %}{% trans "Contract" %}
{% elif selected_job_type == 'INTERNSHIP' %}{% trans "Internship" %} {% elif selected_job_type == 'Internship' %}{% trans "Internship" %}
{% elif selected_job_type == 'FACULTY' %}{% trans "Faculty" %} {% elif selected_job_type == 'Faculty' %}{% trans "Faculty" %}
{% elif selected_job_type == 'TEMPORARY' %}{% trans "Temporary" %} {% elif selected_job_type == 'Temporary' %}{% trans "Temporary" %}
{% endif %} {% endif %}
</strong> </strong>
{# Link to clear this specific filter: use current URL but remove `employment_type` parameter #} {# Link to clear this specific filter: use current URL but remove `employment_type` parameter #}
@ -159,15 +159,15 @@
</span> </span>
{% endif %} {% endif %}
{# --- Active Workplace Type Filter Chip --- #} {# --- Active Workplace Type Filter Chip --- #}
{% if selected_workplace_type %} {% if selected_workplace_type %}
<span class="filter-chip badge bg-primary-theme-subtle text-primary-theme fw-normal p-2 active-filter-chip"> <span class="filter-chip badge bg-primary-theme-subtle text-primary-theme fw-normal p-2 active-filter-chip">
{% trans "Workplace" %}: {% trans "Workplace" %}:
{# Map the key back to its human-readable translation #} {# Map the key back to its human-readable translation #}
<strong class="mx-1"> <strong class="mx-1">
{% if selected_workplace_type == 'ON_SITE' %}{% trans "On-site" %} {% if selected_workplace_type == 'On-site' %}{% trans "On-site" %}
{% elif selected_workplace_type == 'REMOTE' %}{% trans "Remote" %} {% elif selected_workplace_type == 'Remote' %}{% trans "Remote" %}
{% elif selected_workplace_type == 'HYBRID' %}{% trans "Hybrid" %} {% elif selected_workplace_type == 'Hybrid' %}{% trans "Hybrid" %}
{% endif %} {% endif %}
</strong> </strong>
{# Link to clear this specific filter: use current URL but remove `workplace_type` parameter #} {# Link to clear this specific filter: use current URL but remove `workplace_type` parameter #}

View File

@ -122,6 +122,11 @@
</ul> </ul>
</li> </li>
{% endif %} {% endcomment %} {% 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"> <li class="nav-item dropdown">
<button <button
@ -133,8 +138,8 @@
data-bs-auto-close="outside" data-bs-auto-close="outside"
data-bs-offset="0, 16" {# Vertical offset remains 16px to prevent clipping #} data-bs-offset="0, 16" {# Vertical offset remains 16px to prevent clipping #}
> >
{% if user.profile and user.profile.profile_image %} {% if user.profile_image %}
<img src="{{ user.profile.profile_image.url }}" alt="{{ user.username }}" class="profile-avatar" <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;" style="width: 36px; height: 36px; object-fit: cover; background-color: var(--kaauh-teal); display: inline-block; vertical-align: middle;"
title="{% trans 'Your account' %}"> title="{% trans 'Your account' %}">
{% else %} {% else %}
@ -151,8 +156,8 @@
<li class="px-4 py-3 "> <li class="px-4 py-3 ">
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<div class="me-3 d-flex align-items-center justify-content-center" style="min-width: 48px;"> <div class="me-3 d-flex align-items-center justify-content-center" style="min-width: 48px;">
{% if user.profile and user.profile.profile_image %} {% if user.profile_image %}
<img src="{{ user.profile.profile_image.url }}" alt="{{ user.username }}" class="profile-avatar shadow-sm border" <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;" style="width: 44px; height: 44px; object-fit: cover; background-color: var(--kaauh-teal); display: block;"
title="{% trans 'Your account' %}"> title="{% trans 'Your account' %}">
{% else %} {% else %}
@ -208,7 +213,7 @@
<i class="fas fa-sign-out-alt me-3 fs-5 " style="color:red;"></i> <i class="fas fa-sign-out-alt me-3 fs-5 " style="color:red;"></i>
<span style="color:red;">{% trans "Sign Out" %}</span> <span style="color:red;">{% trans "Sign Out" %}</span>
</button> </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" %}> {% 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> <i class="fas fa-sign-out-alt me-3 fs-5 " style="color:red;"></i>
<span style="color:red;">{% trans "Sign Out" %}</span> <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' %}"> <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"> <span class="d-flex align-items-center gap-2">
{% include "icons/users.html" %} {% 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> </span>
</a> </a>
</li> </li>
@ -312,7 +325,7 @@
</div> </div>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{% block content %} {% block content %}
{% endblock %} {% endblock %}
</main> </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/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 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 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> <script>
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
// Navbar collapse auto-close on link click (Standard Mobile UX) // Navbar collapse auto-close on link click (Standard Mobile UX)
@ -404,6 +418,23 @@
</script> </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 --> <!-- Notification JavaScript for Admin Users -->
{% comment %} {% if request.user.is_authenticated and request.user.is_staff %} {% comment %} {% if request.user.is_authenticated and request.user.is_staff %}
<script> <script>

View File

@ -1,10 +1,34 @@
{% load i18n %} {% 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(); }"> 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"> <div class="d-flex justify-content-center align-items-center gap-2">
<i class="fas fa-check me-1"></i> {% trans "Passed" %} <div class="form-check d-flex align-items-center gap-2">
</a> <input class="form-check-input" type="radio" name="exam_status" id="exam_passed" value="Passed" {% if candidate.exam_status == 'Passed' %}checked{% endif %}>
<a hx-post="{% url 'update_candidate_status' job.slug candidate.slug 'exam' 'Failed' %}" class="btn btn-danger"> <label class="form-check-label" for="exam_passed">
<i class="fas fa-times me-1"></i> {% trans "Failed" %} <i class="fas fa-check me-1"></i> {% trans "Passed" %}
</a> </label>
</div> </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>

View File

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

View File

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

View File

@ -11,6 +11,7 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{{interviews}}
<div class="container-fluid py-4"> <div class="container-fluid py-4">
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h1 style="color: var(--kaauh-teal-dark); font-weight: 700;"> <h1 style="color: var(--kaauh-teal-dark); font-weight: 700;">

View File

@ -9,7 +9,7 @@
<h3 class="text-center font-weight-light my-4">Set Interview Location</h3> <h3 class="text-center font-weight-light my-4">Set Interview Location</h3>
</div> </div>
<div class="card-body"> <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 %} {% csrf_token %}
{# Renders the single 'location' field using the crispy filter #} {# Renders the single 'location' field using the crispy filter #}

View File

@ -1,5 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load static %} {% load static crispy_forms_tags %}
{%load i18n %} {%load i18n %}
{% block customCSS %} {% block customCSS %}
@ -65,7 +65,7 @@
padding: 10px 15px; padding: 10px 15px;
background-color: var(--kaauh-teal-light); background-color: var(--kaauh-teal-light);
} }
/* FullCalendar Customization */ /* FullCalendar Customization */
#calendar { #calendar {
font-size: 0.9em; font-size: 0.9em;
@ -87,7 +87,7 @@
{% block content %} {% block content %}
<div class="container py-5"> <div class="container py-5">
<div class="d-flex justify-content-between align-items-center mb-5"> <div class="d-flex justify-content-between align-items-center mb-5">
<h1 class="h3 page-header"> <h1 class="h3 page-header">
<i class="fas fa-calendar-alt me-2 text-primary-theme"></i> Interview Schedule Preview: **{{ job.title }}** <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"> <div class="card-body p-4 p-lg-5">
<h4 class="card-title-border">{% trans "Schedule Parameters" %}</h4> <h4 class="card-title-border">{% trans "Schedule Parameters" %}</h4>
<div class="row g-4"> <div class="row g-4">
<div class="col-md-6"> <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-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-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> <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>
<div class="col-md-6"> <div class="col-md-6">
<p class="mb-2"><strong><i class="fas fa-calendar-day me-2 text-primary-theme"></i> Interview Period:</strong> {{ start_date|date:"F j, Y" }} &mdash; {{ end_date|date:"F j, Y" }}</p> <p class="mb-2"><strong><i class="fas fa-calendar-day me-2 text-primary-theme"></i> Interview Period:</strong> {{ start_date|date:"F j, Y" }} &mdash; {{ end_date|date:"F j, Y" }}</p>
<p class="mb-2"><strong><i class="fas fa-list-check me-2 text-primary-theme"></i> Active Days:</strong> <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 %} {% if not forloop.last %}, {% endif %}
{% endfor %} {% endfor %}
</p> </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>
</div> </div>
<h5 class="mt-4 pt-3 border-top">{% trans "Daily Break Times" %}</h5> <h5 class="mt-4 pt-3 border-top">{% trans "Daily Break Times" %}</h5>
{% if breaks %} {% if breaks %}
<div class="d-flex flex-wrap gap-3 mt-3"> <div class="d-flex flex-wrap gap-3 mt-3">
@ -162,24 +162,58 @@
{% for item in schedule %} {% for item in schedule %}
<tr> <tr>
<td>{{ item.date|date:"F j, Y" }}</td> <td>{{ item.date|date:"F j, Y" }}</td>
<td class="fw-bold text-primary-theme">{{ item.time|time:"g:i A" }}</td> <td>{{ item.time|time:"g:i A" }}</td>
<td>{{ item.candidate.name }}</td> <td>{{ item.application.name }}</td>
<td>{{ item.candidate.email }}</td> <td>{{ item.application.email }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
{% if schedule_interview_type == "Onsite" %}
<form method="post" action="{% url 'confirm_schedule_interviews_view' slug=job.slug %}" class="mt-4 d-flex justify-content-end gap-3"> <button type="submit" name="confirm_schedule" class="btn btn-teal-primary px-4" data-bs-toggle="modal" data-bs-target="#interviewDetailsModal" data-placement="top">
{% 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" %} <i class="fas fa-check me-2"></i> {% trans "Confirm Schedule" %}
</button> </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> </div>
</div> </div>
@ -200,13 +234,13 @@ document.addEventListener('DOMContentLoaded', function() {
events: [ events: [
{% for item in schedule %} {% 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" }}', start: '{{ item.date|date:"Y-m-d" }}T{{ item.time|time:"H:i:s" }}',
url: '#', url: '#',
// Use the theme color for candidate events // Use the theme color for candidate events
color: 'var(--kaauh-teal-dark)', color: 'var(--kaauh-teal-dark)',
extendedProps: { extendedProps: {
email: '{{ item.candidate.email }}', email: '{{ item.application.email }}',
time: '{{ item.time|time:"g:i A" }}' time: '{{ item.time|time:"g:i A" }}'
} }
}, },
@ -214,7 +248,7 @@ document.addEventListener('DOMContentLoaded', function() {
{% for break in breaks %} {% for break in breaks %}
{ {
title: 'Break', 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. // 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" }}', 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" }}', end: '{{ start_date|date:"Y-m-d" }}T{{ break.end_time|time:"H:i:s" }}',

View File

@ -130,9 +130,9 @@
<label for="{{ form.candidates.id_for_label }}"> <label for="{{ form.candidates.id_for_label }}">
{% trans "Candidates to Schedule (Hold Ctrl/Cmd to select multiple)" %} {% trans "Candidates to Schedule (Hold Ctrl/Cmd to select multiple)" %}
</label> </label>
{{ form.candidates }} {{ form.applications }}
{% if form.candidates.errors %} {% if form.applications.errors %}
<div class="text-danger small mt-1">{{ form.candidates.errors }}</div> <div class="text-danger small mt-1">{{ form.applications.errors }}</div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
@ -142,8 +142,8 @@
<div class="row"> <div class="row">
<div class="col-md-12"> <div class="col-md-12">
<div class="form-group mb-3"> <div class="form-group mb-3">
<label for="{{ form.start_date.id_for_label }}">{% trans "Interview Type" %}</label> <label for="{{ form.schedule_interview_type.id_for_label }}">{% trans "Interview Type" %}</label>
{{ form.interview_type }} {{ form.schedule_interview_type }}
</div> </div>
</div> </div>

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