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. `/`
# character), then remove the file in the remaining pattern string and all
# files with the same name in subdirectories.
db.sqlite3
db.sqlite3
.opencode
openspec
AGENTS.md

View File

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

View File

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

View File

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

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.utils import timezone
from .models import (
JobPosting, Candidate, TrainingMaterial, ZoomMeeting,
JobPosting, Application, TrainingMaterial, ZoomMeetingDetails,
FormTemplate, FormStage, FormField, FormSubmission, FieldResponse,
SharedFormTemplate, Source, HiringAgency, IntegrationLog,InterviewSchedule,Profile,JobPostingImage,MeetingComment,
SharedFormTemplate, Source, HiringAgency, IntegrationLog,InterviewSchedule,JobPostingImage,InterviewNote,
AgencyAccessLink, AgencyJobAssignment
)
from django.contrib.auth import get_user_model
User = get_user_model()
class FormFieldInline(admin.TabularInline):
model = FormField
extra = 1
@ -82,17 +84,10 @@ class HiringAgencyAdmin(admin.ModelAdmin):
readonly_fields = ['slug', 'created_at', 'updated_at']
fieldsets = (
('Basic Information', {
'fields': ('name', 'slug', 'contact_person', 'email', 'phone', 'website')
}),
('Location Details', {
'fields': ('country', 'city', 'address')
}),
('Additional Information', {
'fields': ('description', 'created_at', 'updated_at')
'fields': ('name','contact_person', 'email', 'phone', 'website','user')
}),
)
save_on_top = True
prepopulated_fields = {'slug': ('name',)}
@admin.register(JobPosting)
@ -143,43 +138,6 @@ class JobPostingAdmin(admin.ModelAdmin):
mark_as_closed.short_description = 'Mark selected jobs as closed'
@admin.register(Candidate)
class CandidateAdmin(admin.ModelAdmin):
list_display = ['full_name', 'job', 'email', 'phone', 'stage', 'applied','is_resume_parsed', 'created_at']
list_filter = ['stage', 'applied', 'created_at', 'job__department']
search_fields = ['first_name', 'last_name', 'email', 'phone']
readonly_fields = ['slug', 'created_at', 'updated_at']
fieldsets = (
('Personal Information', {
'fields': ('first_name', 'last_name', 'email', 'phone', 'resume')
}),
('Application Details', {
'fields': ('job', 'applied', 'stage','is_resume_parsed')
}),
('Interview Process', {
'fields': ('exam_date', 'exam_status', 'interview_date', 'interview_status', 'offer_date', 'offer_status', 'join_date')
}),
('Scoring', {
'fields': ('ai_analysis_data',)
}),
('Additional Information', {
'fields': ('submitted_by_agency', 'created_at', 'updated_at')
}),
)
save_on_top = True
actions = ['mark_as_applied', 'mark_as_not_applied']
def mark_as_applied(self, request, queryset):
updated = queryset.update(applied=True)
self.message_user(request, f'{updated} candidates marked as applied.')
mark_as_applied.short_description = 'Mark selected candidates as applied'
def mark_as_not_applied(self, request, queryset):
updated = queryset.update(applied=False)
self.message_user(request, f'{updated} candidates marked as not applied.')
mark_as_not_applied.short_description = 'Mark selected candidates as not applied'
@admin.register(TrainingMaterial)
class TrainingMaterialAdmin(admin.ModelAdmin):
list_display = ['title', 'created_by', 'created_at']
@ -200,7 +158,7 @@ class TrainingMaterialAdmin(admin.ModelAdmin):
save_on_top = True
@admin.register(ZoomMeeting)
@admin.register(ZoomMeetingDetails)
class ZoomMeetingAdmin(admin.ModelAdmin):
list_display = ['topic', 'meeting_id', 'start_time', 'duration', 'created_at']
list_filter = ['timezone', 'created_at']
@ -223,24 +181,24 @@ class ZoomMeetingAdmin(admin.ModelAdmin):
save_on_top = True
@admin.register(MeetingComment)
class MeetingCommentAdmin(admin.ModelAdmin):
list_display = ['meeting', 'author', 'created_at', 'updated_at']
list_filter = ['created_at', 'author', 'meeting']
search_fields = ['content', 'meeting__topic', 'author__username']
readonly_fields = ['created_at', 'updated_at', 'slug']
fieldsets = (
('Meeting Information', {
'fields': ('meeting', 'author')
}),
('Comment Content', {
'fields': ('content',)
}),
('Timestamps', {
'fields': ('created_at', 'updated_at', 'slug')
}),
)
save_on_top = True
# @admin.register(InterviewNote)
# class MeetingCommentAdmin(admin.ModelAdmin):
# list_display = ['meeting', 'author', 'created_at', 'updated_at']
# list_filter = ['created_at', 'author', 'meeting']
# search_fields = ['content', 'meeting__topic', 'author__username']
# readonly_fields = ['created_at', 'updated_at', 'slug']
# fieldsets = (
# ('Meeting Information', {
# 'fields': ('meeting', 'author')
# }),
# ('Comment Content', {
# 'fields': ('content',)
# }),
# ('Timestamps', {
# 'fields': ('created_at', 'updated_at', 'slug')
# }),
# )
# save_on_top = True
@admin.register(FormTemplate)
@ -280,13 +238,14 @@ class FormSubmissionAdmin(admin.ModelAdmin):
# Register other models
admin.site.register(FormStage)
admin.site.register(Application)
admin.site.register(FormField)
admin.site.register(FieldResponse)
admin.site.register(InterviewSchedule)
admin.site.register(Profile)
admin.site.register(AgencyAccessLink)
admin.site.register(AgencyJobAssignment)
# AgencyMessage admin removed - model has been deleted
admin.site.register(JobPostingImage)
admin.site.register(User)

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

View File

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

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
def callback_ai_parsing(task):
if task.success:
try:
pk = task.args[0]
c = Candidate.objects.get(pk=pk)
c = Application.objects.get(pk=pk)
if c.retry and not c.is_resume_parsed:
sleep(30)
c.retry -= 1

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.db.models.deletion
import django.utils.timezone
import django_ckeditor_5.fields
import django_countries.fields
import django_extensions.db.fields
@ -15,7 +18,8 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('auth', '0012_alter_user_first_name_max_length'),
('contenttypes', '0002_remove_content_type_name'),
]
operations = [
@ -45,25 +49,20 @@ class Migration(migrations.Migration):
},
),
migrations.CreateModel(
name='HiringAgency',
name='InterviewLocation',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('name', models.CharField(max_length=200, unique=True, verbose_name='Agency Name')),
('contact_person', models.CharField(blank=True, max_length=150, verbose_name='Contact Person')),
('email', models.EmailField(blank=True, max_length=254)),
('phone', models.CharField(blank=True, max_length=20)),
('website', models.URLField(blank=True)),
('notes', models.TextField(blank=True, help_text='Internal notes about the agency')),
('country', django_countries.fields.CountryField(blank=True, max_length=2, null=True)),
('address', models.TextField(blank=True, null=True)),
('location_type', models.CharField(choices=[('Remote', 'Remote (e.g., Zoom, Google Meet)'), ('Onsite', 'In-Person (Physical Location)')], db_index=True, max_length=10, verbose_name='Location Type')),
('details_url', models.URLField(blank=True, max_length=2048, null=True, verbose_name='Meeting/Location URL')),
('topic', models.CharField(blank=True, help_text="e.g., 'Zoom Topic: Software Interview' or 'Main Conference Room, 3rd Floor'", max_length=255, verbose_name='Location/Meeting Topic')),
('timezone', models.CharField(default='UTC', max_length=50, verbose_name='Timezone')),
],
options={
'verbose_name': 'Hiring Agency',
'verbose_name_plural': 'Hiring Agencies',
'ordering': ['name'],
'verbose_name': 'Interview Location',
'verbose_name_plural': 'Interview Locations',
},
),
migrations.CreateModel(
@ -113,29 +112,33 @@ class Migration(migrations.Migration):
},
),
migrations.CreateModel(
name='ZoomMeeting',
name='CustomUser',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('topic', models.CharField(max_length=255, verbose_name='Topic')),
('meeting_id', models.CharField(db_index=True, max_length=20, unique=True, verbose_name='Meeting ID')),
('start_time', models.DateTimeField(db_index=True, verbose_name='Start Time')),
('duration', models.PositiveIntegerField(verbose_name='Duration')),
('timezone', models.CharField(max_length=50, verbose_name='Timezone')),
('join_url', models.URLField(verbose_name='Join URL')),
('participant_video', models.BooleanField(default=True, verbose_name='Participant Video')),
('password', models.CharField(blank=True, max_length=20, null=True, verbose_name='Password')),
('join_before_host', models.BooleanField(default=False, verbose_name='Join Before Host')),
('mute_upon_entry', models.BooleanField(default=False, verbose_name='Mute Upon Entry')),
('waiting_room', models.BooleanField(default=False, verbose_name='Waiting Room')),
('zoom_gateway_response', models.JSONField(blank=True, null=True, verbose_name='Zoom Gateway Response')),
('status', models.CharField(blank=True, db_index=True, default='waiting', max_length=20, null=True, verbose_name='Status')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('user_type', models.CharField(choices=[('staff', 'Staff'), ('agency', 'Agency'), ('candidate', 'Candidate')], default='staff', max_length=20, verbose_name='User Type')),
('phone', models.CharField(blank=True, max_length=20, null=True, verbose_name='Phone')),
('profile_image', models.ImageField(blank=True, null=True, upload_to='profile_pic/', validators=[recruitment.validators.validate_image_size], verbose_name='Profile Image')),
('designation', models.CharField(blank=True, max_length=100, null=True, verbose_name='Designation')),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
'abstract': False,
'verbose_name': 'User',
'verbose_name_plural': 'Users',
},
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
migrations.CreateModel(
name='FormField',
@ -206,42 +209,100 @@ class Migration(migrations.Migration):
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stages', to='recruitment.formtemplate'),
),
migrations.CreateModel(
name='Candidate',
name='HiringAgency',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('first_name', models.CharField(max_length=255, verbose_name='First Name')),
('last_name', models.CharField(max_length=255, verbose_name='Last Name')),
('email', models.EmailField(db_index=True, max_length=254, verbose_name='Email')),
('phone', models.CharField(max_length=20, verbose_name='Phone')),
('address', models.TextField(max_length=200, verbose_name='Address')),
('resume', models.FileField(upload_to='resumes/', verbose_name='Resume')),
('is_resume_parsed', models.BooleanField(default=False, verbose_name='Resume Parsed')),
('is_potential_candidate', models.BooleanField(default=False, verbose_name='Potential Candidate')),
('parsed_summary', models.TextField(blank=True, verbose_name='Parsed Summary')),
('applied', models.BooleanField(default=False, verbose_name='Applied')),
('stage', models.CharField(choices=[('Applied', 'Applied'), ('Exam', 'Exam'), ('Interview', 'Interview'), ('Offer', 'Offer'), ('Hired', 'Hired')], db_index=True, default='Applied', max_length=100, verbose_name='Stage')),
('applicant_status', models.CharField(blank=True, choices=[('Applicant', 'Applicant'), ('Candidate', 'Candidate')], default='Applicant', max_length=100, null=True, verbose_name='Applicant Status')),
('exam_date', models.DateTimeField(blank=True, null=True, verbose_name='Exam Date')),
('exam_status', models.CharField(blank=True, choices=[('Passed', 'Passed'), ('Failed', 'Failed')], max_length=100, null=True, verbose_name='Exam Status')),
('interview_date', models.DateTimeField(blank=True, null=True, verbose_name='Interview Date')),
('interview_status', models.CharField(blank=True, choices=[('Passed', 'Passed'), ('Failed', 'Failed')], max_length=100, null=True, verbose_name='Interview Status')),
('offer_date', models.DateField(blank=True, null=True, verbose_name='Offer Date')),
('offer_status', models.CharField(blank=True, choices=[('Accepted', 'Accepted'), ('Rejected', 'Rejected')], max_length=100, null=True, verbose_name='Offer Status')),
('hired_date', models.DateField(blank=True, null=True, verbose_name='Hired Date')),
('join_date', models.DateField(blank=True, null=True, verbose_name='Join Date')),
('ai_analysis_data', models.JSONField(default=dict, help_text='Full JSON output from the resume scoring model.', verbose_name='AI Analysis Data')),
('retry', models.SmallIntegerField(default=3, verbose_name='Resume Parsing Retry')),
('hiring_source', models.CharField(blank=True, choices=[('Public', 'Public'), ('Internal', 'Internal'), ('Agency', 'Agency')], default='Public', max_length=255, null=True, verbose_name='Hiring Source')),
('hiring_agency', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='candidates', to='recruitment.hiringagency', verbose_name='Hiring Agency')),
('name', models.CharField(max_length=200, unique=True, verbose_name='Agency Name')),
('contact_person', models.CharField(blank=True, max_length=150, verbose_name='Contact Person')),
('email', models.EmailField(blank=True, max_length=254)),
('phone', models.CharField(blank=True, max_length=20)),
('website', models.URLField(blank=True)),
('notes', models.TextField(blank=True, help_text='Internal notes about the agency')),
('country', django_countries.fields.CountryField(blank=True, max_length=2, null=True)),
('address', models.TextField(blank=True, null=True)),
('generated_password', models.CharField(blank=True, help_text='Generated password for agency user account', max_length=255, null=True)),
('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='agency_profile', to=settings.AUTH_USER_MODEL, verbose_name='User')),
],
options={
'verbose_name': 'Candidate',
'verbose_name_plural': 'Candidates',
'verbose_name': 'Hiring Agency',
'verbose_name_plural': 'Hiring Agencies',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Application',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('resume', models.FileField(upload_to='resumes/', verbose_name='Resume')),
('cover_letter', models.FileField(blank=True, null=True, upload_to='cover_letters/', verbose_name='Cover Letter')),
('is_resume_parsed', models.BooleanField(default=False, verbose_name='Resume Parsed')),
('parsed_summary', models.TextField(blank=True, verbose_name='Parsed Summary')),
('applied', models.BooleanField(default=False, verbose_name='Applied')),
('stage', models.CharField(choices=[('Applied', 'Applied'), ('Exam', 'Exam'), ('Interview', 'Interview'), ('Document Review', 'Document Review'), ('Offer', 'Offer'), ('Hired', 'Hired'), ('Rejected', 'Rejected')], db_index=True, default='Applied', max_length=20, verbose_name='Stage')),
('applicant_status', models.CharField(blank=True, choices=[('Applicant', 'Applicant'), ('Candidate', 'Candidate')], default='Applicant', max_length=20, null=True, verbose_name='Applicant Status')),
('exam_date', models.DateTimeField(blank=True, null=True, verbose_name='Exam Date')),
('exam_status', models.CharField(blank=True, choices=[('Passed', 'Passed'), ('Failed', 'Failed')], max_length=20, null=True, verbose_name='Exam Status')),
('exam_score', models.FloatField(blank=True, null=True, verbose_name='Exam Score')),
('interview_date', models.DateTimeField(blank=True, null=True, verbose_name='Interview Date')),
('interview_status', models.CharField(blank=True, choices=[('Passed', 'Passed'), ('Failed', 'Failed')], max_length=20, null=True, verbose_name='Interview Status')),
('offer_date', models.DateField(blank=True, null=True, verbose_name='Offer Date')),
('offer_status', models.CharField(blank=True, choices=[('Accepted', 'Accepted'), ('Rejected', 'Rejected'), ('Pending', 'Pending')], max_length=20, null=True, verbose_name='Offer Status')),
('hired_date', models.DateField(blank=True, null=True, verbose_name='Hired Date')),
('join_date', models.DateField(blank=True, null=True, verbose_name='Join Date')),
('ai_analysis_data', models.JSONField(blank=True, default=dict, help_text='Full JSON output from the resume scoring model.', null=True, verbose_name='AI Analysis Data')),
('retry', models.SmallIntegerField(default=3, verbose_name='Resume Parsing Retry')),
('hiring_source', models.CharField(blank=True, choices=[('Public', 'Public'), ('Internal', 'Internal'), ('Agency', 'Agency')], default='Public', max_length=255, null=True, verbose_name='Hiring Source')),
('hiring_agency', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='applications', to='recruitment.hiringagency', verbose_name='Hiring Agency')),
],
options={
'verbose_name': 'Application',
'verbose_name_plural': 'Applications',
},
),
migrations.CreateModel(
name='OnsiteLocationDetails',
fields=[
('interviewlocation_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='recruitment.interviewlocation')),
('physical_address', models.CharField(blank=True, max_length=255, null=True, verbose_name='Physical Address')),
('room_number', models.CharField(blank=True, max_length=50, null=True, verbose_name='Room Number/Name')),
('start_time', models.DateTimeField(db_index=True, verbose_name='Start Time')),
('duration', models.PositiveIntegerField(verbose_name='Duration (minutes)')),
('status', models.CharField(choices=[('waiting', 'Waiting'), ('started', 'Started'), ('ended', 'Ended'), ('cancelled', 'Cancelled')], db_index=True, default='waiting', max_length=20)),
],
options={
'verbose_name': 'Onsite Location Details',
'verbose_name_plural': 'Onsite Location Details',
},
bases=('recruitment.interviewlocation',),
),
migrations.CreateModel(
name='ZoomMeetingDetails',
fields=[
('interviewlocation_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='recruitment.interviewlocation')),
('status', models.CharField(choices=[('waiting', 'Waiting'), ('started', 'Started'), ('ended', 'Ended'), ('cancelled', 'Cancelled')], db_index=True, default='waiting', max_length=20)),
('start_time', models.DateTimeField(db_index=True, verbose_name='Start Time')),
('duration', models.PositiveIntegerField(verbose_name='Duration (minutes)')),
('meeting_id', models.CharField(db_index=True, max_length=50, unique=True, verbose_name='External Meeting ID')),
('password', models.CharField(blank=True, max_length=20, null=True, verbose_name='Password')),
('zoom_gateway_response', models.JSONField(blank=True, null=True, verbose_name='Zoom Gateway Response')),
('participant_video', models.BooleanField(default=True, verbose_name='Participant Video')),
('join_before_host', models.BooleanField(default=False, verbose_name='Join Before Host')),
('host_email', models.CharField(blank=True, null=True)),
('mute_upon_entry', models.BooleanField(default=False, verbose_name='Mute Upon Entry')),
('waiting_room', models.BooleanField(default=False, verbose_name='Waiting Room')),
],
options={
'verbose_name': 'Zoom Meeting Details',
'verbose_name_plural': 'Zoom Meeting Details',
},
bases=('recruitment.interviewlocation',),
),
migrations.CreateModel(
name='JobPosting',
fields=[
@ -251,8 +312,8 @@ class Migration(migrations.Migration):
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('title', models.CharField(max_length=200)),
('department', models.CharField(blank=True, max_length=100)),
('job_type', models.CharField(choices=[('FULL_TIME', 'Full-time'), ('PART_TIME', 'Part-time'), ('CONTRACT', 'Contract'), ('INTERNSHIP', 'Internship'), ('FACULTY', 'Faculty'), ('TEMPORARY', 'Temporary')], default='FULL_TIME', max_length=20)),
('workplace_type', models.CharField(choices=[('ON_SITE', 'On-site'), ('REMOTE', 'Remote'), ('HYBRID', 'Hybrid')], default='ON_SITE', max_length=20)),
('job_type', models.CharField(choices=[('Full-time', 'Full-time'), ('Part-time', 'Part-time'), ('Contract', 'Contract'), ('Internship', 'Internship'), ('Faculty', 'Faculty'), ('Temporary', 'Temporary')], default='FULL_TIME', max_length=20)),
('workplace_type', models.CharField(choices=[('On-site', 'On-site'), ('Remote', 'Remote'), ('Hybrid', 'Hybrid')], default='ON_SITE', max_length=20)),
('location_city', models.CharField(blank=True, max_length=100)),
('location_state', models.CharField(blank=True, max_length=100)),
('location_country', models.CharField(default='Saudia Arabia', max_length=100)),
@ -281,9 +342,9 @@ class Migration(migrations.Migration):
('cancel_reason', models.TextField(blank=True, help_text='Reason for canceling the job posting', verbose_name='Cancel Reason')),
('cancelled_by', models.CharField(blank=True, help_text='Name of person who cancelled this job', max_length=100, verbose_name='Cancelled By')),
('cancelled_at', models.DateTimeField(blank=True, null=True)),
('ai_parsed', models.BooleanField(default=False, help_text='Whether the job posting has been parsed by AI', verbose_name='AI Parsed')),
('assigned_to', models.ForeignKey(blank=True, help_text='The user who has been assigned to this job', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_jobs', to=settings.AUTH_USER_MODEL, verbose_name='Assigned To')),
('hiring_agency', models.ManyToManyField(blank=True, help_text='External agency responsible for sourcing candidates for this role', related_name='jobs', to='recruitment.hiringagency', verbose_name='Hiring Agency')),
('users', models.ManyToManyField(blank=True, help_text='Internal staff involved in the recruitment process for this job', related_name='jobs_assigned', to=settings.AUTH_USER_MODEL, verbose_name='Internal Participant')),
('participants', models.ManyToManyField(blank=True, help_text='External participants involved in the recruitment process for this job', related_name='jobs_participating', to='recruitment.participants', verbose_name='External Participant')),
('source', models.ForeignKey(blank=True, help_text='The system or channel from which this job posting originated or was first published.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='job_postings', to='recruitment.source')),
],
options={
@ -299,6 +360,7 @@ class Migration(migrations.Migration):
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('schedule_interview_type', models.CharField(choices=[('Remote', 'Remote (e.g., Zoom, Google Meet)'), ('Onsite', 'In-Person (Physical Location)')], default='Remote', max_length=10, verbose_name='Interview Type')),
('start_date', models.DateField(db_index=True, verbose_name='Start Date')),
('end_date', models.DateField(db_index=True, verbose_name='End Date')),
('working_days', models.JSONField(verbose_name='Working Days')),
@ -308,10 +370,14 @@ class Migration(migrations.Migration):
('break_end_time', models.TimeField(blank=True, null=True, verbose_name='Break End Time')),
('interview_duration', models.PositiveIntegerField(verbose_name='Interview Duration (minutes)')),
('buffer_time', models.PositiveIntegerField(default=0, verbose_name='Buffer Time (minutes)')),
('candidates', models.ManyToManyField(blank=True, null=True, related_name='interview_schedules', to='recruitment.candidate')),
('applications', models.ManyToManyField(blank=True, related_name='interview_schedules', to='recruitment.application')),
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
('template_location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='schedule_templates', to='recruitment.interviewlocation', verbose_name='Location Template (Zoom/Onsite)')),
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interview_schedules', to='recruitment.jobposting')),
],
options={
'abstract': False,
},
),
migrations.AddField(
model_name='formtemplate',
@ -319,9 +385,9 @@ class Migration(migrations.Migration):
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='form_template', to='recruitment.jobposting'),
),
migrations.AddField(
model_name='candidate',
model_name='application',
name='job',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='candidates', to='recruitment.jobposting', verbose_name='Job'),
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='applications', to='recruitment.jobposting', verbose_name='Job'),
),
migrations.CreateModel(
name='AgencyJobAssignment',
@ -357,14 +423,114 @@ class Migration(migrations.Migration):
],
),
migrations.CreateModel(
name='Profile',
name='Message',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('profile_image', models.ImageField(blank=True, null=True, upload_to='profile_pic/', validators=[recruitment.validators.validate_image_size])),
('designation', models.CharField(blank=True, max_length=100, null=True)),
('phone', models.CharField(blank=True, max_length=12, null=True, verbose_name='Phone Number')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('subject', models.CharField(max_length=200, verbose_name='Subject')),
('content', models.TextField(verbose_name='Message Content')),
('message_type', models.CharField(choices=[('direct', 'Direct Message'), ('job_related', 'Job Related'), ('system', 'System Notification')], default='direct', max_length=20, verbose_name='Message Type')),
('is_read', models.BooleanField(default=False, verbose_name='Is Read')),
('read_at', models.DateTimeField(blank=True, null=True, verbose_name='Read At')),
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='recruitment.jobposting', verbose_name='Related Job')),
('recipient', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='received_messages', to=settings.AUTH_USER_MODEL, verbose_name='Recipient')),
('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_messages', to=settings.AUTH_USER_MODEL, verbose_name='Sender')),
],
options={
'verbose_name': 'Message',
'verbose_name_plural': 'Messages',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='Notification',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('message', models.TextField(verbose_name='Notification Message')),
('notification_type', models.CharField(choices=[('email', 'Email'), ('in_app', 'In-App')], default='email', max_length=20, verbose_name='Notification Type')),
('status', models.CharField(choices=[('pending', 'Pending'), ('sent', 'Sent'), ('read', 'Read'), ('failed', 'Failed'), ('retrying', 'Retrying')], default='pending', max_length=20, verbose_name='Status')),
('scheduled_for', models.DateTimeField(help_text='The date and time this notification is scheduled to be sent.', verbose_name='Scheduled Send Time')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('attempts', models.PositiveIntegerField(default=0, verbose_name='Send Attempts')),
('last_error', models.TextField(blank=True, verbose_name='Last Error Message')),
('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL, verbose_name='Recipient')),
],
options={
'verbose_name': 'Notification',
'verbose_name_plural': 'Notifications',
'ordering': ['-scheduled_for', '-created_at'],
},
),
migrations.CreateModel(
name='Person',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('first_name', models.CharField(max_length=255, verbose_name='First Name')),
('last_name', models.CharField(max_length=255, verbose_name='Last Name')),
('middle_name', models.CharField(blank=True, max_length=255, null=True, verbose_name='Middle Name')),
('email', models.EmailField(db_index=True, help_text='Unique email address for the person', max_length=254, unique=True, verbose_name='Email')),
('phone', models.CharField(blank=True, max_length=20, null=True, verbose_name='Phone')),
('date_of_birth', models.DateField(blank=True, null=True, verbose_name='Date of Birth')),
('gender', models.CharField(blank=True, choices=[('M', 'Male'), ('F', 'Female')], max_length=1, null=True, verbose_name='Gender')),
('gpa', models.DecimalField(blank=True, decimal_places=2, max_digits=3, null=True, verbose_name='GPA')),
('nationality', django_countries.fields.CountryField(blank=True, max_length=2, null=True, verbose_name='Nationality')),
('address', models.TextField(blank=True, null=True, verbose_name='Address')),
('profile_image', models.ImageField(blank=True, null=True, upload_to='profile_pic/', validators=[recruitment.validators.validate_image_size], verbose_name='Profile Image')),
('linkedin_profile', models.URLField(blank=True, null=True, verbose_name='LinkedIn Profile URL')),
('agency', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='recruitment.hiringagency', verbose_name='Hiring Agency')),
('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='person_profile', to=settings.AUTH_USER_MODEL, verbose_name='User Account')),
],
options={
'verbose_name': 'Person',
'verbose_name_plural': 'People',
},
),
migrations.AddField(
model_name='application',
name='person',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='applications', to='recruitment.person', verbose_name='Person'),
),
migrations.CreateModel(
name='ScheduledInterview',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('interview_date', models.DateField(db_index=True, verbose_name='Interview Date')),
('interview_time', models.TimeField(verbose_name='Interview Time')),
('status', models.CharField(choices=[('scheduled', 'Scheduled'), ('confirmed', 'Confirmed'), ('cancelled', 'Cancelled'), ('completed', 'Completed')], db_index=True, default='scheduled', max_length=20)),
('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.application')),
('interview_location', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='scheduled_interview', to='recruitment.interviewlocation', verbose_name='Meeting/Location Details')),
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.jobposting')),
('participants', models.ManyToManyField(blank=True, to='recruitment.participants')),
('schedule', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='interviews', to='recruitment.interviewschedule')),
('system_users', models.ManyToManyField(blank=True, related_name='attended_interviews', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='InterviewNote',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('note_type', models.CharField(choices=[('Feedback', 'Candidate Feedback'), ('Logistics', 'Logistical Note'), ('General', 'General Comment')], default='Feedback', max_length=50, verbose_name='Note Type')),
('content', django_ckeditor_5.fields.CKEditor5Field(verbose_name='Content/Feedback')),
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interview_notes', to=settings.AUTH_USER_MODEL, verbose_name='Author')),
('interview', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notes', to='recruitment.scheduledinterview', verbose_name='Scheduled Interview')),
],
options={
'verbose_name': 'Interview Note',
'verbose_name_plural': 'Interview Notes',
'ordering': ['created_at'],
},
),
migrations.CreateModel(
name='SharedFormTemplate',
@ -425,60 +591,6 @@ class Migration(migrations.Migration):
'verbose_name_plural': 'Training Materials',
},
),
migrations.CreateModel(
name='ScheduledInterview',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('interview_date', models.DateField(db_index=True, verbose_name='Interview Date')),
('interview_time', models.TimeField(verbose_name='Interview Time')),
('status', models.CharField(choices=[('scheduled', 'Scheduled'), ('confirmed', 'Confirmed'), ('cancelled', 'Cancelled'), ('completed', 'Completed')], db_index=True, default='scheduled', max_length=20)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('candidate', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.candidate')),
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.jobposting')),
('schedule', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interviews', to='recruitment.interviewschedule')),
('zoom_meeting', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='interview', to='recruitment.zoommeeting')),
],
),
migrations.CreateModel(
name='Notification',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('message', models.TextField(verbose_name='Notification Message')),
('notification_type', models.CharField(choices=[('email', 'Email'), ('in_app', 'In-App')], default='email', max_length=20, verbose_name='Notification Type')),
('status', models.CharField(choices=[('pending', 'Pending'), ('sent', 'Sent'), ('read', 'Read'), ('failed', 'Failed'), ('retrying', 'Retrying')], default='pending', max_length=20, verbose_name='Status')),
('scheduled_for', models.DateTimeField(help_text='The date and time this notification is scheduled to be sent.', verbose_name='Scheduled Send Time')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('attempts', models.PositiveIntegerField(default=0, verbose_name='Send Attempts')),
('last_error', models.TextField(blank=True, verbose_name='Last Error Message')),
('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL, verbose_name='Recipient')),
('related_meeting', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to='recruitment.zoommeeting', verbose_name='Related Meeting')),
],
options={
'verbose_name': 'Notification',
'verbose_name_plural': 'Notifications',
'ordering': ['-scheduled_for', '-created_at'],
},
),
migrations.CreateModel(
name='MeetingComment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('content', django_ckeditor_5.fields.CKEditor5Field(verbose_name='Content')),
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='meeting_comments', to=settings.AUTH_USER_MODEL, verbose_name='Author')),
('meeting', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='recruitment.zoommeeting', verbose_name='Meeting')),
],
options={
'verbose_name': 'Meeting Comment',
'verbose_name_plural': 'Meeting Comments',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='AgencyAccessLink',
fields=[
@ -501,6 +613,27 @@ class Migration(migrations.Migration):
'indexes': [models.Index(fields=['unique_token'], name='recruitment_unique__f91e76_idx'), models.Index(fields=['expires_at'], name='recruitment_expires_954ed9_idx'), models.Index(fields=['is_active'], name='recruitment_is_acti_4b0804_idx')],
},
),
migrations.CreateModel(
name='Document',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('object_id', models.PositiveIntegerField(verbose_name='Object ID')),
('file', models.FileField(upload_to='documents/%Y/%m/', validators=[recruitment.validators.validate_image_size], verbose_name='Document File')),
('document_type', models.CharField(choices=[('resume', 'Resume'), ('cover_letter', 'Cover Letter'), ('certificate', 'Certificate'), ('id_document', 'ID Document'), ('passport', 'Passport'), ('education', 'Education Document'), ('experience', 'Experience Letter'), ('other', 'Other')], default='other', max_length=20, verbose_name='Document Type')),
('description', models.CharField(blank=True, max_length=200, verbose_name='Description')),
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype', verbose_name='Content Type')),
('uploaded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Uploaded By')),
],
options={
'verbose_name': 'Document',
'verbose_name_plural': 'Documents',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['content_type', 'object_id', 'document_type', 'created_at'], name='recruitment_content_547650_idx')],
},
),
migrations.CreateModel(
name='FieldResponse',
fields=[
@ -523,17 +656,10 @@ class Migration(migrations.Migration):
model_name='formsubmission',
index=models.Index(fields=['submitted_at'], name='recruitment_submitt_7946c8_idx'),
),
migrations.AddIndex(
model_name='interviewschedule',
index=models.Index(fields=['start_date'], name='recruitment_start_d_15d55e_idx'),
),
migrations.AddIndex(
model_name='interviewschedule',
index=models.Index(fields=['end_date'], name='recruitment_end_dat_aeb00e_idx'),
),
migrations.AddIndex(
model_name='interviewschedule',
index=models.Index(fields=['created_by'], name='recruitment_created_d0bdcc_idx'),
migrations.AddField(
model_name='notification',
name='related_meeting',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to='recruitment.zoommeetingdetails', verbose_name='Related Meeting'),
),
migrations.AddIndex(
model_name='formtemplate',
@ -543,14 +669,6 @@ class Migration(migrations.Migration):
model_name='formtemplate',
index=models.Index(fields=['is_active'], name='recruitment_is_acti_ae5efb_idx'),
),
migrations.AddIndex(
model_name='candidate',
index=models.Index(fields=['stage'], name='recruitment_stage_f1c6eb_idx'),
),
migrations.AddIndex(
model_name='candidate',
index=models.Index(fields=['created_at'], name='recruitment_created_73590f_idx'),
),
migrations.AddIndex(
model_name='agencyjobassignment',
index=models.Index(fields=['agency', 'status'], name='recruitment_agency__491a54_idx'),
@ -572,12 +690,52 @@ class Migration(migrations.Migration):
unique_together={('agency', 'job')},
),
migrations.AddIndex(
model_name='jobposting',
index=models.Index(fields=['status', 'created_at', 'title'], name='recruitment_status_8b77aa_idx'),
model_name='message',
index=models.Index(fields=['sender', 'created_at'], name='recruitment_sender__49d984_idx'),
),
migrations.AddIndex(
model_name='jobposting',
index=models.Index(fields=['slug'], name='recruitment_slug_004045_idx'),
model_name='message',
index=models.Index(fields=['recipient', 'is_read', 'created_at'], name='recruitment_recipie_af0e6d_idx'),
),
migrations.AddIndex(
model_name='message',
index=models.Index(fields=['job', 'created_at'], name='recruitment_job_id_18f813_idx'),
),
migrations.AddIndex(
model_name='message',
index=models.Index(fields=['message_type', 'created_at'], name='recruitment_message_f25659_idx'),
),
migrations.AddIndex(
model_name='person',
index=models.Index(fields=['email'], name='recruitment_email_0b1ab1_idx'),
),
migrations.AddIndex(
model_name='person',
index=models.Index(fields=['first_name', 'last_name'], name='recruitment_first_n_739de5_idx'),
),
migrations.AddIndex(
model_name='person',
index=models.Index(fields=['created_at'], name='recruitment_created_33495a_idx'),
),
migrations.AddIndex(
model_name='application',
index=models.Index(fields=['person', 'job'], name='recruitment_person__34355c_idx'),
),
migrations.AddIndex(
model_name='application',
index=models.Index(fields=['stage'], name='recruitment_stage_52c2d1_idx'),
),
migrations.AddIndex(
model_name='application',
index=models.Index(fields=['created_at'], name='recruitment_created_80633f_idx'),
),
migrations.AddIndex(
model_name='application',
index=models.Index(fields=['person', 'stage', 'created_at'], name='recruitment_person__8715ec_idx'),
),
migrations.AlterUniqueTogether(
name='application',
unique_together={('person', 'job')},
),
migrations.AddIndex(
model_name='scheduledinterview',
@ -589,7 +747,15 @@ class Migration(migrations.Migration):
),
migrations.AddIndex(
model_name='scheduledinterview',
index=models.Index(fields=['candidate', 'job'], name='recruitment_candida_43d5b0_idx'),
index=models.Index(fields=['application', 'job'], name='recruitment_applica_927561_idx'),
),
migrations.AddIndex(
model_name='jobposting',
index=models.Index(fields=['status', 'created_at', 'title'], name='recruitment_status_8b77aa_idx'),
),
migrations.AddIndex(
model_name='jobposting',
index=models.Index(fields=['slug'], name='recruitment_slug_004045_idx'),
),
migrations.AddIndex(
model_name='notification',

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 .models import JobPosting, Candidate
from .models import JobPosting, Application
class JobPostingSerializer(serializers.ModelSerializer):
class Meta:
model = JobPosting
fields = '__all__'
class CandidateSerializer(serializers.ModelSerializer):
class ApplicationSerializer(serializers.ModelSerializer):
job_title = serializers.CharField(source='job.title', read_only=True)
class Meta:
model = Candidate
model = Application
fields = '__all__'

View File

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

View File

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

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

View File

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

View File

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

View File

@ -594,7 +594,7 @@ def update_meeting(instance, updated_data):
instance.topic = zoom_details.get("topic", instance.topic)
instance.duration = zoom_details.get("duration", instance.duration)
instance.join_url = zoom_details.get("join_url", instance.join_url)
instance.details_url = zoom_details.get("join_url", instance.details_url)
instance.password = zoom_details.get("password", instance.password)
# Corrected status assignment: instance.status, not instance.password
instance.status = zoom_details.get("status")

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

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)
import pandas as pd
from .models import Candidate
from .models import Application
def get_dashboard_data():
df = pd.DataFrame(list(Candidate.objects.all().values('status', 'created_at')))
df = pd.DataFrame(list( Application.objects.all().values('status', 'created_at')))
summary = df['status'].value_counts().to_dict()
return summary

View File

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

View File

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

View File

@ -122,6 +122,11 @@
</ul>
</li>
{% endif %} {% endcomment %}
<li class="nav-item me-2">
<a class="nav-link" href="{% url 'message_list' %}">
<i class="fas fa-envelope"></i>
</a>
</li>
<li class="nav-item dropdown">
<button
@ -133,8 +138,8 @@
data-bs-auto-close="outside"
data-bs-offset="0, 16" {# Vertical offset remains 16px to prevent clipping #}
>
{% if user.profile and user.profile.profile_image %}
<img src="{{ user.profile.profile_image.url }}" alt="{{ user.username }}" class="profile-avatar"
{% if user.profile_image %}
<img src="{{ user.profile_image.url }}" alt="{{ user.username }}" class="profile-avatar"
style="width: 36px; height: 36px; object-fit: cover; background-color: var(--kaauh-teal); display: inline-block; vertical-align: middle;"
title="{% trans 'Your account' %}">
{% else %}
@ -151,8 +156,8 @@
<li class="px-4 py-3 ">
<div class="d-flex align-items-center">
<div class="me-3 d-flex align-items-center justify-content-center" style="min-width: 48px;">
{% if user.profile and user.profile.profile_image %}
<img src="{{ user.profile.profile_image.url }}" alt="{{ user.username }}" class="profile-avatar shadow-sm border"
{% if user.profile_image %}
<img src="{{ user.profile_image.url }}" alt="{{ user.username }}" class="profile-avatar shadow-sm border"
style="width: 44px; height: 44px; object-fit: cover; background-color: var(--kaauh-teal); display: block;"
title="{% trans 'Your account' %}">
{% else %}
@ -208,7 +213,7 @@
<i class="fas fa-sign-out-alt me-3 fs-5 " style="color:red;"></i>
<span style="color:red;">{% trans "Sign Out" %}</span>
</button>
</form>
</form>
{% comment %} <a class="d-inline text-decoration-none px-4 d-flex align-items-center border-0 bg-transparent text-start text-center" href={% url "account_logout" %}>
<i class="fas fa-sign-out-alt me-3 fs-5 " style="color:red;"></i>
<span style="color:red;">{% trans "Sign Out" %}</span>
@ -237,7 +242,15 @@
<a class="nav-link {% if request.resolver_match.url_name == 'candidate_list' %}active{% endif %}" href="{% url 'candidate_list' %}">
<span class="d-flex align-items-center gap-2">
{% include "icons/users.html" %}
{% trans "Applicants" %}
{% trans "Applications" %}
</span>
</a>
</li>
<li class="nav-item me-lg-4">
<a class="nav-link {% if request.resolver_match.url_name == 'person_list' %}active{% endif %}" href="{% url 'person_list' %}">
<span class="d-flex align-items-center gap-2">
{% include "icons/users.html" %}
{% trans "Person" %}
</span>
</a>
</li>
@ -312,7 +325,7 @@
</div>
{% endfor %}
{% endif %}
{% block content %}
{% endblock %}
</main>
@ -340,6 +353,7 @@
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js"></script>
<script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.6/bundles/datastar.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js" integrity="sha512-v2CJ7UaYy4JwqLDIrZUI/4hqeoQieOmAZNXBeQyjo21dadnwR+8ZaIJVT8EE2iyI61OV8e6M8PP2/4hpQINQ/g==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Navbar collapse auto-close on link click (Standard Mobile UX)
@ -404,6 +418,23 @@
</script>
<!-- Message Count JavaScript -->
<script>
document.addEventListener('DOMContentLoaded', function() {
// Update unread message count on page load
fetch('/api/unread-count/')
.then(response => response.json())
.then(data => {
const badge = document.getElementById('unread-messages-badge');
if (badge && data.unread_count > 0) {
badge.textContent = data.unread_count;
badge.style.display = 'inline-block';
}
})
.catch(error => console.error('Error fetching unread count:', error));
});
</script>
<!-- Notification JavaScript for Admin Users -->
{% comment %} {% if request.user.is_authenticated and request.user.is_staff %}
<script>

View File

@ -1,10 +1,34 @@
{% load i18n %}
<div class="d-flex justify-content-center align-items-center gap-2" hx-swap='outerHTML' hx-target="#status-result-{{ candidate.pk }}"
<form id="exam-update-form" hx-post="{% url 'update_candidate_status' job.slug candidate.slug 'exam' 'Failed' %}" hx-swap='outerHTML' hx-target="#status-result-{{ candidate.pk }}"
hx-on::after-request="const modal = bootstrap.Modal.getInstance(document.getElementById('candidateviewModal')); if (modal) { modal.hide(); }">
<a hx-post="{% url 'update_candidate_status' job.slug candidate.slug 'exam' 'Passed' %}" class="btn btn-outline-secondary">
<i class="fas fa-check me-1"></i> {% trans "Passed" %}
</a>
<a hx-post="{% url 'update_candidate_status' job.slug candidate.slug 'exam' 'Failed' %}" class="btn btn-danger">
<i class="fas fa-times me-1"></i> {% trans "Failed" %}
</a>
</div>
<div class="d-flex justify-content-center align-items-center gap-2">
<div class="form-check d-flex align-items-center gap-2">
<input class="form-check-input" type="radio" name="exam_status" id="exam_passed" value="Passed" {% if candidate.exam_status == 'Passed' %}checked{% endif %}>
<label class="form-check-label" for="exam_passed">
<i class="fas fa-check me-1"></i> {% trans "Passed" %}
</label>
</div>
<div class="form-check d-flex align-items-center gap-2">
<input class="form-check-input" type="radio" name="exam_status" id="exam_failed" value="Failed" {% if candidate.exam_status == 'Failed' %}checked{% endif %}>
<label class="form-check-label" for="exam_failed">
<i class="fas fa-times me-1"></i> {% trans "Failed" %}
</label>
</div>
</div>
<div class="d-flex justify-content-center align-items-center mt-3 gap-2">
<div class="w-25 text-end pe-none">
<label for="exam_score" class="form-label small text-muted">{% trans "Exam Score" %}</label>
</div>
<div class="w-25">
<input type="number" class="form-control form-control-sm" id="exam_score" name="exam_score" min="0" max="100" required value="{{ candidate.exam_score }}">
</div>
<div class="w-25 text-start ps-none">
</div>
</div>
<div class="text-center mt-3">
<button type="submit" class="btn btn-success btn-sm">
<i class="fas fa-check me-1"></i> {% trans "Update" %}
</button>
</div>
</form>

View File

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

View File

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

View File

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

View File

@ -9,7 +9,7 @@
<h3 class="text-center font-weight-light my-4">Set Interview Location</h3>
</div>
<div class="card-body">
<form method="post" action="{% url 'schedule_interview_location_form' schedule.slug %}" enctype="multipart/form-data">
<form method="post" action="{% url 'confirm_schedule_interviews_view' job.slug %}" enctype="multipart/form-data">
{% csrf_token %}
{# Renders the single 'location' field using the crispy filter #}

View File

@ -1,5 +1,5 @@
{% extends "base.html" %}
{% load static %}
{% load static crispy_forms_tags %}
{%load i18n %}
{% block customCSS %}
@ -65,7 +65,7 @@
padding: 10px 15px;
background-color: var(--kaauh-teal-light);
}
/* FullCalendar Customization */
#calendar {
font-size: 0.9em;
@ -87,7 +87,7 @@
{% block content %}
<div class="container py-5">
<div class="d-flex justify-content-between align-items-center mb-5">
<h1 class="h3 page-header">
<i class="fas fa-calendar-alt me-2 text-primary-theme"></i> Interview Schedule Preview: **{{ job.title }}**
@ -98,13 +98,13 @@
<div class="card-body p-4 p-lg-5">
<h4 class="card-title-border">{% trans "Schedule Parameters" %}</h4>
<div class="row g-4">
<div class="col-md-6">
<p class="mb-2"><strong><i class="fas fa-clock me-2 text-primary-theme"></i> Working Hours:</strong> {{ start_time|time:"g:i A" }} to {{ end_time|time:"g:i A" }}</p>
<p class="mb-2"><strong><i class="fas fa-hourglass-half me-2 text-primary-theme"></i> Interview Duration:</strong> {{ interview_duration }} minutes</p>
<p class="mb-2"><strong><i class="fas fa-shield-alt me-2 text-primary-theme"></i> Buffer Time:</strong> {{ buffer_time }} minutes</p>
</div>
<div class="col-md-6">
<p class="mb-2"><strong><i class="fas fa-calendar-day me-2 text-primary-theme"></i> Interview Period:</strong> {{ start_date|date:"F j, Y" }} &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>
@ -119,10 +119,10 @@
{% if not forloop.last %}, {% endif %}
{% endfor %}
</p>
<p class="mb-2"><strong><i class="fas fa-calendar-day me-2 text-primary-theme"></i> Interview Type:</strong> {{interview_type}}</p>
<p class="mb-2"><strong><i class="fas fa-calendar-day me-2 text-primary-theme"></i> Interview Type:</strong> {{schedule_interview_type}}</p>
</div>
</div>
<h5 class="mt-4 pt-3 border-top">{% trans "Daily Break Times" %}</h5>
{% if breaks %}
<div class="d-flex flex-wrap gap-3 mt-3">
@ -162,24 +162,58 @@
{% for item in schedule %}
<tr>
<td>{{ item.date|date:"F j, Y" }}</td>
<td class="fw-bold text-primary-theme">{{ item.time|time:"g:i A" }}</td>
<td>{{ item.candidate.name }}</td>
<td>{{ item.candidate.email }}</td>
<td>{{ item.time|time:"g:i A" }}</td>
<td>{{ item.application.name }}</td>
<td>{{ item.application.email }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<form method="post" action="{% url 'confirm_schedule_interviews_view' slug=job.slug %}" class="mt-4 d-flex justify-content-end gap-3">
{% csrf_token %}
<a href="{% url 'schedule_interviews' slug=job.slug %}" class="btn btn-secondary px-4">
<i class="fas fa-arrow-left me-2"></i> {% trans "Back to Edit" %}
</a>
<button type="submit" name="confirm_schedule" class="btn btn-teal-primary px-4">
{% if schedule_interview_type == "Onsite" %}
<button type="submit" name="confirm_schedule" class="btn btn-teal-primary px-4" data-bs-toggle="modal" data-bs-target="#interviewDetailsModal" data-placement="top">
<i class="fas fa-check me-2"></i> {% trans "Confirm Schedule" %}
</button>
</form>
{% else %}
<form method="post" action="{% url 'confirm_schedule_interviews_view' slug=job.slug %}" class="mt-4 d-flex justify-content-end gap-3">
{% csrf_token %}
<a href="{% url 'schedule_interviews' slug=job.slug %}" class="btn btn-secondary px-4">
<i class="fas fa-arrow-left me-2"></i> {% trans "Back to Edit" %}
</a>
<button type="submit" name="confirm_schedule" class="btn btn-teal-primary px-4">
<i class="fas fa-check me-2"></i> {% trans "Confirm Schedule" %}
</button>
</form>
{% endif %}
</div>
</div>
</div>
<div class="modal fade" id="interviewDetailsModal" tabindex="-1" aria-labelledby="interviewDetailsModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="interviewDetailsModalLabel">{% trans "Interview Details" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body"> <form method="post" action="{% url 'confirm_schedule_interviews_view' job.slug %}" enctype="multipart/form-data" id="onsite-form">
{% csrf_token %}
{# Renders the single 'location' field using the crispy filter #}
{{ form|crispy }}
</form>
</div>
<div class="modal-footer">
<div class="d-flex align-items-center justify-content-between mt-4 mb-0">
<a href="{% url 'list_meetings' %}" class="btn btn-secondary me-2">
<i class="fas fa-times me-1"></i> Close
</a>
<button type="submit" class="btn btn-primary" form="onsite-form">
<i class="fas fa-save me-1"></i> Save Location
</button>
</div>
</div>
</div>
</div>
</div>
@ -200,13 +234,13 @@ document.addEventListener('DOMContentLoaded', function() {
events: [
{% for item in schedule %}
{
title: '{{ item.candidate.name }}',
title: '{{ item.application.name }}',
start: '{{ item.date|date:"Y-m-d" }}T{{ item.time|time:"H:i:s" }}',
url: '#',
// Use the theme color for candidate events
color: 'var(--kaauh-teal-dark)',
color: 'var(--kaauh-teal-dark)',
extendedProps: {
email: '{{ item.candidate.email }}',
email: '{{ item.application.email }}',
time: '{{ item.time|time:"g:i A" }}'
}
},
@ -214,7 +248,7 @@ document.addEventListener('DOMContentLoaded', function() {
{% for break in breaks %}
{
title: 'Break',
// FullCalendar requires a specific date for breaks, using start_date as a placeholder for daily breaks.
// FullCalendar requires a specific date for breaks, using start_date as a placeholder for daily breaks.
// Note: Breaks displayed on the monthly grid will only show on start_date, but weekly/daily view should reflect it daily if implemented correctly in the backend or using recurring events.
start: '{{ start_date|date:"Y-m-d" }}T{{ break.start_time|time:"H:i:s" }}',
end: '{{ start_date|date:"Y-m-d" }}T{{ break.end_time|time:"H:i:s" }}',

View File

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

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