From caa7ed88aa8adbce3bfcae78b31d694b038eb0ef Mon Sep 17 00:00:00 2001 From: ismail Date: Wed, 5 Nov 2025 18:27:43 +0300 Subject: [PATCH] add login to candidate and agency --- NorahUniversity/settings.py | 487 +-- recruitment/__pycache__/admin.cpython-313.pyc | Bin 12464 -> 12375 bytes recruitment/__pycache__/forms.cpython-313.pyc | Bin 64383 -> 65367 bytes .../__pycache__/models.cpython-313.pyc | Bin 85273 -> 86623 bytes recruitment/__pycache__/views.cpython-313.pyc | Bin 139905 -> 142714 bytes recruitment/admin.py | 16 +- recruitment/forms.py | 1520 +++++----- .../__pycache__/__init__.cpython-313.pyc | Bin 167 -> 167 bytes .../migrations/0003_auto_20251105_1616.py | 38 + .../0004_alter_candidate_ai_analysis_data.py | 18 + recruitment/migrations/0005_customuser.py | 44 + recruitment/models.py | 538 ++-- recruitment/urls.py | 658 +++-- recruitment/views.py | 2607 ++++++++++------- .../{agency_base.html => portal_base.html} | 25 +- .../agency_access_link_detail.html | 2 +- .../agency_portal_assignment_detail.html | 2 +- .../recruitment/agency_portal_dashboard.html | 2 +- .../recruitment/agency_portal_login.html | 4 +- .../agency_portal_submit_candidate.html | 2 +- .../candidate_portal_dashboard.html | 165 ++ templates/recruitment/portal_login.html | 295 ++ 22 files changed, 4048 insertions(+), 2375 deletions(-) create mode 100644 recruitment/migrations/0003_auto_20251105_1616.py create mode 100644 recruitment/migrations/0004_alter_candidate_ai_analysis_data.py create mode 100644 recruitment/migrations/0005_customuser.py rename templates/{agency_base.html => portal_base.html} (92%) create mode 100644 templates/recruitment/candidate_portal_dashboard.html create mode 100644 templates/recruitment/portal_login.html diff --git a/NorahUniversity/settings.py b/NorahUniversity/settings.py index cecb890..fb2b574 100644 --- a/NorahUniversity/settings.py +++ b/NorahUniversity/settings.py @@ -9,6 +9,7 @@ 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 @@ -20,7 +21,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,116 +31,114 @@ 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 = 'dashboard' +LOGIN_REDIRECT_URL = "dashboard" -ACCOUNT_LOGOUT_REDIRECT_URL = '/' +ACCOUNT_LOGOUT_REDIRECT_URL = "/" -ACCOUNT_SIGNUP_REDIRECT_URL = '/' +ACCOUNT_SIGNUP_REDIRECT_URL = "/" -LOGIN_URL = '/accounts/login/' - +LOGIN_URL = "/accounts/login/" AUTHENTICATION_BACKENDS = [ - 'django.contrib.auth.backends.ModelBackend', - 'allauth.account.auth_backends.AuthenticationBackend', + "django.contrib.auth.backends.ModelBackend", + "allauth.account.auth_backends.AuthenticationBackend", ] MIDDLEWARE = [ - 'corsheaders.middleware.CorsMiddleware', - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.locale.LocaleMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', - 'allauth.account.middleware.AccountMiddleware', - 'easyaudit.middleware.easyaudit.EasyAuditMiddleware', + "corsheaders.middleware.CorsMiddleware", + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.locale.LocaleMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "allauth.account.middleware.AccountMiddleware", + "easyaudit.middleware.easyaudit.EasyAuditMiddleware", ] -ROOT_URLCONF = 'NorahUniversity.urls' +ROOT_URLCONF = "NorahUniversity.urls" CORS_ALLOW_ALL_ORIGINS = True -ASGI_APPLICATION = 'hospital_recruitment.asgi.application' +ASGI_APPLICATION = "hospital_recruitment.asgi.application" CHANNEL_LAYERS = { - 'default': { - 'BACKEND': 'channels_redis.core.RedisChannelLayer', - 'CONFIG': { - 'hosts': [('127.0.0.1', 6379)], + "default": { + "BACKEND": "channels_redis.core.RedisChannelLayer", + "CONFIG": { + "hosts": [("127.0.0.1", 6379)], }, }, } TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [os.path.join(BASE_DIR, 'templates')], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [os.path.join(BASE_DIR, "templates")], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", ], }, }, ] -WSGI_APPLICATION = 'NorahUniversity.wsgi.application' +WSGI_APPLICATION = "NorahUniversity.wsgi.application" # Database # https://docs.djangoproject.com/en/5.2/ref/settings/#databases DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.postgresql_psycopg2', - 'NAME': 'haikal_db', - 'USER': 'faheed', - 'PASSWORD': 'Faheed@215', - 'HOST': '127.0.0.1', - 'PORT': '5432', + "default": { + "ENGINE": "django.db.backends.postgresql_psycopg2", + "NAME": "norahuniversity", + "USER": "norahuniversity", + "PASSWORD": "norahuniversity", + "HOST": "127.0.0.1", + "PORT": "5432", } } @@ -154,7 +153,6 @@ DATABASES = { # https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators - # AUTH_PASSWORD_VALIDATORS = [ # { # 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', @@ -174,24 +172,24 @@ DATABASES = { AUTH_PASSWORD_VALIDATORS = [ { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", }, ] -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_USER_MODEL_USERNAME_FIELD = None -ACCOUNT_EMAIL_VERIFICATION = "mandatory" +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" @@ -199,29 +197,29 @@ CRISPY_TEMPLATE_PACK = "bootstrapconsole5" # 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 @@ -230,36 +228,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, '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) @@ -268,142 +265,200 @@ FILE_UPLOAD_MAX_MEMORY_SIZE = 10485760 # 10MB CORS_ALLOW_CREDENTIALS = True -CELERY_BROKER_URL = 'redis://localhost:6379/0' # Or your message broker URL -CELERY_RESULT_BACKEND = 'django-db' # If using django-celery-results -CELERY_ACCEPT_CONTENT = ['application/json'] -CELERY_TASK_SERIALIZER = 'json' -CELERY_RESULT_SERIALIZER = 'json' -CELERY_TIMEZONE = 'UTC' +CELERY_BROKER_URL = "redis://localhost:6379/0" # Or your message broker URL +CELERY_RESULT_BACKEND = "django-db" # If using django-celery-results +CELERY_ACCEPT_CONTENT = ["application/json"] +CELERY_TASK_SERIALIZER = "json" +CELERY_RESULT_SERIALIZER = "json" +CELERY_TIMEZONE = "UTC" -LINKEDIN_CLIENT_ID = '867jwsiyem1504' -LINKEDIN_CLIENT_SECRET = 'WPL_AP1.QNH5lYnfRSQpp0Qp.GO8Srw==' -LINKEDIN_REDIRECT_URI = 'http://127.0.0.1:8000/jobs/linkedin/callback/' +LINKEDIN_CLIENT_ID = "867jwsiyem1504" +LINKEDIN_CLIENT_SECRET = "WPL_AP1.QNH5lYnfRSQpp0Qp.GO8Srw==" +LINKEDIN_REDIRECT_URI = "http://127.0.0.1:8000/jobs/linkedin/callback/" Q_CLUSTER = { - 'name': 'KAAUH_CLUSTER', - 'workers': 8, - 'recycle': 500, - 'timeout': 60, - 'max_attempts': 1, - 'compress': True, - 'save_limit': 250, - 'queue_limit': 500, - 'cpu_affinity': 1, - 'label': 'Django Q2', - 'redis': { - 'host': '127.0.0.1', - 'port': 6379, - 'db': 3, }, - 'ALT_CLUSTERS': { - 'long': { - 'timeout': 3000, - 'retry': 3600, - 'max_attempts': 2, + "name": "KAAUH_CLUSTER", + "workers": 8, + "recycle": 500, + "timeout": 60, + "max_attempts": 1, + "compress": True, + "save_limit": 250, + "queue_limit": 500, + "cpu_affinity": 1, + "label": "Django Q2", + "redis": { + "host": "127.0.0.1", + "port": 6379, + "db": 3, + }, + "ALT_CLUSTERS": { + "long": { + "timeout": 3000, + "retry": 3600, + "max_attempts": 2, }, - 'short': { - 'timeout': 10, - 'max_attempts': 1, + "short": { + "timeout": 10, + "max_attempts": 1, }, - } + }, } customColorPalette = [ - { - 'color': 'hsl(4, 90%, 58%)', - 'label': 'Red' - }, - { - 'color': 'hsl(340, 82%, 52%)', - 'label': 'Pink' - }, - { - 'color': 'hsl(291, 64%, 42%)', - 'label': 'Purple' - }, - { - 'color': 'hsl(262, 52%, 47%)', - 'label': 'Deep Purple' - }, - { - 'color': 'hsl(231, 48%, 48%)', - 'label': 'Indigo' - }, - { - 'color': 'hsl(207, 90%, 54%)', - 'label': 'Blue' - }, - ] + {"color": "hsl(4, 90%, 58%)", "label": "Red"}, + {"color": "hsl(340, 82%, 52%)", "label": "Pink"}, + {"color": "hsl(291, 64%, 42%)", "label": "Purple"}, + {"color": "hsl(262, 52%, 47%)", "label": "Deep Purple"}, + {"color": "hsl(231, 48%, 48%)", "label": "Indigo"}, + {"color": "hsl(207, 90%, 54%)", "label": "Blue"}, +] # CKEDITOR_5_CUSTOM_CSS = 'path_to.css' # optional # CKEDITOR_5_FILE_STORAGE = "path_to_storage.CustomStorage" # optional CKEDITOR_5_CONFIGS = { - 'default': { - 'toolbar': { - 'items': ['heading', '|', 'bold', 'italic', 'link', - 'bulletedList', 'numberedList', 'blockQuote', 'imageUpload', ], - } - + "default": { + "toolbar": { + "items": [ + "heading", + "|", + "bold", + "italic", + "link", + "bulletedList", + "numberedList", + "blockQuote", + "imageUpload", + ], + } }, - 'extends': { - 'blockToolbar': [ - 'paragraph', 'heading1', 'heading2', 'heading3', - '|', - 'bulletedList', 'numberedList', - '|', - 'blockQuote', + "extends": { + "blockToolbar": [ + "paragraph", + "heading1", + "heading2", + "heading3", + "|", + "bulletedList", + "numberedList", + "|", + "blockQuote", ], - 'toolbar': { - 'items': ['heading', '|', 'outdent', 'indent', '|', 'bold', 'italic', 'link', 'underline', 'strikethrough', - 'code','subscript', 'superscript', 'highlight', '|', 'codeBlock', 'sourceEditing', 'insertImage', - 'bulletedList', 'numberedList', 'todoList', '|', 'blockQuote', 'imageUpload', '|', - 'fontSize', 'fontFamily', 'fontColor', 'fontBackgroundColor', 'mediaEmbed', 'removeFormat', - 'insertTable', - ], - 'shouldNotGroupWhenFull': 'true' + "toolbar": { + "items": [ + "heading", + "|", + "outdent", + "indent", + "|", + "bold", + "italic", + "link", + "underline", + "strikethrough", + "code", + "subscript", + "superscript", + "highlight", + "|", + "codeBlock", + "sourceEditing", + "insertImage", + "bulletedList", + "numberedList", + "todoList", + "|", + "blockQuote", + "imageUpload", + "|", + "fontSize", + "fontFamily", + "fontColor", + "fontBackgroundColor", + "mediaEmbed", + "removeFormat", + "insertTable", + ], + "shouldNotGroupWhenFull": "true", }, - 'image': { - 'toolbar': ['imageTextAlternative', '|', 'imageStyle:alignLeft', - 'imageStyle:alignRight', 'imageStyle:alignCenter', 'imageStyle:side', '|'], - 'styles': [ - 'full', - 'side', - 'alignLeft', - 'alignRight', - 'alignCenter', - ] - + "image": { + "toolbar": [ + "imageTextAlternative", + "|", + "imageStyle:alignLeft", + "imageStyle:alignRight", + "imageStyle:alignCenter", + "imageStyle:side", + "|", + ], + "styles": [ + "full", + "side", + "alignLeft", + "alignRight", + "alignCenter", + ], }, - 'table': { - 'contentToolbar': [ 'tableColumn', 'tableRow', 'mergeTableCells', - 'tableProperties', 'tableCellProperties' ], - 'tableProperties': { - 'borderColors': customColorPalette, - 'backgroundColors': customColorPalette + "table": { + "contentToolbar": [ + "tableColumn", + "tableRow", + "mergeTableCells", + "tableProperties", + "tableCellProperties", + ], + "tableProperties": { + "borderColors": customColorPalette, + "backgroundColors": customColorPalette, + }, + "tableCellProperties": { + "borderColors": customColorPalette, + "backgroundColors": customColorPalette, }, - 'tableCellProperties': { - 'borderColors': customColorPalette, - 'backgroundColors': customColorPalette - } }, - 'heading' : { - 'options': [ - { 'model': 'paragraph', 'title': 'Paragraph', 'class': 'ck-heading_paragraph' }, - { 'model': 'heading1', 'view': 'h1', 'title': 'Heading 1', 'class': 'ck-heading_heading1' }, - { 'model': 'heading2', 'view': 'h2', 'title': 'Heading 2', 'class': 'ck-heading_heading2' }, - { 'model': 'heading3', 'view': 'h3', 'title': 'Heading 3', 'class': 'ck-heading_heading3' } + "heading": { + "options": [ + { + "model": "paragraph", + "title": "Paragraph", + "class": "ck-heading_paragraph", + }, + { + "model": "heading1", + "view": "h1", + "title": "Heading 1", + "class": "ck-heading_heading1", + }, + { + "model": "heading2", + "view": "h2", + "title": "Heading 2", + "class": "ck-heading_heading2", + }, + { + "model": "heading3", + "view": "h3", + "title": "Heading 3", + "class": "ck-heading_heading3", + }, ] + }, + }, + "list": { + "properties": { + "styles": "true", + "startIndex": "true", + "reversed": "true", } }, - 'list': { - 'properties': { - 'styles': 'true', - 'startIndex': 'true', - 'reversed': 'true', - } - } } # Define a constant in settings.py to specify file upload permissions -CKEDITOR_5_FILE_UPLOAD_PERMISSION = "staff" # Possible values: "staff", "authenticated", "any" +CKEDITOR_5_FILE_UPLOAD_PERMISSION = ( + "staff" # Possible values: "staff", "authenticated", "any" +) + +# Custom User Model +AUTH_USER_MODEL = "recruitment.CustomUser" diff --git a/recruitment/__pycache__/admin.cpython-313.pyc b/recruitment/__pycache__/admin.cpython-313.pyc index 02337a2544f18d682d4cad23a7fb541e9d1fe86d..dc7eec901aba395f7b670873db3e42cd03005424 100644 GIT binary patch delta 3692 zcmai0du&@*8Ta-3oj9)JM`Fj1JbdHCAz729UD!zJ`XJD?N%m6Pxi|L3y`5v5bFW*H zQPOP^TM<>4ejT7m6N55nB?O}|7)Vt?VuejGwJKZTj>1|Hh<_kef&IfI&Udb3r>#9K z<(G55_xT>@+*gDv``kBOF1wEY<{oiKr~8-P0e!c5Ir(h&jBds<)%l4q2}gB%!g1Xw z3eg{l1axuTj5Va2v7OMjFJH7dqKsmv6o*DJ#rAO1o8cAC2Vu@_b5f3rQntWH^nDzo z`+cLFW{>H*sYFXvB4O&OYA0;1lVoCHysQ9<@&x61D&(2W#YhB(q~k2DdFlc^x%gs>VAlqoeY&Zq zKbj&No2fN$7O6YSNBP)U^EB7SbE-QpDw&EblFWQ5Cl+AZvV(KO*DXi57{sj~w}+`I zg5ZXdHDvB*TR3ig_;D{TEeKr*F$z0)2Z`e*fzXZ6gWy8wMd(9FBA`vxA{7f#QIrXf zBs4*ohZL>`SHTOrY;n!?AS~E^%mrcCKGnA#`C|xoBa9>5ML~7sL^(^OvLcm=WE@V} zha5;JOko3DwFkLTc;7zJgUYH`Dk@^02q>L7C=wa9n8l5#G!BnAp6ABkACAY8EUZaX z>YOQ6NLHN8%}d3!kX59Ef+A*QMwACZaSmJXAdA6C_?asJzi>))NYk#4;QeTG8wJ@& zAxeLQS=|J4t^_v%UvLd`{qUMA7SIyfaz{ephHKwFEC`RhzeJSG+S6`A^No@@j0}Vz ze93)EOXe_)wg5K{Z?p{V!n=s2kT(+SU-B5j?i$sgI>6uFF` zkb6+1nqJoOw`!}FL*kq)DWYn`k)PFu_KY^P&b3nuL!M6fZihM1sFx0)7X%Dno`4U$ zz0sK!gTBkYqSMbJ*5rw0r>2?_qn}L_U&ZnVN~xUc;_ zZUZc}znDUksy$0YmcO}$x?R<=y?!guhT9EW{ZriM0sQZAhd~J>n-1~`_#otmUk992 z?)L#4W=9aXufW%W-A#1>{t)cqo`U}dQ9d1dj>?}8^)!_~Nqr=^XCT%w%qDrU6Iz4q zQ0%Z&Z;jSF`hT^ccRz$#If3vn0wA13XjJJ#$UxW&f9?2>c557h>z!|L$KdE)HhM%WrT4sMbeIX=a%8#Na%rTbDlz#O?7sdH4MdH@aMbTCmXwTu(Tch6z~LC`e=j!J z{Qj4A;qYzvD5?8-MAaJS27pt~Go%{y!-b7$Zhr!bO!Vpvaf04Y7N(dL= zV(&36lGh>IcYN$BCGtkqGB1h>y?9l#P*LcMS#1hr8npF0XyR>+a2}G$q@(7Ud^v1$?Xwbn{1jonEPq(CE~9;ftMFD*(n7opALWlO-9V>C?WV6M ze+=arusGOFju-#!wW}&J7Sl!@)rvvkXBGJ&-B#n`U3XjwlnEmq-^d_Z9zvK!z=yG6 zAHItWgc|_;1??2?(o+N>k~+W{;Am>XaUEseL%0SPQ-f_RZw8I>XCz;TmVxllHC+CM z!iQz;Pbh?7fZYRGXaDMU2R{UF4*Yb!#B^QAO4Hj(DiSY6UVrBvgC^mS8Rv%*}Z zAdv0s8YNB`uXfIB_X@^7b(8@gukUAFj-82_b*P3fbZcFzA*)0IH- zQXqL#M>jVOI(=kCPgg6UAN(!-b1w8-UH9*Frf2L=+OO!mmvr9eHovV4vO)YGW%v~( delta 3624 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

Cmgp0m<){>54 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 diff --git a/recruitment/__pycache__/models.cpython-313.pyc b/recruitment/__pycache__/models.cpython-313.pyc index 87d5a3fb71f06380be43dc20c544ba14803310ae..ad0d791e95260dca527bb3351df26155083dda37 100644 GIT binary patch delta 25890 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 delta 24936 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 SUME+x1qbuKl)(=T&Ho3A)?Fn2IUnmCv=31(As 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 a<&mu2M76a*n|+B_kcBn}m&-!4F#R7)!QLqV diff --git a/recruitment/admin.py b/recruitment/admin.py index cd2c8a1..1a35903 100644 --- a/recruitment/admin.py +++ b/recruitment/admin.py @@ -8,7 +8,9 @@ from .models import ( SharedFormTemplate, Source, HiringAgency, IntegrationLog,InterviewSchedule,Profile,JobPostingImage,MeetingComment, AgencyAccessLink, AgencyJobAssignment ) +from django.contrib.auth import get_user_model +User = get_user_model() class FormFieldInline(admin.TabularInline): model = FormField extra = 1 @@ -82,17 +84,10 @@ class HiringAgencyAdmin(admin.ModelAdmin): readonly_fields = ['slug', 'created_at', 'updated_at'] fieldsets = ( ('Basic Information', { - 'fields': ('name', 'slug', 'contact_person', 'email', 'phone', 'website') - }), - ('Location Details', { - 'fields': ('country', 'city', 'address') - }), - ('Additional Information', { - 'fields': ('description', 'created_at', 'updated_at') + 'fields': ('name','contact_person', 'email', 'phone', 'website','user') }), ) save_on_top = True - prepopulated_fields = {'slug': ('name',)} @admin.register(JobPosting) @@ -151,7 +146,7 @@ class CandidateAdmin(admin.ModelAdmin): readonly_fields = ['slug', 'created_at', 'updated_at'] fieldsets = ( ('Personal Information', { - 'fields': ('first_name', 'last_name', 'email', 'phone', 'resume') + 'fields': ('first_name', 'last_name', 'email', 'phone', 'resume','user') }), ('Application Details', { 'fields': ('job', 'applied', 'stage','is_resume_parsed') @@ -163,7 +158,7 @@ class CandidateAdmin(admin.ModelAdmin): 'fields': ('ai_analysis_data',) }), ('Additional Information', { - 'fields': ('submitted_by_agency', 'created_at', 'updated_at') + 'fields': ('created_at', 'updated_at') }), ) save_on_top = True @@ -290,3 +285,4 @@ admin.site.register(AgencyJobAssignment) admin.site.register(JobPostingImage) +admin.site.register(User) diff --git a/recruitment/forms.py b/recruitment/forms.py index a7810de..4fa6af8 100644 --- a/recruitment/forms.py +++ b/recruitment/forms.py @@ -4,15 +4,30 @@ from django.forms.formsets import formset_factory from django.utils.translation import gettext_lazy as _ from crispy_forms.helper import FormHelper from crispy_forms.layout import Layout, Submit, Row, Column, Field, Div -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.contrib.auth.forms import UserCreationForm + +User = get_user_model() import re from .models import ( - ZoomMeeting, Candidate,TrainingMaterial,JobPosting, - FormTemplate,InterviewSchedule,BreakTime,JobPostingImage, - Profile,MeetingComment,ScheduledInterview,Source,HiringAgency, - AgencyJobAssignment, AgencyAccessLink,Participants + ZoomMeeting, + Candidate, + TrainingMaterial, + JobPosting, + FormTemplate, + InterviewSchedule, + BreakTime, + JobPostingImage, + Profile, + MeetingComment, + ScheduledInterview, + Source, + HiringAgency, + AgencyJobAssignment, + AgencyAccessLink, + Participants, ) + # from django_summernote.widgets import SummernoteWidget from django_ckeditor_5.widgets import CKEditor5Widget import secrets @@ -20,79 +35,84 @@ import string from django.core.exceptions import ValidationError from django.utils import timezone + def generate_api_key(length=32): """Generate a secure API key""" alphabet = string.ascii_letters + string.digits - return ''.join(secrets.choice(alphabet) for _ in range(length)) + return "".join(secrets.choice(alphabet) for _ in range(length)) + def generate_api_secret(length=64): """Generate a secure API secret""" - alphabet = string.ascii_letters + string.digits + '-._~' - return ''.join(secrets.choice(alphabet) for _ in range(length)) + alphabet = string.ascii_letters + string.digits + "-._~" + return "".join(secrets.choice(alphabet) for _ in range(length)) + class SourceForm(forms.ModelForm): """Simple form for creating and editing sources""" class Meta: model = Source - fields = [ - 'name', 'source_type', 'description', 'ip_address', 'is_active' - ] + fields = ["name", "source_type", "description", "ip_address", "is_active"] widgets = { - 'name': forms.TextInput(attrs={ - 'class': 'form-control', - 'placeholder': 'e.g., ATS System, ERP Integration', - 'required': True - }), - 'source_type': forms.TextInput(attrs={ - 'class': 'form-control', - 'placeholder': 'e.g., ATS, ERP, API', - 'required': True - }), - 'description': forms.Textarea(attrs={ - 'class': 'form-control', - 'rows': 3, - 'placeholder': 'Brief description of the source system' - }), - 'ip_address': forms.TextInput(attrs={ - 'class': 'form-control', - 'placeholder': '192.168.1.100' - }), - 'is_active': forms.CheckboxInput(attrs={ - 'class': 'form-check-input' - }), + "name": forms.TextInput( + attrs={ + "class": "form-control", + "placeholder": "e.g., ATS System, ERP Integration", + "required": True, + } + ), + "source_type": forms.TextInput( + attrs={ + "class": "form-control", + "placeholder": "e.g., ATS, ERP, API", + "required": True, + } + ), + "description": forms.Textarea( + attrs={ + "class": "form-control", + "rows": 3, + "placeholder": "Brief description of the source system", + } + ), + "ip_address": forms.TextInput( + attrs={"class": "form-control", "placeholder": "192.168.1.100"} + ), + "is_active": forms.CheckboxInput(attrs={"class": "form-check-input"}), } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.helper = FormHelper() - self.helper.form_method = 'post' - self.helper.form_class = 'form-horizontal' - self.helper.label_class = 'col-md-3' - self.helper.field_class = 'col-md-9' + self.helper.form_method = "post" + self.helper.form_class = "form-horizontal" + self.helper.label_class = "col-md-3" + self.helper.field_class = "col-md-9" self.helper.layout = Layout( - Field('name', css_class='form-control'), - Field('source_type', css_class='form-control'), - Field('ip_address', css_class='form-control'), - Field('is_active', css_class='form-check-input'), - Submit('submit', 'Save Source', css_class='btn btn-primary mt-3') + Field("name", css_class="form-control"), + Field("source_type", css_class="form-control"), + Field("ip_address", css_class="form-control"), + Field("is_active", css_class="form-check-input"), + Submit("submit", "Save Source", css_class="btn btn-primary mt-3"), ) def clean_name(self): """Ensure source name is unique""" - name = self.cleaned_data.get('name') + name = self.cleaned_data.get("name") if name: # Check for duplicates excluding current instance if editing instance = self.instance if not instance.pk: # Creating new instance if Source.objects.filter(name=name).exists(): - raise ValidationError('A source with this name already exists.') + raise ValidationError("A source with this name already exists.") else: # Editing existing instance if Source.objects.filter(name=name).exclude(pk=instance.pk).exists(): - raise ValidationError('A source with this name already exists.') + raise ValidationError("A source with this name already exists.") return name + class SourceAdvancedForm(forms.ModelForm): """Advanced form for creating and editing sources with API key generation""" @@ -100,118 +120,126 @@ class SourceAdvancedForm(forms.ModelForm): generate_keys = forms.CharField( widget=forms.HiddenInput(), required=False, - help_text="Set to 'true' to generate new API keys" + help_text="Set to 'true' to generate new API keys", ) # Display fields for generated keys (read-only) api_key_generated = forms.CharField( label="Generated API Key", required=False, - widget=forms.TextInput(attrs={'readonly': True, 'class': 'form-control'}) + widget=forms.TextInput(attrs={"readonly": True, "class": "form-control"}), ) api_secret_generated = forms.CharField( label="Generated API Secret", required=False, - widget=forms.TextInput(attrs={'readonly': True, 'class': 'form-control'}) + widget=forms.TextInput(attrs={"readonly": True, "class": "form-control"}), ) class Meta: model = Source fields = [ - 'name', 'source_type', 'description', 'ip_address', - 'trusted_ips', 'is_active', 'integration_version' + "name", + "source_type", + "description", + "ip_address", + "trusted_ips", + "is_active", + "integration_version", ] widgets = { - 'name': forms.TextInput(attrs={ - 'class': 'form-control', - 'placeholder': 'e.g., ATS System, ERP Integration', - 'required': True - }), - 'source_type': forms.TextInput(attrs={ - 'class': 'form-control', - 'placeholder': 'e.g., ATS, ERP, API', - 'required': True - }), - 'description': forms.Textarea(attrs={ - 'class': 'form-control', - 'rows': 3, - 'placeholder': 'Brief description of the source system' - }), - 'ip_address': forms.TextInput(attrs={ - 'class': 'form-control', - 'placeholder': '192.168.1.100' - }), - 'trusted_ips': forms.Textarea(attrs={ - 'class': 'form-control', - 'rows': 2, - 'placeholder': 'Comma-separated IP addresses (e.g., 192.168.1.100, 10.0.0.1)' - }), - 'integration_version': forms.TextInput(attrs={ - 'class': 'form-control', - 'placeholder': 'v1.0, v2.1' - }), + "name": forms.TextInput( + attrs={ + "class": "form-control", + "placeholder": "e.g., ATS System, ERP Integration", + "required": True, + } + ), + "source_type": forms.TextInput( + attrs={ + "class": "form-control", + "placeholder": "e.g., ATS, ERP, API", + "required": True, + } + ), + "description": forms.Textarea( + attrs={ + "class": "form-control", + "rows": 3, + "placeholder": "Brief description of the source system", + } + ), + "ip_address": forms.TextInput( + attrs={"class": "form-control", "placeholder": "192.168.1.100"} + ), + "trusted_ips": forms.Textarea( + attrs={ + "class": "form-control", + "rows": 2, + "placeholder": "Comma-separated IP addresses (e.g., 192.168.1.100, 10.0.0.1)", + } + ), + "integration_version": forms.TextInput( + attrs={"class": "form-control", "placeholder": "v1.0, v2.1"} + ), } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.helper = FormHelper() - self.helper.form_method = 'post' - self.helper.form_class = 'form-horizontal' - self.helper.label_class = 'col-md-3' - self.helper.field_class = 'col-md-9' + self.helper.form_method = "post" + self.helper.form_class = "form-horizontal" + self.helper.label_class = "col-md-3" + self.helper.field_class = "col-md-9" # Add generate keys button self.helper.layout = Layout( - Field('name', css_class='form-control'), - Field('source_type', css_class='form-control'), - Field('description', css_class='form-control'), - Field('ip_address', css_class='form-control'), - Field('trusted_ips', css_class='form-control'), - Field('integration_version', css_class='form-control'), - Field('is_active', css_class='form-check-input'), - + Field("name", css_class="form-control"), + Field("source_type", css_class="form-control"), + Field("description", css_class="form-control"), + Field("ip_address", css_class="form-control"), + Field("trusted_ips", css_class="form-control"), + Field("integration_version", css_class="form-control"), + Field("is_active", css_class="form-check-input"), # Hidden field for key generation trigger - Field('generate_keys', type='hidden'), - + Field("generate_keys", type="hidden"), # Display fields for generated keys - Field('api_key_generated', css_class='form-control'), - Field('api_secret_generated', css_class='form-control'), - - Submit('submit', 'Save Source', css_class='btn btn-primary mt-3') + Field("api_key_generated", css_class="form-control"), + Field("api_secret_generated", css_class="form-control"), + Submit("submit", "Save Source", css_class="btn btn-primary mt-3"), ) def clean_name(self): """Ensure source name is unique""" - name = self.cleaned_data.get('name') + name = self.cleaned_data.get("name") if name: # Check for duplicates excluding current instance if editing instance = self.instance if not instance.pk: # Creating new instance if Source.objects.filter(name=name).exists(): - raise ValidationError('A source with this name already exists.') + raise ValidationError("A source with this name already exists.") else: # Editing existing instance if Source.objects.filter(name=name).exclude(pk=instance.pk).exists(): - raise ValidationError('A source with this name already exists.') + raise ValidationError("A source with this name already exists.") return name def clean_trusted_ips(self): """Validate and format trusted IP addresses""" - trusted_ips = self.cleaned_data.get('trusted_ips') + trusted_ips = self.cleaned_data.get("trusted_ips") if trusted_ips: # Split by comma and strip whitespace - ips = [ip.strip() for ip in trusted_ips.split(',') if ip.strip()] + ips = [ip.strip() for ip in trusted_ips.split(",") if ip.strip()] # Validate each IP address for ip in ips: try: # Basic IP validation (can be enhanced) - if not (ip.replace('.', '').isdigit() and len(ip.split('.')) == 4): - raise ValidationError(f'Invalid IP address: {ip}') + if not (ip.replace(".", "").isdigit() and len(ip.split(".")) == 4): + raise ValidationError(f"Invalid IP address: {ip}") except Exception: - raise ValidationError(f'Invalid IP address: {ip}') + raise ValidationError(f"Invalid IP address: {ip}") - return ', '.join(ips) + return ", ".join(ips) return trusted_ips def clean(self): @@ -219,147 +247,187 @@ class SourceAdvancedForm(forms.ModelForm): cleaned_data = super().clean() # Check if we need to generate API keys - generate_keys = cleaned_data.get('generate_keys') + generate_keys = cleaned_data.get("generate_keys") - if generate_keys == 'true': + if generate_keys == "true": # Generate new API key and secret - cleaned_data['api_key'] = generate_api_key() - cleaned_data['api_secret'] = generate_api_secret() + cleaned_data["api_key"] = generate_api_key() + cleaned_data["api_secret"] = generate_api_secret() # Set display fields for the frontend - cleaned_data['api_key_generated'] = cleaned_data['api_key'] - cleaned_data['api_secret_generated'] = cleaned_data['api_secret'] + cleaned_data["api_key_generated"] = cleaned_data["api_key"] + cleaned_data["api_secret_generated"] = cleaned_data["api_secret"] return cleaned_data + class CandidateForm(forms.ModelForm): class Meta: model = Candidate - fields = ['job', 'first_name', 'last_name', 'phone', 'email','hiring_source','hiring_agency', 'resume',] + fields = [ + "job", + "first_name", + "last_name", + "phone", + "email", + "hiring_source", + "hiring_agency", + "resume", + ] labels = { - 'first_name': _('First Name'), - 'last_name': _('Last Name'), - 'phone': _('Phone'), - 'email': _('Email'), - 'resume': _('Resume'), - 'hiring_source': _('Hiring Type'), - 'hiring_agency': _('Hiring Agency'), + "first_name": _("First Name"), + "last_name": _("Last Name"), + "phone": _("Phone"), + "email": _("Email"), + "resume": _("Resume"), + "hiring_source": _("Hiring Type"), + "hiring_agency": _("Hiring Agency"), } widgets = { - 'first_name': forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Enter first name')}), - 'last_name': forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Enter last name')}), - 'phone': forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Enter phone number')}), - 'email': forms.EmailInput(attrs={'class': 'form-control', 'placeholder': _('Enter email')}), - 'stage': forms.Select(attrs={'class': 'form-select'}), - 'hiring_source': forms.Select(attrs={'class': 'form-select'}), - 'hiring_agency': forms.Select(attrs={'class': 'form-select'}), + "first_name": forms.TextInput( + attrs={"class": "form-control", "placeholder": _("Enter first name")} + ), + "last_name": forms.TextInput( + attrs={"class": "form-control", "placeholder": _("Enter last name")} + ), + "phone": forms.TextInput( + attrs={"class": "form-control", "placeholder": _("Enter phone number")} + ), + "email": forms.EmailInput( + attrs={"class": "form-control", "placeholder": _("Enter email")} + ), + "stage": forms.Select(attrs={"class": "form-select"}), + "hiring_source": forms.Select(attrs={"class": "form-select"}), + "hiring_agency": forms.Select(attrs={"class": "form-select"}), } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.helper = FormHelper() - self.helper.form_method = 'post' - self.helper.form_class = 'form-horizontal' - self.helper.label_class = 'col-md-3' - self.helper.field_class = 'col-md-9' + self.helper.form_method = "post" + self.helper.form_class = "form-horizontal" + self.helper.label_class = "col-md-3" + self.helper.field_class = "col-md-9" # Make job field read-only if it's being pre-populated - job_value = self.initial.get('job') + job_value = self.initial.get("job") if job_value: - self.fields['job'].widget.attrs['readonly'] = True + self.fields["job"].widget.attrs["readonly"] = True self.helper.layout = Layout( - Field('job', css_class='form-control'), - Field('first_name', css_class='form-control'), - Field('last_name', css_class='form-control'), - Field('phone', css_class='form-control'), - Field('email', css_class='form-control'), - Field('stage', css_class='form-control'), - Field('hiring_source', css_class='form-control'), - Field('hiring_agency', css_class='form-control'), - Field('resume', css_class='form-control'), - Submit('submit', _('Submit'), css_class='btn btn-primary') - + Field("job", css_class="form-control"), + Field("first_name", css_class="form-control"), + Field("last_name", css_class="form-control"), + Field("phone", css_class="form-control"), + Field("email", css_class="form-control"), + Field("stage", css_class="form-control"), + Field("hiring_source", css_class="form-control"), + Field("hiring_agency", css_class="form-control"), + Field("resume", css_class="form-control"), + Submit("submit", _("Submit"), css_class="btn btn-primary"), ) + class CandidateStageForm(forms.ModelForm): """Form specifically for updating candidate stage with validation""" class Meta: model = Candidate - fields = ['stage'] + fields = ["stage"] labels = { - 'stage': _('New Application Stage'), + "stage": _("New Application Stage"), } widgets = { - 'stage': forms.Select(attrs={'class': 'form-select'}), + "stage": forms.Select(attrs={"class": "form-select"}), } + class ZoomMeetingForm(forms.ModelForm): class Meta: model = ZoomMeeting - fields = ['topic', 'start_time', 'duration'] + fields = ["topic", "start_time", "duration"] labels = { - 'topic': _('Topic'), - 'start_time': _('Start Time'), - 'duration': _('Duration'), + "topic": _("Topic"), + "start_time": _("Start Time"), + "duration": _("Duration"), } widgets = { - 'topic': forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Enter meeting topic'),}), - 'start_time': forms.DateTimeInput(attrs={'class': 'form-control','type': 'datetime-local'}), - 'duration': forms.NumberInput(attrs={'class': 'form-control','min': 1, 'placeholder': _('60')}), + "topic": forms.TextInput( + attrs={ + "class": "form-control", + "placeholder": _("Enter meeting topic"), + } + ), + "start_time": forms.DateTimeInput( + attrs={"class": "form-control", "type": "datetime-local"} + ), + "duration": forms.NumberInput( + attrs={"class": "form-control", "min": 1, "placeholder": _("60")} + ), } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.helper = FormHelper() - self.helper.form_method = 'post' - self.helper.form_class = 'form-horizontal' - self.helper.label_class = 'col-md-3' - self.helper.field_class = 'col-md-9' + self.helper.form_method = "post" + self.helper.form_class = "form-horizontal" + self.helper.label_class = "col-md-3" + self.helper.field_class = "col-md-9" self.helper.layout = Layout( - Field('topic', css_class='form-control'), - Field('start_time', css_class='form-control'), - Field('duration', css_class='form-control'), - Submit('submit', _('Create Meeting'), css_class='btn btn-primary') + Field("topic", css_class="form-control"), + Field("start_time", css_class="form-control"), + Field("duration", css_class="form-control"), + Submit("submit", _("Create Meeting"), css_class="btn btn-primary"), ) + class TrainingMaterialForm(forms.ModelForm): class Meta: model = TrainingMaterial - fields = ['title', 'content', 'video_link', 'file'] + fields = ["title", "content", "video_link", "file"] labels = { - 'title': _('Title'), - 'content': _('Content'), - 'video_link': _('Video Link'), - 'file': _('File'), + "title": _("Title"), + "content": _("Content"), + "video_link": _("Video Link"), + "file": _("File"), } widgets = { - 'title': forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Enter material title')}), - 'content': CKEditor5Widget(attrs={'placeholder': _('Enter material content')}), - 'video_link': forms.URLInput(attrs={'class': 'form-control', 'placeholder': _('https://www.youtube.com/watch?v=...')}), - 'file': forms.FileInput(attrs={'class': 'form-control'}), + "title": forms.TextInput( + attrs={ + "class": "form-control", + "placeholder": _("Enter material title"), + } + ), + "content": CKEditor5Widget( + attrs={"placeholder": _("Enter material content")} + ), + "video_link": forms.URLInput( + attrs={ + "class": "form-control", + "placeholder": _("https://www.youtube.com/watch?v=..."), + } + ), + "file": forms.FileInput(attrs={"class": "form-control"}), } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.helper = FormHelper() - self.helper.form_method = 'post' - self.helper.form_class = 'g-3' + self.helper.form_method = "post" + self.helper.form_class = "g-3" self.helper.layout = Layout( - 'title', - 'content', + "title", + "content", Row( - Column('video_link', css_class='col-md-6'), - Column('file', css_class='col-md-6'), - css_class='g-3 mb-4' + Column("video_link", css_class="col-md-6"), + Column("file", css_class="col-md-6"), + css_class="g-3 mb-4", ), Div( - Submit('submit', _('Create Material'), - css_class='btn btn-main-action'), - css_class='col-12 mt-4' - ) + Submit("submit", _("Create Material"), css_class="btn btn-main-action"), + css_class="col-12 mt-4", + ), ) @@ -369,117 +437,116 @@ class JobPostingForm(forms.ModelForm): class Meta: model = JobPosting fields = [ - 'title', 'department', 'job_type', 'workplace_type', - 'location_city', 'location_state', 'location_country', - 'description', 'qualifications', 'salary_range', 'benefits', - 'application_deadline', 'application_instructions', - 'position_number', 'reporting_to', - 'open_positions', 'hash_tags', 'max_applications' + "title", + "department", + "job_type", + "workplace_type", + "location_city", + "location_state", + "location_country", + "description", + "qualifications", + "salary_range", + "benefits", + "application_deadline", + "application_instructions", + "position_number", + "reporting_to", + "open_positions", + "hash_tags", + "max_applications", ] widgets = { # Basic Information - 'title': forms.TextInput(attrs={ - 'class': 'form-control', - 'placeholder': '', - 'required': True - }), - 'department': forms.TextInput(attrs={ - 'class': 'form-control', - 'placeholder': '' - }), - 'job_type': forms.Select(attrs={ - 'class': 'form-select', - 'required': True - }), - 'workplace_type': forms.Select(attrs={ - 'class': 'form-select', - 'required': True - }), - + "title": forms.TextInput( + attrs={"class": "form-control", "placeholder": "", "required": True} + ), + "department": forms.TextInput( + attrs={"class": "form-control", "placeholder": ""} + ), + "job_type": forms.Select(attrs={"class": "form-select", "required": True}), + "workplace_type": forms.Select( + attrs={"class": "form-select", "required": True} + ), # Location - 'location_city': forms.TextInput(attrs={ - 'class': 'form-control', - 'placeholder': 'Boston' - }), - 'location_state': forms.TextInput(attrs={ - 'class': 'form-control', - 'placeholder': 'MA' - }), - 'location_country': forms.TextInput(attrs={ - 'class': 'form-control', - 'value': 'United States' - }), - - 'salary_range': forms.TextInput(attrs={ - 'class': 'form-control', - 'placeholder': '$60,000 - $80,000' - }), - + "location_city": forms.TextInput( + attrs={"class": "form-control", "placeholder": "Boston"} + ), + "location_state": forms.TextInput( + attrs={"class": "form-control", "placeholder": "MA"} + ), + "location_country": forms.TextInput( + attrs={"class": "form-control", "value": "United States"} + ), + "salary_range": forms.TextInput( + attrs={"class": "form-control", "placeholder": "$60,000 - $80,000"} + ), # Application Information # 'application_url': forms.URLInput(attrs={ # 'class': 'form-control', # 'placeholder': 'https://university.edu/careers/job123', # 'required': True # }), - - 'application_deadline': forms.DateInput(attrs={ - 'class': 'form-control', - 'type': 'date', - 'required': True - }), - - 'open_positions': forms.NumberInput(attrs={ - 'class': 'form-control', - 'min': 1, - 'placeholder': 'Number of open positions' - }), - 'hash_tags': forms.TextInput(attrs={ - 'class': 'form-control', - 'placeholder': '#hiring,#jobopening', - # 'validators':validate_hash_tags, # Assuming this is available - }), - + "application_deadline": forms.DateInput( + attrs={"class": "form-control", "type": "date", "required": True} + ), + "open_positions": forms.NumberInput( + attrs={ + "class": "form-control", + "min": 1, + "placeholder": "Number of open positions", + } + ), + "hash_tags": forms.TextInput( + attrs={ + "class": "form-control", + "placeholder": "#hiring,#jobopening", + # 'validators':validate_hash_tags, # Assuming this is available + } + ), # Internal Information - 'position_number': forms.TextInput(attrs={ - 'class': 'form-control', - 'placeholder': 'UNIV-2025-001' - }), - 'reporting_to': forms.TextInput(attrs={ - 'class': 'form-control', - 'placeholder': 'Department Chair, Director, etc.' - }), - - 'max_applications': forms.NumberInput(attrs={ - 'class': 'form-control', - 'min': 1, - 'placeholder': 'Maximum number of applicants' - }), + "position_number": forms.TextInput( + attrs={"class": "form-control", "placeholder": "UNIV-2025-001"} + ), + "reporting_to": forms.TextInput( + attrs={ + "class": "form-control", + "placeholder": "Department Chair, Director, etc.", + } + ), + "max_applications": forms.NumberInput( + attrs={ + "class": "form-control", + "min": 1, + "placeholder": "Maximum number of applicants", + } + ), } def __init__(self, *args, **kwargs): - # Now call the parent __init__ with remaining args super().__init__(*args, **kwargs) if not self.instance.pk: # Creating new job posting # self.fields['status'].initial = 'Draft' - self.fields['location_city'].initial = 'Riyadh' - self.fields['location_state'].initial = 'Riyadh Province' - self.fields['location_country'].initial = 'Saudi Arabia' + self.fields["location_city"].initial = "Riyadh" + self.fields["location_state"].initial = "Riyadh Province" + self.fields["location_country"].initial = "Saudi Arabia" def clean_hash_tags(self): - hash_tags = self.cleaned_data.get('hash_tags') + hash_tags = self.cleaned_data.get("hash_tags") if hash_tags: - tags = [tag.strip() for tag in hash_tags.split(',') if tag.strip()] + tags = [tag.strip() for tag in hash_tags.split(",") if tag.strip()] for tag in tags: - if not tag.startswith('#'): + if not tag.startswith("#"): raise forms.ValidationError( - "Each hashtag must start with '#' symbol and must be comma(,) sepearted.") - return ','.join(tags) + "Each hashtag must start with '#' symbol and must be comma(,) sepearted." + ) + return ",".join(tags) return hash_tags # Allow blank def clean_title(self): - title = self.cleaned_data.get('title') + title = self.cleaned_data.get("title") if not title or len(title.strip()) < 3: raise forms.ValidationError("Job title must be at least 3 characters long.") if len(title) > 200: @@ -487,165 +554,209 @@ class JobPostingForm(forms.ModelForm): return title.strip() def clean_description(self): - description = self.cleaned_data.get('description') + description = self.cleaned_data.get("description") if not description or len(description.strip()) < 20: - raise forms.ValidationError("Job description must be at least 20 characters long.") + raise forms.ValidationError( + "Job description must be at least 20 characters long." + ) return description.strip() # to remove leading/trailing whitespace def clean_application_url(self): - url = self.cleaned_data.get('application_url') + url = self.cleaned_data.get("application_url") if url: validator = URLValidator() try: validator(url) except forms.ValidationError: - raise forms.ValidationError('Please enter a valid URL (e.g., https://example.com)') + raise forms.ValidationError( + "Please enter a valid URL (e.g., https://example.com)" + ) return url + class JobPostingImageForm(forms.ModelForm): class Meta: - model=JobPostingImage - fields=['post_image'] + model = JobPostingImage + fields = ["post_image"] + class FormTemplateForm(forms.ModelForm): """Form for creating form templates""" + class Meta: model = FormTemplate - fields = ['job','name', 'description', 'is_active'] + fields = ["job", "name", "description", "is_active"] labels = { - 'job': _('Job'), - 'name': _('Template Name'), - 'description': _('Description'), - 'is_active': _('Active'), + "job": _("Job"), + "name": _("Template Name"), + "description": _("Description"), + "is_active": _("Active"), } widgets = { - 'name': forms.TextInput(attrs={ - 'class': 'form-control', - 'placeholder': _('Enter template name'), - 'required': True - }), - 'description': forms.Textarea(attrs={ - 'class': 'form-control', - 'rows': 3, - 'placeholder': _('Enter template description (optional)') - }), - 'is_active': forms.CheckboxInput(attrs={ - 'class': 'form-check-input' - }) + "name": forms.TextInput( + attrs={ + "class": "form-control", + "placeholder": _("Enter template name"), + "required": True, + } + ), + "description": forms.Textarea( + attrs={ + "class": "form-control", + "rows": 3, + "placeholder": _("Enter template description (optional)"), + } + ), + "is_active": forms.CheckboxInput(attrs={"class": "form-check-input"}), } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.helper = FormHelper() - self.helper.form_method = 'post' - self.helper.form_class = 'form-horizontal' - self.helper.label_class = 'col-md-3' - self.helper.field_class = 'col-md-9' + self.helper.form_method = "post" + self.helper.form_class = "form-horizontal" + self.helper.label_class = "col-md-3" + self.helper.field_class = "col-md-9" self.helper.layout = Layout( - Field('job', css_class='form-control'), - Field('name', css_class='form-control'), - Field('description', css_class='form-control'), - Field('is_active', css_class='form-check-input'), - Submit('submit', _('Create Template'), css_class='btn btn-primary mt-3') + Field("job", css_class="form-control"), + Field("name", css_class="form-control"), + Field("description", css_class="form-control"), + Field("is_active", css_class="form-check-input"), + Submit("submit", _("Create Template"), css_class="btn btn-primary mt-3"), ) + class BreakTimeForm(forms.Form): """ A simple Form used for the BreakTimeFormSet. It is not a ModelForm because the data is stored directly in InterviewSchedule's JSONField, not in a separate BreakTime model instance. """ + start_time = forms.TimeField( - widget=forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}), - label="Start Time" + widget=forms.TimeInput(attrs={"type": "time", "class": "form-control"}), + label="Start Time", ) end_time = forms.TimeField( - widget=forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}), - label="End Time" + widget=forms.TimeInput(attrs={"type": "time", "class": "form-control"}), + label="End Time", ) + BreakTimeFormSet = formset_factory(BreakTimeForm, extra=1, can_delete=True) + class InterviewScheduleForm(forms.ModelForm): candidates = forms.ModelMultipleChoiceField( queryset=Candidate.objects.none(), widget=forms.CheckboxSelectMultiple, - required=True + required=True, ) working_days = forms.MultipleChoiceField( choices=[ - (0, 'Monday'), (1, 'Tuesday'), (2, 'Wednesday'), (3, 'Thursday'), - (4, 'Friday'), (5, 'Saturday'), (6, 'Sunday'), + (0, "Monday"), + (1, "Tuesday"), + (2, "Wednesday"), + (3, "Thursday"), + (4, "Friday"), + (5, "Saturday"), + (6, "Sunday"), ], widget=forms.CheckboxSelectMultiple, - required=True + required=True, ) class Meta: model = InterviewSchedule fields = [ - 'candidates', 'start_date', 'end_date', 'working_days', - 'start_time', 'end_time', 'interview_duration', 'buffer_time', - 'break_start_time', 'break_end_time' + "candidates", + "start_date", + "end_date", + "working_days", + "start_time", + "end_time", + "interview_duration", + "buffer_time", + "break_start_time", + "break_end_time", ] widgets = { - 'start_date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}), - 'end_date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}), - 'start_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}), - 'end_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}), - 'interview_duration': forms.NumberInput(attrs={'class': 'form-control'}), - 'buffer_time': forms.NumberInput(attrs={'class': 'form-control'}), - 'break_start_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}), - 'break_end_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}), + "start_date": forms.DateInput( + attrs={"type": "date", "class": "form-control"} + ), + "end_date": forms.DateInput( + attrs={"type": "date", "class": "form-control"} + ), + "start_time": forms.TimeInput( + attrs={"type": "time", "class": "form-control"} + ), + "end_time": forms.TimeInput( + attrs={"type": "time", "class": "form-control"} + ), + "interview_duration": forms.NumberInput(attrs={"class": "form-control"}), + "buffer_time": forms.NumberInput(attrs={"class": "form-control"}), + "break_start_time": forms.TimeInput( + attrs={"type": "time", "class": "form-control"} + ), + "break_end_time": forms.TimeInput( + attrs={"type": "time", "class": "form-control"} + ), } def __init__(self, slug, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields['candidates'].queryset = Candidate.objects.filter( - job__slug=slug, - stage='Interview' + self.fields["candidates"].queryset = Candidate.objects.filter( + job__slug=slug, stage="Interview" ) def clean_working_days(self): - working_days = self.cleaned_data.get('working_days') + working_days = self.cleaned_data.get("working_days") return [int(day) for day in working_days] + class MeetingCommentForm(forms.ModelForm): """Form for creating and editing meeting comments""" + class Meta: model = MeetingComment - fields = ['content'] + fields = ["content"] widgets = { - 'content': CKEditor5Widget( - attrs={'class': 'form-control', 'placeholder': _('Enter your comment or note')}, - config_name='extends' + "content": CKEditor5Widget( + attrs={ + "class": "form-control", + "placeholder": _("Enter your comment or note"), + }, + config_name="extends", ), } labels = { - 'content': _('Comment'), + "content": _("Comment"), } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.helper = FormHelper() - self.helper.form_method = 'post' - self.helper.form_class = 'form-horizontal' - self.helper.label_class = 'col-md-3' - self.helper.field_class = 'col-md-9' + self.helper.form_method = "post" + self.helper.form_class = "form-horizontal" + self.helper.label_class = "col-md-3" + self.helper.field_class = "col-md-9" self.helper.layout = Layout( - Field('content', css_class='form-control'), - Submit('submit', _('Add Comment'), css_class='btn btn-primary mt-3') + Field("content", css_class="form-control"), + Submit("submit", _("Add Comment"), css_class="btn btn-primary mt-3"), ) + class InterviewForm(forms.ModelForm): class Meta: model = ScheduledInterview - fields = ['job','candidate'] + fields = ["job", "candidate"] + class ProfileImageUploadForm(forms.ModelForm): class Meta: - model=Profile - fields=['profile_image'] + model = Profile + fields = ["profile_image"] + class StaffUserCreationForm(UserCreationForm): email = forms.EmailField(required=True) @@ -664,10 +775,10 @@ class StaffUserCreationForm(UserCreationForm): def generate_username(self, email): """Generate a valid, unique username from email.""" - prefix = email.split('@')[0].lower() - username = re.sub(r'[^a-z0-9._]', '', prefix) + prefix = email.split("@")[0].lower() + username = re.sub(r"[^a-z0-9._]", "", prefix) if not username: - username = 'user' + username = "user" base = username counter = 1 while User.objects.filter(username=username).exists(): @@ -686,173 +797,200 @@ class StaffUserCreationForm(UserCreationForm): user.save() return user + class ToggleAccountForm(forms.Form): pass + class JobPostingCancelReasonForm(forms.ModelForm): class Meta: model = JobPosting - fields = ['cancel_reason'] + fields = ["cancel_reason"] + class JobPostingStatusForm(forms.ModelForm): class Meta: model = JobPosting - fields = ['status'] + fields = ["status"] widgets = { - 'status': forms.Select(attrs={'class': 'form-select'}), + "status": forms.Select(attrs={"class": "form-select"}), } + + class LinkedPostContentForm(forms.ModelForm): class Meta: model = JobPosting - fields = ['linkedin_post_formated_data'] + fields = ["linkedin_post_formated_data"] + class FormTemplateIsActiveForm(forms.ModelForm): class Meta: model = FormTemplate - fields = ['is_active'] + fields = ["is_active"] + class CandidateExamDateForm(forms.ModelForm): class Meta: model = Candidate - fields = ['exam_date'] + fields = ["exam_date"] widgets = { - 'exam_date': forms.DateTimeInput(attrs={'type': 'datetime-local', 'class': 'form-control'}), + "exam_date": forms.DateTimeInput( + attrs={"type": "datetime-local", "class": "form-control"} + ), } + class HiringAgencyForm(forms.ModelForm): """Form for creating and editing hiring agencies""" class Meta: model = HiringAgency fields = [ - 'name', 'contact_person', 'email', 'phone', - 'website', 'country', 'address', 'notes' + "name", + "contact_person", + "email", + "phone", + "website", + "country", + "address", + "notes", ] widgets = { - 'name': forms.TextInput(attrs={ - 'class': 'form-control', - 'placeholder': 'Enter agency name', - 'required': True - }), - 'contact_person': forms.TextInput(attrs={ - 'class': 'form-control', - 'placeholder': 'Enter contact person name' - }), - 'email': forms.EmailInput(attrs={ - 'class': 'form-control', - 'placeholder': 'agency@example.com' - }), - 'phone': forms.TextInput(attrs={ - 'class': 'form-control', - 'placeholder': '+966 50 123 4567' - }), - 'website': forms.URLInput(attrs={ - 'class': 'form-control', - 'placeholder': 'https://www.agency.com' - }), - 'country': forms.Select(attrs={ - 'class': 'form-select' - }), - 'address': forms.Textarea(attrs={ - 'class': 'form-control', - 'rows': 3, - 'placeholder': 'Enter agency address' - }), - 'notes': forms.Textarea(attrs={ - 'class': 'form-control', - 'rows': 3, - 'placeholder': 'Internal notes about the agency' - }), + "name": forms.TextInput( + attrs={ + "class": "form-control", + "placeholder": "Enter agency name", + "required": True, + } + ), + "contact_person": forms.TextInput( + attrs={ + "class": "form-control", + "placeholder": "Enter contact person name", + } + ), + "email": forms.EmailInput( + attrs={"class": "form-control", "placeholder": "agency@example.com"} + ), + "phone": forms.TextInput( + attrs={"class": "form-control", "placeholder": "+966 50 123 4567"} + ), + "website": forms.URLInput( + attrs={"class": "form-control", "placeholder": "https://www.agency.com"} + ), + "country": forms.Select(attrs={"class": "form-select"}), + "address": forms.Textarea( + attrs={ + "class": "form-control", + "rows": 3, + "placeholder": "Enter agency address", + } + ), + "notes": forms.Textarea( + attrs={ + "class": "form-control", + "rows": 3, + "placeholder": "Internal notes about the agency", + } + ), } labels = { - 'name': _('Agency Name'), - 'contact_person': _('Contact Person'), - 'email': _('Email Address'), - 'phone': _('Phone Number'), - 'website': _('Website'), - 'country': _('Country'), - 'address': _('Address'), - 'notes': _('Internal Notes'), + "name": _("Agency Name"), + "contact_person": _("Contact Person"), + "email": _("Email Address"), + "phone": _("Phone Number"), + "website": _("Website"), + "country": _("Country"), + "address": _("Address"), + "notes": _("Internal Notes"), } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.helper = FormHelper() - self.helper.form_method = 'post' - self.helper.form_class = 'form-horizontal' - self.helper.label_class = 'col-md-3' - self.helper.field_class = 'col-md-9' + self.helper.form_method = "post" + self.helper.form_class = "form-horizontal" + self.helper.label_class = "col-md-3" + self.helper.field_class = "col-md-9" self.helper.layout = Layout( - Field('name', css_class='form-control'), - Field('contact_person', css_class='form-control'), + Field("name", css_class="form-control"), + Field("contact_person", css_class="form-control"), Row( - Column('email', css_class='col-md-6'), - Column('phone', css_class='col-md-6'), - css_class='g-3 mb-3' + Column("email", css_class="col-md-6"), + Column("phone", css_class="col-md-6"), + css_class="g-3 mb-3", ), - Field('website', css_class='form-control'), - Field('country', css_class='form-control'), - Field('address', css_class='form-control'), - Field('notes', css_class='form-control'), + Field("website", css_class="form-control"), + Field("country", css_class="form-control"), + Field("address", css_class="form-control"), + Field("notes", css_class="form-control"), Div( - Submit('submit', _('Save Agency'), css_class='btn btn-main-action'), - css_class='col-12 mt-4' - ) + Submit("submit", _("Save Agency"), css_class="btn btn-main-action"), + css_class="col-12 mt-4", + ), ) def clean_name(self): """Ensure agency name is unique""" - name = self.cleaned_data.get('name') + name = self.cleaned_data.get("name") if name: instance = self.instance if not instance.pk: # Creating new instance if HiringAgency.objects.filter(name=name).exists(): - raise ValidationError('An agency with this name already exists.') + raise ValidationError("An agency with this name already exists.") else: # Editing existing instance - if HiringAgency.objects.filter(name=name).exclude(pk=instance.pk).exists(): - raise ValidationError('An agency with this name already exists.') + if ( + HiringAgency.objects.filter(name=name) + .exclude(pk=instance.pk) + .exists() + ): + raise ValidationError("An agency with this name already exists.") return name.strip() def clean_email(self): """Validate email format and uniqueness""" - email = self.cleaned_data.get('email') + email = self.cleaned_data.get("email") if email: # Check email format - if not '@' in email or '.' not in email.split('@')[1]: - raise ValidationError('Please enter a valid email address.') + if not "@" in email or "." not in email.split("@")[1]: + raise ValidationError("Please enter a valid email address.") # Check uniqueness (optional - remove if multiple agencies can have same email) instance = self.instance if not instance.pk: # Creating new instance if HiringAgency.objects.filter(email=email).exists(): - raise ValidationError('An agency with this email already exists.') + raise ValidationError("An agency with this email already exists.") else: # Editing existing instance - if HiringAgency.objects.filter(email=email).exclude(pk=instance.pk).exists(): - raise ValidationError('An agency with this email already exists.') + if ( + HiringAgency.objects.filter(email=email) + .exclude(pk=instance.pk) + .exists() + ): + raise ValidationError("An agency with this email already exists.") return email.lower().strip() if email else email def clean_phone(self): """Validate phone number format""" - phone = self.cleaned_data.get('phone') + phone = self.cleaned_data.get("phone") if phone: # Remove common formatting characters - clean_phone = ''.join(c for c in phone if c.isdigit() or c in '+') + clean_phone = "".join(c for c in phone if c.isdigit() or c in "+") if len(clean_phone) < 10: - raise ValidationError('Phone number must be at least 10 digits long.') + raise ValidationError("Phone number must be at least 10 digits long.") return phone.strip() if phone else phone def clean_website(self): """Validate website URL""" - website = self.cleaned_data.get('website') + website = self.cleaned_data.get("website") if website: - if not website.startswith(('http://', 'https://')): - website = 'https://' + website + if not website.startswith(("http://", "https://")): + website = "https://" + website validator = URLValidator() try: validator(website) except ValidationError: - raise ValidationError('Please enter a valid website URL.') + raise ValidationError("Please enter a valid website URL.") return website @@ -861,105 +999,108 @@ class AgencyJobAssignmentForm(forms.ModelForm): class Meta: model = AgencyJobAssignment - fields = [ - 'agency', 'job', 'max_candidates', 'deadline_date','admin_notes' - ] + fields = ["agency", "job", "max_candidates", "deadline_date", "admin_notes"] widgets = { - 'agency': forms.Select(attrs={'class': 'form-select'}), - 'job': forms.Select(attrs={'class': 'form-select'}), - 'max_candidates': forms.NumberInput(attrs={ - 'class': 'form-control', - 'min': 1, - 'placeholder': 'Maximum number of candidates' - }), - 'deadline_date': forms.DateTimeInput(attrs={ - 'class': 'form-control', - 'type': 'datetime-local' - }), - 'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}), - 'status': forms.Select(attrs={'class': 'form-select'}), - 'admin_notes': forms.Textarea(attrs={ - 'class': 'form-control', - 'rows': 3, - 'placeholder': 'Internal notes about this assignment' - }), + "agency": forms.Select(attrs={"class": "form-select"}), + "job": forms.Select(attrs={"class": "form-select"}), + "max_candidates": forms.NumberInput( + attrs={ + "class": "form-control", + "min": 1, + "placeholder": "Maximum number of candidates", + } + ), + "deadline_date": forms.DateTimeInput( + attrs={"class": "form-control", "type": "datetime-local"} + ), + "is_active": forms.CheckboxInput(attrs={"class": "form-check-input"}), + "status": forms.Select(attrs={"class": "form-select"}), + "admin_notes": forms.Textarea( + attrs={ + "class": "form-control", + "rows": 3, + "placeholder": "Internal notes about this assignment", + } + ), } labels = { - 'agency': _('Agency'), - 'job': _('Job Posting'), - 'max_candidates': _('Maximum Candidates'), - 'deadline_date': _('Deadline Date'), - 'is_active': _('Is Active'), - 'status': _('Status'), - 'admin_notes': _('Admin Notes'), + "agency": _("Agency"), + "job": _("Job Posting"), + "max_candidates": _("Maximum Candidates"), + "deadline_date": _("Deadline Date"), + "is_active": _("Is Active"), + "status": _("Status"), + "admin_notes": _("Admin Notes"), } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.helper = FormHelper() - self.helper.form_method = 'post' - self.helper.form_class = 'form-horizontal' - self.helper.label_class = 'col-md-3' - self.helper.field_class = 'col-md-9' + self.helper.form_method = "post" + self.helper.form_class = "form-horizontal" + self.helper.label_class = "col-md-3" + self.helper.field_class = "col-md-9" # Filter jobs to only show active jobs - self.fields['job'].queryset = JobPosting.objects.filter( - status='ACTIVE' - ).order_by('-created_at') + self.fields["job"].queryset = JobPosting.objects.filter( + status="ACTIVE" + ).order_by("-created_at") self.helper.layout = Layout( Row( - Column('agency', css_class='col-md-6'), - Column('job', css_class='col-md-6'), - css_class='g-3 mb-3' + Column("agency", css_class="col-md-6"), + Column("job", css_class="col-md-6"), + css_class="g-3 mb-3", ), Row( - Column('max_candidates', css_class='col-md-6'), - Column('deadline_date', css_class='col-md-6'), - css_class='g-3 mb-3' + Column("max_candidates", css_class="col-md-6"), + Column("deadline_date", css_class="col-md-6"), + css_class="g-3 mb-3", ), Row( - Column('is_active', css_class='col-md-6'), - Column('status', css_class='col-md-6'), - css_class='g-3 mb-3' + Column("is_active", css_class="col-md-6"), + Column("status", css_class="col-md-6"), + css_class="g-3 mb-3", ), - Field('admin_notes', css_class='form-control'), + Field("admin_notes", css_class="form-control"), Div( - Submit('submit', _('Save Assignment'), css_class='btn btn-main-action'), - css_class='col-12 mt-4' - ) + Submit("submit", _("Save Assignment"), css_class="btn btn-main-action"), + css_class="col-12 mt-4", + ), ) def clean_deadline_date(self): """Validate deadline date is in the future""" - deadline_date = self.cleaned_data.get('deadline_date') + deadline_date = self.cleaned_data.get("deadline_date") if deadline_date and deadline_date <= timezone.now(): - raise ValidationError('Deadline date must be in the future.') + raise ValidationError("Deadline date must be in the future.") return deadline_date def clean_max_candidates(self): """Validate maximum candidates is positive""" - max_candidates = self.cleaned_data.get('max_candidates') + max_candidates = self.cleaned_data.get("max_candidates") if max_candidates and max_candidates <= 0: - raise ValidationError('Maximum candidates must be greater than 0.') + raise ValidationError("Maximum candidates must be greater than 0.") return max_candidates def clean(self): """Check for duplicate assignments""" cleaned_data = super().clean() - agency = cleaned_data.get('agency') - job = cleaned_data.get('job') + agency = cleaned_data.get("agency") + job = cleaned_data.get("job") if agency and job: # Check if this assignment already exists - existing = AgencyJobAssignment.objects.filter( - agency=agency, job=job - ).exclude(pk=self.instance.pk).first() + existing = ( + AgencyJobAssignment.objects.filter(agency=agency, job=job) + .exclude(pk=self.instance.pk) + .first() + ) if existing: raise ValidationError( - f'This job is already assigned to {agency.name}. ' - f'Current status: {existing.get_status_display()}' + f"This job is already assigned to {agency.name}. " + f"Current status: {existing.get_status_display()}" ) return cleaned_data @@ -970,54 +1111,52 @@ class AgencyAccessLinkForm(forms.ModelForm): class Meta: model = AgencyAccessLink - fields = [ - 'assignment', 'expires_at', 'is_active' - ] + fields = ["assignment", "expires_at", "is_active"] widgets = { - 'assignment': forms.Select(attrs={'class': 'form-select'}), - 'expires_at': forms.DateTimeInput(attrs={ - 'class': 'form-control', - 'type': 'datetime-local' - }), - 'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}), + "assignment": forms.Select(attrs={"class": "form-select"}), + "expires_at": forms.DateTimeInput( + attrs={"class": "form-control", "type": "datetime-local"} + ), + "is_active": forms.CheckboxInput(attrs={"class": "form-check-input"}), } labels = { - 'assignment': _('Assignment'), - 'expires_at': _('Expires At'), - 'is_active': _('Is Active'), + "assignment": _("Assignment"), + "expires_at": _("Expires At"), + "is_active": _("Is Active"), } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.helper = FormHelper() - self.helper.form_method = 'post' - self.helper.form_class = 'form-horizontal' - self.helper.label_class = 'col-md-3' - self.helper.field_class = 'col-md-9' + self.helper.form_method = "post" + self.helper.form_class = "form-horizontal" + self.helper.label_class = "col-md-3" + self.helper.field_class = "col-md-9" # Filter assignments to only show active ones without existing links - self.fields['assignment'].queryset = AgencyJobAssignment.objects.filter( - is_active=True, - status='ACTIVE' - ).exclude( - access_link__isnull=False - ).order_by('-created_at') + self.fields["assignment"].queryset = ( + AgencyJobAssignment.objects.filter(is_active=True, status="ACTIVE") + .exclude(access_link__isnull=False) + .order_by("-created_at") + ) self.helper.layout = Layout( - Field('assignment', css_class='form-control'), - Field('expires_at', css_class='form-control'), - Field('is_active', css_class='form-check-input'), + Field("assignment", css_class="form-control"), + Field("expires_at", css_class="form-control"), + Field("is_active", css_class="form-check-input"), Div( - Submit('submit', _('Create Access Link'), css_class='btn btn-main-action'), - css_class='col-12 mt-4' - ) + Submit( + "submit", _("Create Access Link"), css_class="btn btn-main-action" + ), + css_class="col-12 mt-4", + ), ) def clean_expires_at(self): """Validate expiration date is in the future""" - expires_at = self.cleaned_data.get('expires_at') + expires_at = self.cleaned_data.get("expires_at") if expires_at and expires_at <= timezone.now(): - raise ValidationError('Expiration date must be in the future.') + raise ValidationError("Expiration date must be in the future.") return expires_at @@ -1029,101 +1168,108 @@ class AgencyCandidateSubmissionForm(forms.ModelForm): class Meta: model = Candidate - fields = [ - 'first_name', 'last_name', 'email', 'phone', 'resume' - ] + fields = ["first_name", "last_name", "email", "phone", "resume"] widgets = { - 'first_name': forms.TextInput(attrs={ - 'class': 'form-control', - 'placeholder': 'First Name', - 'required': True - }), - 'last_name': forms.TextInput(attrs={ - 'class': 'form-control', - 'placeholder': 'Last Name', - 'required': True - }), - 'email': forms.EmailInput(attrs={ - 'class': 'form-control', - 'placeholder': 'email@example.com', - 'required': True - }), - 'phone': forms.TextInput(attrs={ - 'class': 'form-control', - 'placeholder': '+966 50 123 4567', - 'required': True - }), - 'resume': forms.FileInput(attrs={ - 'class': 'form-control', - 'accept': '.pdf,.doc,.docx', - 'required': True - }), + "first_name": forms.TextInput( + attrs={ + "class": "form-control", + "placeholder": "First Name", + "required": True, + } + ), + "last_name": forms.TextInput( + attrs={ + "class": "form-control", + "placeholder": "Last Name", + "required": True, + } + ), + "email": forms.EmailInput( + attrs={ + "class": "form-control", + "placeholder": "email@example.com", + "required": True, + } + ), + "phone": forms.TextInput( + attrs={ + "class": "form-control", + "placeholder": "+966 50 123 4567", + "required": True, + } + ), + "resume": forms.FileInput( + attrs={ + "class": "form-control", + "accept": ".pdf,.doc,.docx", + "required": True, + } + ), } labels = { - 'first_name': _('First Name'), - 'last_name': _('Last Name'), - 'email': _('Email Address'), - 'phone': _('Phone Number'), - 'resume': _('Resume'), + "first_name": _("First Name"), + "last_name": _("Last Name"), + "email": _("Email Address"), + "phone": _("Phone Number"), + "resume": _("Resume"), } def __init__(self, assignment, *args, **kwargs): super().__init__(*args, **kwargs) self.assignment = assignment self.helper = FormHelper() - self.helper.form_method = 'post' - self.helper.form_class = 'g-3' - self.helper.enctype = 'multipart/form-data' + self.helper.form_method = "post" + self.helper.form_class = "g-3" + self.helper.enctype = "multipart/form-data" self.helper.layout = Layout( Row( - Column('first_name', css_class='col-md-6'), - Column('last_name', css_class='col-md-6'), - css_class='g-3 mb-3' + Column("first_name", css_class="col-md-6"), + Column("last_name", css_class="col-md-6"), + css_class="g-3 mb-3", ), Row( - Column('email', css_class='col-md-6'), - Column('phone', css_class='col-md-6'), - css_class='g-3 mb-3' + Column("email", css_class="col-md-6"), + Column("phone", css_class="col-md-6"), + css_class="g-3 mb-3", ), - Field('resume', css_class='form-control'), + Field("resume", css_class="form-control"), Div( - Submit('submit', _('Submit Candidate'), css_class='btn btn-main-action'), - css_class='col-12 mt-4' - ) + Submit( + "submit", _("Submit Candidate"), css_class="btn btn-main-action" + ), + css_class="col-12 mt-4", + ), ) def clean_email(self): """Validate email format and check for duplicates in the same job""" - email = self.cleaned_data.get('email') + email = self.cleaned_data.get("email") if email: # Check if candidate with this email already exists for this job existing_candidate = Candidate.objects.filter( - email=email.lower().strip(), - job=self.assignment.job + email=email.lower().strip(), job=self.assignment.job ).first() if existing_candidate: raise ValidationError( - f'A candidate with this email has already applied for {self.assignment.job.title}.' + f"A candidate with this email has already applied for {self.assignment.job.title}." ) return email.lower().strip() if email else email def clean_resume(self): """Validate resume file""" - resume = self.cleaned_data.get('resume') + resume = self.cleaned_data.get("resume") if resume: # Check file size (max 5MB) if resume.size > 5 * 1024 * 1024: - raise ValidationError('Resume file size must be less than 5MB.') + raise ValidationError("Resume file size must be less than 5MB.") # Check file extension - allowed_extensions = ['.pdf', '.doc', '.docx'] - file_extension = resume.name.lower().split('.')[-1] - if f'.{file_extension}' not in allowed_extensions: - raise ValidationError( - 'Resume must be in PDF, DOC, or DOCX format.' - ) + allowed_extensions = [".pdf", ".doc", ".docx"] + file_extension = resume.name.lower().split(".")[-1] + if f".{file_extension}" not in allowed_extensions: + raise ValidationError("Resume must be in PDF, DOC, or DOCX format.") return resume def save(self, commit=True): @@ -1148,21 +1294,52 @@ class AgencyLoginForm(forms.Form): """Form for agencies to login with token and password""" token = forms.CharField( - widget=forms.TextInput(attrs={ - 'class': 'form-control', - 'placeholder': 'Enter your access token' - }), - label=_('Access Token'), - required=True + widget=forms.TextInput( + attrs={"class": "form-control", "placeholder": "Enter your access token"} + ), + label=_("Access Token"), + required=True, ) password = forms.CharField( - widget=forms.PasswordInput(attrs={ - 'class': 'form-control', - 'placeholder': 'Enter your password' - }), - label=_('Password'), - required=True + widget=forms.PasswordInput( + attrs={"class": "form-control", "placeholder": "Enter your password"} + ), + label=_("Password"), + required=True, + ) + + +class PortalLoginForm(forms.Form): + """Unified login form for agency and candidate""" + + USER_TYPE_CHOICES = [ + ("", _("Select User Type")), + ("agency", _("Agency")), + ("candidate", _("Candidate")), + ] + + email = forms.EmailField( + widget=forms.EmailInput( + attrs={"class": "form-control", "placeholder": "Enter your email"} + ), + label=_("Email"), + required=True, + ) + + password = forms.CharField( + widget=forms.PasswordInput( + attrs={"class": "form-control", "placeholder": "Enter your password"} + ), + label=_("Password"), + required=True, + ) + + user_type = forms.ChoiceField( + choices=USER_TYPE_CHOICES, + widget=forms.Select(attrs={"class": "form-control"}), + label=_("User Type"), + required=True, ) # def __init__(self, *args, **kwargs): @@ -1183,62 +1360,60 @@ class AgencyLoginForm(forms.Form): def clean(self): """Validate token and password combination""" cleaned_data = super().clean() - token = cleaned_data.get('token') - password = cleaned_data.get('password') + token = cleaned_data.get("token") + password = cleaned_data.get("password") if token and password: try: access_link = AgencyAccessLink.objects.get( - unique_token=token, - is_active=True + unique_token=token, is_active=True ) if not access_link.is_valid: if access_link.is_expired: - raise ValidationError('This access link has expired.') + raise ValidationError("This access link has expired.") else: - raise ValidationError('This access link is no longer active.') + raise ValidationError("This access link is no longer active.") if access_link.access_password != password: - raise ValidationError('Invalid password.') + raise ValidationError("Invalid password.") # Store the access_link for use in the view self.validated_access_link = access_link except AgencyAccessLink.DoesNotExist: print("Access link does not exist") - raise ValidationError('Invalid access token.') + raise ValidationError("Invalid access token.") return cleaned_data - - - -#participants form +# participants form class ParticipantsForm(forms.ModelForm): """Form for creating and editing Participants""" class Meta: - model = Participants - fields = ['name', 'email', 'phone', 'designation'] + model = Participants + fields = ["name", "email", "phone", "designation"] widgets = { - 'name': forms.TextInput(attrs={ - 'class': 'form-control', - 'placeholder': 'Enter participant name', - 'required': True - }), - 'email': forms.EmailInput(attrs={ - 'class': 'form-control', - 'placeholder': 'Enter email address', - 'required': True - }), - 'phone': forms.TextInput(attrs={ - 'class': 'form-control', - 'placeholder': 'Enter phone number' - }), - 'designation': forms.TextInput(attrs={ - 'class': 'form-control', - 'placeholder': 'Enter designation' - }), + "name": forms.TextInput( + attrs={ + "class": "form-control", + "placeholder": "Enter participant name", + "required": True, + } + ), + "email": forms.EmailInput( + attrs={ + "class": "form-control", + "placeholder": "Enter email address", + "required": True, + } + ), + "phone": forms.TextInput( + attrs={"class": "form-control", "placeholder": "Enter phone number"} + ), + "designation": forms.TextInput( + attrs={"class": "form-control", "placeholder": "Enter designation"} + ), # 'jobs': forms.CheckboxSelectMultiple(), } @@ -1246,21 +1421,23 @@ class ParticipantsForm(forms.ModelForm): class ParticipantsSelectForm(forms.ModelForm): """Form for selecting Participants""" - participants=forms.ModelMultipleChoiceField( + participants = forms.ModelMultipleChoiceField( queryset=Participants.objects.all(), widget=forms.CheckboxSelectMultiple, required=False, - label=_("Select Participants")) + label=_("Select Participants"), + ) - users=forms.ModelMultipleChoiceField( + users = forms.ModelMultipleChoiceField( queryset=User.objects.all(), widget=forms.CheckboxSelectMultiple, required=False, - label=_("Select Users")) + label=_("Select Users"), + ) class Meta: model = JobPosting - fields = ['participants','users'] # No direct fields from Participants model + fields = ["participants", "users"] # No direct fields from Participants model class CandidateEmailForm(forms.Form): @@ -1268,53 +1445,50 @@ class CandidateEmailForm(forms.Form): subject = forms.CharField( max_length=200, - widget=forms.TextInput(attrs={ - 'class': 'form-control', - 'placeholder': 'Enter email subject', - 'required': True - }), - label=_('Subject'), - required=True + widget=forms.TextInput( + attrs={ + "class": "form-control", + "placeholder": "Enter email subject", + "required": True, + } + ), + label=_("Subject"), + required=True, ) message = forms.CharField( - widget=forms.Textarea(attrs={ - 'class': 'form-control', - 'rows': 8, - 'placeholder': 'Enter your message here...', - 'required': True - }), - label=_('Message'), - required=True + widget=forms.Textarea( + attrs={ + "class": "form-control", + "rows": 8, + "placeholder": "Enter your message here...", + "required": True, + } + ), + label=_("Message"), + required=True, ) recipients = forms.MultipleChoiceField( - widget=forms.CheckboxSelectMultiple(attrs={ - 'class': 'form-check' - }), - label=_('Recipients'), - required=True + widget=forms.CheckboxSelectMultiple(attrs={"class": "form-check"}), + label=_("Recipients"), + required=True, ) include_candidate_info = forms.BooleanField( - widget=forms.CheckboxInput(attrs={ - 'class': 'form-check-input' - }), - label=_('Include candidate information'), + widget=forms.CheckboxInput(attrs={"class": "form-check-input"}), + label=_("Include candidate information"), initial=True, - required=False + required=False, ) include_meeting_details = forms.BooleanField( - widget=forms.CheckboxInput(attrs={ - 'class': 'form-check-input' - }), - label=_('Include meeting details'), + widget=forms.CheckboxInput(attrs={"class": "form-check-input"}), + label=_("Include meeting details"), initial=True, - required=False + required=False, ) - def __init__(self, job, candidate, *args, **kwargs): super().__init__(*args, **kwargs) self.job = job @@ -1326,25 +1500,35 @@ class CandidateEmailForm(forms.Form): # Add job participants for participant in job.participants.all(): recipient_choices.append( - (f'participant_{participant.id}', f'{participant.name} - {participant.designation} (Participant)') + ( + f"participant_{participant.id}", + f"{participant.name} - {participant.designation} (Participant)", + ) ) # Add job users for user in job.users.all(): recipient_choices.append( - (f'user_{user.id}', f'{user.get_full_name() or user.username} - {user.email} (User)') + ( + f"user_{user.id}", + f"{user.get_full_name() or user.username} - {user.email} (User)", + ) ) - self.fields['recipients'].choices = recipient_choices - self.fields['recipients'].initial = [choice[0] for choice in recipient_choices] # Select all by default + self.fields["recipients"].choices = recipient_choices + self.fields["recipients"].initial = [ + choice[0] for choice in recipient_choices + ] # Select all by default # Set initial subject - self.fields['subject'].initial = f'Interview Update: {candidate.name} - {job.title}' + self.fields[ + "subject" + ].initial = f"Interview Update: {candidate.name} - {job.title}" # Set initial message with candidate and meeting info initial_message = self._get_initial_message() if initial_message: - self.fields['message'].initial = initial_message + self.fields["message"].initial = initial_message def _get_initial_message(self): """Generate initial message with candidate and meeting information""" @@ -1362,34 +1546,36 @@ class CandidateEmailForm(forms.Form): if latest_meeting: message_parts.append(f"\nMeeting Information:") message_parts.append(f"Topic: {latest_meeting.topic}") - message_parts.append(f"Date & Time: {latest_meeting.start_time.strftime('%B %d, %Y at %I:%M %p')}") + message_parts.append( + f"Date & Time: {latest_meeting.start_time.strftime('%B %d, %Y at %I:%M %p')}" + ) message_parts.append(f"Duration: {latest_meeting.duration} minutes") if latest_meeting.join_url: message_parts.append(f"Join URL: {latest_meeting.join_url}") - return '\n'.join(message_parts) + return "\n".join(message_parts) def clean_recipients(self): """Ensure at least one recipient is selected""" - recipients = self.cleaned_data.get('recipients') + recipients = self.cleaned_data.get("recipients") if not recipients: - raise forms.ValidationError(_('Please select at least one recipient.')) + raise forms.ValidationError(_("Please select at least one recipient.")) return recipients def get_email_addresses(self): """Extract email addresses from selected recipients""" email_addresses = [] - recipients = self.cleaned_data.get('recipients', []) + recipients = self.cleaned_data.get("recipients", []) for recipient in recipients: - if recipient.startswith('participant_'): - participant_id = recipient.split('_')[1] + if recipient.startswith("participant_"): + participant_id = recipient.split("_")[1] try: participant = Participants.objects.get(id=participant_id) email_addresses.append(participant.email) except Participants.DoesNotExist: continue - elif recipient.startswith('user_'): - user_id = recipient.split('_')[1] + elif recipient.startswith("user_"): + user_id = recipient.split("_")[1] try: user = User.objects.get(id=user_id) email_addresses.append(user.email) @@ -1400,10 +1586,10 @@ class CandidateEmailForm(forms.Form): def get_formatted_message(self): """Get the formatted message with optional additional information""" - message = self.cleaned_data.get('message', '') + message = self.cleaned_data.get("message", "") # Add candidate information if requested - if self.cleaned_data.get('include_candidate_info') and self.candidate: + if self.cleaned_data.get("include_candidate_info") and self.candidate: candidate_info = f"\n\n--- Candidate Information ---\n" candidate_info += f"Name: {self.candidate.name}\n" candidate_info += f"Email: {self.candidate.email}\n" @@ -1411,7 +1597,7 @@ class CandidateEmailForm(forms.Form): message += candidate_info # Add meeting details if requested - if self.cleaned_data.get('include_meeting_details') and self.candidate: + if self.cleaned_data.get("include_meeting_details") and self.candidate: latest_meeting = self.candidate.get_latest_meeting if latest_meeting: meeting_info = f"\n\n--- Meeting Details ---\n" diff --git a/recruitment/management/__pycache__/__init__.cpython-313.pyc b/recruitment/management/__pycache__/__init__.cpython-313.pyc index 2374032674d16b33d73fa136c8a40583f4b910c8..8a43ab6613dff3ee16012c8f7d5801c7a4696068 100644 GIT binary patch delta 20 acmZ3^xSWyaGcPX}0}#yo!I?RcXEp#h3 0: vacancy_fill_rate = no_of_positions_filled / total_positions @@ -381,10 +440,11 @@ class JobPosting(Base): return vacancy_fill_rate - class JobPostingImage(models.Model): - job=models.OneToOneField('JobPosting',on_delete=models.CASCADE,related_name='post_images') - post_image = models.ImageField(upload_to='post/',validators=[validate_image_size]) + job = models.OneToOneField( + "JobPosting", on_delete=models.CASCADE, related_name="post_images" + ) + post_image = models.ImageField(upload_to="post/", validators=[validate_image_size]) class Candidate(Base): @@ -415,6 +475,14 @@ class Candidate(Base): "Offer": [], # Final stage - no further transitions } + user = models.OneToOneField( + User, + on_delete=models.CASCADE, + related_name="candidate_profile", + verbose_name=_("User"), + null=True, + blank=True, + ) job = models.ForeignKey( JobPosting, on_delete=models.CASCADE, @@ -423,7 +491,7 @@ class Candidate(Base): ) 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, verbose_name=_("Email")) # Added index + email = models.EmailField(db_index=True, verbose_name=_("Email")) # Added index 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")) @@ -436,7 +504,8 @@ class Candidate(Base): parsed_summary = models.TextField(blank=True, verbose_name=_("Parsed Summary")) applied = models.BooleanField(default=False, verbose_name=_("Applied")) stage = models.CharField( - db_index=True, max_length=100, # Added index + db_index=True, + max_length=100, # Added index default="Applied", choices=Stage.choices, verbose_name=_("Stage"), @@ -480,10 +549,12 @@ class Candidate(Base): ai_analysis_data = models.JSONField( verbose_name="AI Analysis Data", default=dict, - help_text="Full JSON output from the resume scoring model." - )# {'resume_data': {}, 'analysis_data': {}} + help_text="Full JSON output from the resume scoring model.", + null=True, + blank=True, + ) # {'resume_data': {}, 'analysis_data': {}} - retry = models.SmallIntegerField(verbose_name="Resume Parsing Retry",default=3) + retry = models.SmallIntegerField(verbose_name="Resume Parsing Retry", default=3) hiring_source = models.CharField( max_length=255, null=True, @@ -509,9 +580,10 @@ class Candidate(Base): verbose_name = _("Candidate") verbose_name_plural = _("Candidates") indexes = [ - models.Index(fields=['stage']), - models.Index(fields=['created_at']), + models.Index(fields=["stage"]), + models.Index(fields=["created_at"]), ] + def set_field(self, key: str, value: Any): """ Generic method to set any single key-value pair and save. @@ -524,92 +596,94 @@ class Candidate(Base): # ==================================================================== @property def resume_data(self): - return self.ai_analysis_data.get('resume_data', {}) + return self.ai_analysis_data.get("resume_data", {}) + @property def analysis_data(self): - return self.ai_analysis_data.get('analysis_data', {}) + return self.ai_analysis_data.get("analysis_data", {}) + @property def match_score(self) -> int: """1. A score from 0 to 100 representing how well the candidate fits the role.""" - return self.analysis_data.get('match_score', 0) + return self.analysis_data.get("match_score", 0) @property def years_of_experience(self) -> float: """4. The total number of years of professional experience as a numerical value.""" - return self.analysis_data.get('years_of_experience', 0.0) + return self.analysis_data.get("years_of_experience", 0.0) @property def soft_skills_score(self) -> int: """15. A score (0-100) for inferred non-technical skills.""" - return self.analysis_data.get('soft_skills_score', 0) + return self.analysis_data.get("soft_skills_score", 0) @property def industry_match_score(self) -> int: """16. A score (0-100) for the relevance of the candidate's industry experience.""" # Renamed to clarify: experience_industry_match - return self.analysis_data.get('experience_industry_match', 0) + return self.analysis_data.get("experience_industry_match", 0) # --- Properties for Funnel & Screening Efficiency --- @property def min_requirements_met(self) -> bool: """14. Boolean (true/false) indicating if all non-negotiable minimum requirements are met.""" - return self.analysis_data.get('min_req_met_bool', False) + return self.analysis_data.get("min_req_met_bool", False) @property def screening_stage_rating(self) -> str: """13. A standardized rating (e.g., "A - Highly Qualified", "B - Qualified").""" - return self.analysis_data.get('screening_stage_rating', 'N/A') + return self.analysis_data.get("screening_stage_rating", "N/A") @property def top_3_keywords(self) -> List[str]: """10. A list of the three most dominant and relevant technical skills or technologies.""" - return self.analysis_data.get('top_3_keywords', []) + return self.analysis_data.get("top_3_keywords", []) @property def most_recent_job_title(self) -> str: """8. The candidate's most recent or current professional job title.""" - return self.analysis_data.get('most_recent_job_title', 'N/A') + return self.analysis_data.get("most_recent_job_title", "N/A") # --- Properties for Structured Detail --- @property def criteria_checklist(self) -> Dict[str, str]: """5 & 6. An object rating the candidate's match for each specific criterion.""" - return self.analysis_data.get('criteria_checklist', {}) + return self.analysis_data.get("criteria_checklist", {}) @property def professional_category(self) -> str: """7. The most fitting professional field or category for the individual.""" - return self.analysis_data.get('category', 'N/A') + return self.analysis_data.get("category", "N/A") @property def language_fluency(self) -> List[Dict[str, str]]: """12. A list of languages and their fluency levels mentioned.""" - return self.analysis_data.get('language_fluency', []) + return self.analysis_data.get("language_fluency", []) # --- Properties for Summaries and Narrative --- @property def strengths(self) -> str: """2. A brief summary of why the candidate is a strong fit.""" - return self.analysis_data.get('strengths', '') + return self.analysis_data.get("strengths", "") @property def weaknesses(self) -> str: """3. A brief summary of where the candidate falls short or what criteria are missing.""" - return self.analysis_data.get('weaknesses', '') + return self.analysis_data.get("weaknesses", "") @property def job_fit_narrative(self) -> str: """11. A single, concise sentence summarizing the core fit.""" - return self.analysis_data.get('job_fit_narrative', '') + return self.analysis_data.get("job_fit_narrative", "") @property def recommendation(self) -> str: """9. Provide a detailed final recommendation for the candidate.""" # Using a more descriptive name to avoid conflict with potential built-in methods - return self.analysis_data.get('recommendation', '') + return self.analysis_data.get("recommendation", "") @property def name(self): @@ -625,7 +699,6 @@ class Candidate(Base): return self.resume.size return 0 - def save(self, *args, **kwargs): """Override save to ensure validation is called""" self.clean() # Call validation before saving @@ -642,20 +715,23 @@ class Candidate(Base): @property def submission(self): return FormSubmission.objects.filter(template__job=self.job).first() + @property def responses(self): if self.submission: return self.submission.responses.all() return [] + def __str__(self): return self.full_name @property def get_meetings(self): return self.scheduled_interviews.all() + @property def get_latest_meeting(self): - schedule = self.scheduled_interviews.order_by('-created_at').first() + schedule = self.scheduled_interviews.order_by("-created_at").first() if schedule: return schedule.zoom_meeting return None @@ -669,16 +745,15 @@ class Candidate(Base): now = timezone.now() # Check if any related ScheduledInterview has a future interview_date and interview_time # We need to combine date and time for a proper datetime comparison if they are separate fields - future_meetings = self.scheduled_interviews.filter( - interview_date__gt=now.date() - ).filter( - interview_time__gte=now.time() - ).exists() + future_meetings = ( + self.scheduled_interviews.filter(interview_date__gt=now.date()) + .filter(interview_time__gte=now.time()) + .exists() + ) # Also check for interviews happening later today today_future_meetings = self.scheduled_interviews.filter( - interview_date=now.date(), - interview_time__gte=now.time() + interview_date=now.date(), interview_time__gte=now.time() ).exists() return future_meetings or today_future_meetings @@ -686,24 +761,19 @@ class Candidate(Base): @property def scoring_timeout(self): return timezone.now() <= (self.created_at + timezone.timedelta(minutes=5)) - + @property def get_interview_date(self): - if hasattr(self, 'scheduled_interview') and self.scheduled_interview: - return self.scheduled_interviews.first().interview_date + if hasattr(self, "scheduled_interview") and self.scheduled_interview: + return self.scheduled_interviews.first().interview_date return None - - - - - + @property def get_interview_time(self): - if hasattr(self, 'scheduled_interview') and self.scheduled_interview: - return self.scheduled_interviews.first().interview_time - return None - - + if hasattr(self, "scheduled_interview") and self.scheduled_interview: + return self.scheduled_interviews.first().interview_time + return None + @property def time_to_hire_days(self): if self.hired_date and self.created_at: @@ -711,9 +781,12 @@ class Candidate(Base): return time_to_hire.days return 0 + class TrainingMaterial(Base): title = models.CharField(max_length=255, verbose_name=_("Title")) - content = CKEditor5Field(blank=True, verbose_name=_("Content"),config_name='extends') + content = CKEditor5Field( + blank=True, verbose_name=_("Content"), config_name="extends" + ) video_link = models.URLField(blank=True, verbose_name=_("Video Link")) file = models.FileField( upload_to="training_materials/", blank=True, verbose_name=_("File") @@ -735,13 +808,19 @@ class ZoomMeeting(Base): WAITING = "waiting", _("Waiting") STARTED = "started", _("Started") ENDED = "ended", _("Ended") - CANCELLED = "cancelled",_("Cancelled") + CANCELLED = "cancelled", _("Cancelled") + # Basic meeting details 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") # Added index + db_index=True, + max_length=20, + unique=True, + verbose_name=_("Meeting ID"), # Added index ) # Unique identifier for the meeting - start_time = models.DateTimeField(db_index=True, verbose_name=_("Start Time")) # Added index + start_time = models.DateTimeField( + db_index=True, verbose_name=_("Start Time") + ) # Added index duration = models.PositiveIntegerField( verbose_name=_("Duration") ) # Duration in minutes @@ -767,7 +846,8 @@ class ZoomMeeting(Base): blank=True, null=True, verbose_name=_("Zoom Gateway Response") ) status = models.CharField( - db_index=True, max_length=20, # Added index + db_index=True, + max_length=20, # Added index null=True, blank=True, verbose_name=_("Status"), @@ -776,46 +856,48 @@ class ZoomMeeting(Base): # Timestamps def __str__(self): - return self.topic\ - @property + return self.topic @ property + def get_job(self): return self.interview.job + @property def get_candidate(self): return self.interview.candidate + @property def get_participants(self): return self.interview.job.participants.all() + @property def get_users(self): return self.interview.job.users.all() + class MeetingComment(Base): """ Model for storing meeting comments/notes """ + meeting = models.ForeignKey( ZoomMeeting, on_delete=models.CASCADE, related_name="comments", - verbose_name=_("Meeting") + verbose_name=_("Meeting"), ) author = models.ForeignKey( User, on_delete=models.CASCADE, related_name="meeting_comments", - verbose_name=_("Author") - ) - content = CKEditor5Field( - verbose_name=_("Content"), - config_name='extends' + verbose_name=_("Author"), ) + content = CKEditor5Field(verbose_name=_("Content"), config_name="extends") # Inherited from Base: created_at, updated_at, slug class Meta: verbose_name = _("Meeting Comment") verbose_name_plural = _("Meeting Comments") - ordering = ['-created_at'] + ordering = ["-created_at"] def __str__(self): return f"Comment by {self.author.get_username()} on {self.meeting.topic}" @@ -827,14 +909,22 @@ class FormTemplate(Base): """ job = models.OneToOneField( - JobPosting, on_delete=models.CASCADE, related_name="form_template", db_index=True + JobPosting, + on_delete=models.CASCADE, + related_name="form_template", + db_index=True, ) name = models.CharField(max_length=200, help_text="Name of the form template") description = models.TextField( blank=True, help_text="Description of the form template" ) created_by = models.ForeignKey( - User, on_delete=models.CASCADE, related_name="form_templates",null=True,blank=True, db_index=True + User, + on_delete=models.CASCADE, + related_name="form_templates", + null=True, + blank=True, + db_index=True, ) is_active = models.BooleanField( default=False, help_text="Whether this template is active" @@ -845,8 +935,8 @@ class FormTemplate(Base): verbose_name = "Form Template" verbose_name_plural = "Form Templates" indexes = [ - models.Index(fields=['created_at']), - models.Index(fields=['is_active']), + models.Index(fields=["created_at"]), + models.Index(fields=["is_active"]), ] def __str__(self): @@ -997,7 +1087,10 @@ class FormSubmission(Base): """ template = models.ForeignKey( - FormTemplate, on_delete=models.CASCADE, related_name="submissions", db_index=True + FormTemplate, + on_delete=models.CASCADE, + related_name="submissions", + db_index=True, ) submitted_by = models.ForeignKey( User, @@ -1005,20 +1098,22 @@ class FormSubmission(Base): null=True, blank=True, related_name="form_submissions", - db_index=True + db_index=True, ) - submitted_at = models.DateTimeField(db_index=True, auto_now_add=True) # Added index + submitted_at = models.DateTimeField(db_index=True, auto_now_add=True) # Added index applicant_name = models.CharField( max_length=200, blank=True, help_text="Name of the applicant" ) - applicant_email = models.EmailField(db_index=True, blank=True, help_text="Email of the applicant") # Added index + applicant_email = models.EmailField( + db_index=True, blank=True, help_text="Email of the applicant" + ) # Added index class Meta: ordering = ["-submitted_at"] verbose_name = "Form Submission" verbose_name_plural = "Form Submissions" indexes = [ - models.Index(fields=['submitted_at']), + models.Index(fields=["submitted_at"]), ] def __str__(self): @@ -1031,7 +1126,10 @@ class FieldResponse(Base): """ submission = models.ForeignKey( - FormSubmission, on_delete=models.CASCADE, related_name="responses", db_index=True + FormSubmission, + on_delete=models.CASCADE, + related_name="responses", + db_index=True, ) field = models.ForeignKey( FormField, on_delete=models.CASCADE, related_name="responses", db_index=True @@ -1049,8 +1147,8 @@ class FieldResponse(Base): verbose_name = "Field Response" verbose_name_plural = "Field Responses" indexes = [ - models.Index(fields=['submission']), - models.Index(fields=['field']), + models.Index(fields=["submission"]), + models.Index(fields=["field"]), ] def __str__(self): @@ -1296,6 +1394,14 @@ class IntegrationLog(Base): class HiringAgency(Base): + user = models.OneToOneField( + User, + on_delete=models.CASCADE, + related_name="agency_profile", + verbose_name=_("User"), + null=True, + blank=True, + ) name = models.CharField(max_length=200, unique=True, verbose_name=_("Agency Name")) contact_person = models.CharField( max_length=150, blank=True, verbose_name=_("Contact Person") @@ -1329,31 +1435,33 @@ class AgencyJobAssignment(Base): HiringAgency, on_delete=models.CASCADE, related_name="job_assignments", - verbose_name=_("Agency") + verbose_name=_("Agency"), ) job = models.ForeignKey( JobPosting, on_delete=models.CASCADE, related_name="agency_assignments", - verbose_name=_("Job") + verbose_name=_("Job"), ) # Limits & Controls max_candidates = models.PositiveIntegerField( verbose_name=_("Maximum Candidates"), - help_text=_("Maximum candidates agency can submit for this job") + help_text=_("Maximum candidates agency can submit for this job"), ) candidates_submitted = models.PositiveIntegerField( default=0, verbose_name=_("Candidates Submitted"), - help_text=_("Number of candidates submitted so far") + help_text=_("Number of candidates submitted so far"), ) # Timeline - assigned_date = models.DateTimeField(auto_now_add=True, verbose_name=_("Assigned Date")) + assigned_date = models.DateTimeField( + auto_now_add=True, verbose_name=_("Assigned Date") + ) deadline_date = models.DateTimeField( verbose_name=_("Deadline Date"), - help_text=_("Deadline for agency to submit candidates") + help_text=_("Deadline for agency to submit candidates"), ) # Status & Extensions @@ -1362,26 +1470,25 @@ class AgencyJobAssignment(Base): max_length=20, choices=AssignmentStatus.choices, default=AssignmentStatus.ACTIVE, - verbose_name=_("Status") + verbose_name=_("Status"), ) # Extension tracking deadline_extended = models.BooleanField( - default=False, - verbose_name=_("Deadline Extended") + default=False, verbose_name=_("Deadline Extended") ) original_deadline = models.DateTimeField( null=True, blank=True, verbose_name=_("Original Deadline"), - help_text=_("Original deadline before extensions") + help_text=_("Original deadline before extensions"), ) # Admin notes admin_notes = models.TextField( blank=True, verbose_name=_("Admin Notes"), - help_text=_("Internal notes about this assignment") + help_text=_("Internal notes about this assignment"), ) class Meta: @@ -1389,12 +1496,12 @@ class AgencyJobAssignment(Base): verbose_name_plural = _("Agency Job Assignments") ordering = ["-created_at"] indexes = [ - models.Index(fields=['agency', 'status']), - models.Index(fields=['job', 'status']), - models.Index(fields=['deadline_date']), - models.Index(fields=['is_active']), + models.Index(fields=["agency", "status"]), + models.Index(fields=["job", "status"]), + models.Index(fields=["deadline_date"]), + models.Index(fields=["is_active"]), ] - unique_together = ['agency', 'job'] # Prevent duplicate assignments + unique_together = ["agency", "job"] # Prevent duplicate assignments def __str__(self): return f"{self.agency.name} - {self.job.title}" @@ -1411,10 +1518,10 @@ class AgencyJobAssignment(Base): def is_currently_active(self): """Check if assignment is currently active""" return ( - self.status == 'ACTIVE' and - self.deadline_date and - self.deadline_date > timezone.now() and - self.candidates_submitted < self.max_candidates + self.status == "ACTIVE" + and self.deadline_date + and self.deadline_date > timezone.now() + and self.candidates_submitted < self.max_candidates ) @property @@ -1431,7 +1538,9 @@ class AgencyJobAssignment(Base): raise ValidationError(_("Maximum candidates must be greater than 0")) if self.candidates_submitted > self.max_candidates: - raise ValidationError(_("Candidates submitted cannot exceed maximum candidates")) + raise ValidationError( + _("Candidates submitted cannot exceed maximum candidates") + ) @property def remaining_slots(self): @@ -1451,16 +1560,18 @@ class AgencyJobAssignment(Base): @property def can_submit(self): """Check if agency can still submit candidates""" - return (self.is_active and - not self.is_expired and - not self.is_full and - self.status == self.AssignmentStatus.ACTIVE) + return ( + self.is_active + and not self.is_expired + and not self.is_full + and self.status == self.AssignmentStatus.ACTIVE + ) def increment_submission_count(self): """Safely increment the submitted candidates count""" if self.can_submit: self.candidates_submitted += 1 - self.save(update_fields=['candidates_submitted']) + self.save(update_fields=["candidates_submitted"]) # Check if assignment is now complete # if self.candidates_submitted >= self.max_candidates: @@ -1472,13 +1583,23 @@ class AgencyJobAssignment(Base): def extend_deadline(self, new_deadline): """Extend the deadline for this assignment""" # Convert database deadline to timezone-aware for comparison - deadline_aware = timezone.make_aware(self.deadline_date) if timezone.is_naive(self.deadline_date) else self.deadline_date + deadline_aware = ( + timezone.make_aware(self.deadline_date) + if timezone.is_naive(self.deadline_date) + else self.deadline_date + ) if new_deadline > deadline_aware: if not self.deadline_extended: self.original_deadline = self.deadline_date self.deadline_extended = True self.deadline_date = new_deadline - self.save(update_fields=['deadline_date', 'original_deadline', 'deadline_extended']) + self.save( + update_fields=[ + "deadline_date", + "original_deadline", + "deadline_extended", + ] + ) return True return False @@ -1490,52 +1611,42 @@ class AgencyAccessLink(Base): AgencyJobAssignment, on_delete=models.CASCADE, related_name="access_link", - verbose_name=_("Assignment") + verbose_name=_("Assignment"), ) # Security unique_token = models.CharField( - max_length=64, - unique=True, - editable=False, - verbose_name=_("Unique Token") + max_length=64, unique=True, editable=False, verbose_name=_("Unique Token") ) access_password = models.CharField( max_length=32, verbose_name=_("Access Password"), - help_text=_("Password for agency access") + help_text=_("Password for agency access"), ) # Timeline created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created At")) expires_at = models.DateTimeField( - verbose_name=_("Expires At"), - help_text=_("When this access link expires") + verbose_name=_("Expires At"), help_text=_("When this access link expires") ) last_accessed = models.DateTimeField( - null=True, - blank=True, - verbose_name=_("Last Accessed") + null=True, blank=True, verbose_name=_("Last Accessed") ) # Usage tracking access_count = models.PositiveIntegerField( - default=0, - verbose_name=_("Access Count") - ) - is_active = models.BooleanField( - default=True, - verbose_name=_("Is Active") + default=0, verbose_name=_("Access Count") ) + is_active = models.BooleanField(default=True, verbose_name=_("Is Active")) class Meta: verbose_name = _("Agency Access Link") verbose_name_plural = _("Agency Access Links") ordering = ["-created_at"] indexes = [ - models.Index(fields=['unique_token']), - models.Index(fields=['expires_at']), - models.Index(fields=['is_active']), + models.Index(fields=["unique_token"]), + models.Index(fields=["expires_at"]), + models.Index(fields=["is_active"]), ] def __str__(self): @@ -1560,19 +1671,21 @@ class AgencyAccessLink(Base): """Record an access to this link""" self.last_accessed = timezone.now() self.access_count += 1 - self.save(update_fields=['last_accessed', 'access_count']) + self.save(update_fields=["last_accessed", "access_count"]) def generate_token(self): """Generate a unique secure token""" import secrets + self.unique_token = secrets.token_urlsafe(48) def generate_password(self): """Generate a random password""" import secrets import string + alphabet = string.ascii_letters + string.digits - self.access_password = ''.join(secrets.choice(alphabet) for _ in range(12)) + self.access_password = "".join(secrets.choice(alphabet) for _ in range(12)) def save(self, *args, **kwargs): """Override save to generate token and password if not set""" @@ -1583,8 +1696,6 @@ class AgencyAccessLink(Base): super().save(*args, **kwargs) - - class BreakTime(models.Model): """Model to store break times for a schedule""" @@ -1599,19 +1710,32 @@ class InterviewSchedule(Base): """Stores the scheduling criteria for interviews""" job = models.ForeignKey( - JobPosting, on_delete=models.CASCADE, related_name="interview_schedules", db_index=True + JobPosting, + on_delete=models.CASCADE, + related_name="interview_schedules", + db_index=True, ) - candidates = models.ManyToManyField(Candidate, related_name="interview_schedules", blank=True,null=True) - start_date = models.DateField(db_index=True, verbose_name=_("Start Date")) # Added index - end_date = models.DateField(db_index=True, verbose_name=_("End Date")) # Added index + candidates = models.ManyToManyField( + Candidate, related_name="interview_schedules", blank=True, null=True + ) + start_date = models.DateField( + db_index=True, verbose_name=_("Start Date") + ) # Added index + end_date = models.DateField( + db_index=True, verbose_name=_("End Date") + ) # Added index working_days = models.JSONField( verbose_name=_("Working Days") ) # Store days of week as [0,1,2,3,4] for Mon-Fri start_time = models.TimeField(verbose_name=_("Start Time")) end_time = models.TimeField(verbose_name=_("End Time")) - break_start_time = models.TimeField(verbose_name=_("Break Start Time"),null=True,blank=True) - break_end_time = models.TimeField(verbose_name=_("Break End Time"),null=True,blank=True) + break_start_time = models.TimeField( + verbose_name=_("Break Start Time"), null=True, blank=True + ) + break_end_time = models.TimeField( + verbose_name=_("Break End Time"), null=True, blank=True + ) interview_duration = models.PositiveIntegerField( verbose_name=_("Interview Duration (minutes)") @@ -1619,20 +1743,21 @@ class InterviewSchedule(Base): buffer_time = models.PositiveIntegerField( verbose_name=_("Buffer Time (minutes)"), default=0 ) - created_by = models.ForeignKey(User, on_delete=models.CASCADE, db_index=True) # Added index + created_by = models.ForeignKey( + User, on_delete=models.CASCADE, db_index=True + ) # Added index def __str__(self): return f"Interview Schedule for {self.job.title}" class Meta: indexes = [ - models.Index(fields=['start_date']), - models.Index(fields=['end_date']), - models.Index(fields=['created_by']), + models.Index(fields=["start_date"]), + models.Index(fields=["end_date"]), + models.Index(fields=["created_by"]), ] - class ScheduledInterview(Base): """Stores individual scheduled interviews""" @@ -1640,24 +1765,34 @@ class ScheduledInterview(Base): Candidate, on_delete=models.CASCADE, related_name="scheduled_interviews", - db_index=True + db_index=True, ) - job = models.ForeignKey( - "JobPosting", on_delete=models.CASCADE, related_name="scheduled_interviews", db_index=True + "JobPosting", + on_delete=models.CASCADE, + related_name="scheduled_interviews", + db_index=True, ) zoom_meeting = models.OneToOneField( ZoomMeeting, on_delete=models.CASCADE, related_name="interview", db_index=True ) schedule = models.ForeignKey( - InterviewSchedule, on_delete=models.CASCADE, related_name="interviews",null=True,blank=True, db_index=True + InterviewSchedule, + on_delete=models.CASCADE, + related_name="interviews", + null=True, + blank=True, + db_index=True, ) - interview_date = models.DateField(db_index=True, verbose_name=_("Interview Date")) # Added index + interview_date = models.DateField( + db_index=True, verbose_name=_("Interview Date") + ) # Added index interview_time = models.TimeField(verbose_name=_("Interview Time")) status = models.CharField( - db_index=True, max_length=20, # Added index + db_index=True, + max_length=20, # Added index choices=[ ("scheduled", _("Scheduled")), ("confirmed", _("Confirmed")), @@ -1674,18 +1809,20 @@ class ScheduledInterview(Base): class Meta: indexes = [ - models.Index(fields=['job', 'status']), - models.Index(fields=['interview_date', 'interview_time']), - models.Index(fields=['candidate', 'job']), + models.Index(fields=["job", "status"]), + models.Index(fields=["interview_date", "interview_time"]), + models.Index(fields=["candidate", "job"]), ] + class Notification(models.Model): """ Model to store system notifications, primarily for emails. """ + class NotificationType(models.TextChoices): EMAIL = "email", _("Email") - IN_APP = "in_app", _("In-App") # For future expansion + IN_APP = "in_app", _("In-App") # For future expansion class Status(models.TextChoices): PENDING = "pending", _("Pending") @@ -1698,20 +1835,20 @@ class Notification(models.Model): User, on_delete=models.CASCADE, related_name="notifications", - verbose_name=_("Recipient") + verbose_name=_("Recipient"), ) message = models.TextField(verbose_name=_("Notification Message")) notification_type = models.CharField( max_length=20, choices=NotificationType.choices, default=NotificationType.EMAIL, - verbose_name=_("Notification Type") + verbose_name=_("Notification Type"), ) status = models.CharField( max_length=20, choices=Status.choices, default=Status.PENDING, - verbose_name=_("Status") + verbose_name=_("Status"), ) related_meeting = models.ForeignKey( ZoomMeeting, @@ -1719,11 +1856,11 @@ class Notification(models.Model): related_name="notifications", null=True, blank=True, - verbose_name=_("Related Meeting") + verbose_name=_("Related Meeting"), ) scheduled_for = models.DateTimeField( verbose_name=_("Scheduled Send Time"), - help_text=_("The date and time this notification is scheduled to be sent.") + help_text=_("The date and time this notification is scheduled to be sent."), ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -1735,8 +1872,8 @@ class Notification(models.Model): verbose_name = _("Notification") verbose_name_plural = _("Notifications") indexes = [ - models.Index(fields=['status', 'scheduled_for']), - models.Index(fields=['recipient']), + models.Index(fields=["status", "scheduled_for"]), + models.Index(fields=["recipient"]), ] def __str__(self): @@ -1745,25 +1882,28 @@ class Notification(models.Model): def mark_as_sent(self): self.status = Notification.Status.SENT self.last_error = "" - self.save(update_fields=['status', 'last_error']) + self.save(update_fields=["status", "last_error"]) def mark_as_failed(self, error_message=""): self.status = Notification.Status.FAILED self.last_error = error_message self.attempts += 1 - self.save(update_fields=['status', 'last_error', 'attempts']) - + self.save(update_fields=["status", "last_error", "attempts"]) class Participants(Base): """Model to store Participants details""" - name = models.CharField(max_length=255, verbose_name=_("Participant Name"),null=True,blank=True) - email= models.EmailField(verbose_name=_("Email")) - phone = models.CharField(max_length=12,verbose_name=_("Phone Number"),null=True,blank=True) + + name = models.CharField( + max_length=255, verbose_name=_("Participant Name"), null=True, blank=True + ) + email = models.EmailField(verbose_name=_("Email")) + phone = models.CharField( + max_length=12, verbose_name=_("Phone Number"), null=True, blank=True + ) designation = models.CharField( - max_length=100, blank=True, verbose_name=_("Designation"),null=True + max_length=100, blank=True, verbose_name=_("Designation"), null=True ) def __str__(self): return f"{self.name} - {self.email}" - diff --git a/recruitment/urls.py b/recruitment/urls.py index cb16bf2..90e379e 100644 --- a/recruitment/urls.py +++ b/recruitment/urls.py @@ -5,98 +5,264 @@ from . import views_integration from . import views_source urlpatterns = [ - path('dashboard/', 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//upload_image_simple/', views.job_image_upload, name='job_image_upload'), - path('jobs//update/', views.edit_job, name='job_update'), + path("jobs/", views_frontend.JobListView.as_view(), name="job_list"), + path("jobs/create/", views.create_job, name="job_create"), + path( + "job//upload_image_simple/", + views.job_image_upload, + name="job_image_upload", + ), + path("jobs//update/", views.edit_job, name="job_update"), # path('jobs//delete/', views., name='job_delete'), - path('jobs//', views.job_detail, name='job_detail'), - - path('careers/',views.kaauh_career,name='kaauh_career'), - + path("jobs//", views.job_detail, name="job_detail"), + path("careers/", views.kaauh_career, name="kaauh_career"), # LinkedIn Integration URLs - path('jobs//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//schedule-interviews/', views.schedule_interviews_view, name='schedule_interviews'), - path('jobs//confirm-schedule-interviews/', views.confirm_schedule_interviews_view, name='confirm_schedule_interviews_view'), + path( + "jobs//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//schedule-interviews/", + views.schedule_interviews_view, + name="schedule_interviews", + ), + path( + "jobs//confirm-schedule-interviews/", + views.confirm_schedule_interviews_view, + name="confirm_schedule_interviews_view", + ), # Candidate URLs - path('candidates/', views_frontend.CandidateListView.as_view(), name='candidate_list'), - path('candidates/create/', views_frontend.CandidateCreateView.as_view(), name='candidate_create'), - path('candidates/create//', views_frontend.CandidateCreateView.as_view(), name='candidate_create_for_job'), - path('jobs//candidates/', views_frontend.JobCandidatesListView.as_view(), name='job_candidates_list'), - path('candidates//update/', views_frontend.CandidateUpdateView.as_view(), name='candidate_update'), - path('candidates//delete/', views_frontend.CandidateDeleteView.as_view(), name='candidate_delete'), - path('candidate//view/', views_frontend.candidate_detail, name='candidate_detail'), - path('candidate//resume-template/', views_frontend.candidate_resume_template_view, name='candidate_resume_template'), - path('candidate//update-stage/', views_frontend.candidate_update_stage, name='candidate_update_stage'), - path('candidate//retry-scoring/', views_frontend.retry_scoring_view, name='candidate_retry_scoring'), - + path( + "candidates/", views_frontend.CandidateListView.as_view(), name="candidate_list" + ), + path( + "candidates/create/", + views_frontend.CandidateCreateView.as_view(), + name="candidate_create", + ), + path( + "candidates/create//", + views_frontend.CandidateCreateView.as_view(), + name="candidate_create_for_job", + ), + path( + "jobs//candidates/", + views_frontend.JobCandidatesListView.as_view(), + name="job_candidates_list", + ), + path( + "candidates//update/", + views_frontend.CandidateUpdateView.as_view(), + name="candidate_update", + ), + path( + "candidates//delete/", + views_frontend.CandidateDeleteView.as_view(), + name="candidate_delete", + ), + path( + "candidate//view/", + views_frontend.candidate_detail, + name="candidate_detail", + ), + path( + "candidate//resume-template/", + views_frontend.candidate_resume_template_view, + name="candidate_resume_template", + ), + path( + "candidate//update-stage/", + views_frontend.candidate_update_stage, + name="candidate_update_stage", + ), + path( + "candidate//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//', views_frontend.TrainingDetailView.as_view(), name='training_detail'), - path('training//update/', views_frontend.TrainingUpdateView.as_view(), name='training_update'), - path('training//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//", + views_frontend.TrainingDetailView.as_view(), + name="training_detail", + ), + path( + "training//update/", + views_frontend.TrainingUpdateView.as_view(), + name="training_update", + ), + path( + "training//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//', views.ZoomMeetingDetailsView.as_view(), name='meeting_details'), - path('meetings/update-meeting//', views.ZoomMeetingUpdateView.as_view(), name='update_meeting'), - path('meetings/delete-meeting//', views.ZoomMeetingDeleteView, name='delete_meeting'), - + path("meetings/", views.ZoomMeetingListView.as_view(), name="list_meetings"), + path( + "meetings/create-meeting/", + views.ZoomMeetingCreateView.as_view(), + name="create_meeting", + ), + path( + "meetings/meeting-details//", + views.ZoomMeetingDetailsView.as_view(), + name="meeting_details", + ), + path( + "meetings/update-meeting//", + views.ZoomMeetingUpdateView.as_view(), + name="update_meeting", + ), + path( + "meetings/delete-meeting//", + views.ZoomMeetingDeleteView, + name="delete_meeting", + ), # JobPosting functional views URLs (keeping for compatibility) - path('api/create/', views.create_job, name='create_job_api'), - path('api//edit/', views.edit_job, name='edit_job_api'), - + path("api/create/", views.create_job, name="create_job_api"), + path("api//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//', 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//edit_linkedin_post_content/',views.edit_linkedin_post_content,name='edit_linkedin_post_content'), - path('jobs//candidate_screening_view/', views.candidate_screening_view, name='candidate_screening_view'), - path('jobs//candidate_exam_view/', views.candidate_exam_view, name='candidate_exam_view'), - path('jobs//candidate_interview_view/', views.candidate_interview_view, name='candidate_interview_view'), - path('jobs//candidate_offer_view/', views_frontend.candidate_offer_view, name='candidate_offer_view'), - path('jobs//candidate_hired_view/', views_frontend.candidate_hired_view, name='candidate_hired_view'), - path('jobs//export//csv/', views_frontend.export_candidates_csv, name='export_candidates_csv'), - path('jobs//candidates//update_status///', views_frontend.update_candidate_status, name='update_candidate_status'), - + path("forms/builder/", views.form_builder, name="form_builder"), + path( + "forms/builder//", 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//edit_linkedin_post_content/", + views.edit_linkedin_post_content, + name="edit_linkedin_post_content", + ), + path( + "jobs//candidate_screening_view/", + views.candidate_screening_view, + name="candidate_screening_view", + ), + path( + "jobs//candidate_exam_view/", + views.candidate_exam_view, + name="candidate_exam_view", + ), + path( + "jobs//candidate_interview_view/", + views.candidate_interview_view, + name="candidate_interview_view", + ), + path( + "jobs//candidate_offer_view/", + views_frontend.candidate_offer_view, + name="candidate_offer_view", + ), + path( + "jobs//candidate_hired_view/", + views_frontend.candidate_hired_view, + name="candidate_hired_view", + ), + path( + "jobs//export//csv/", + views_frontend.export_candidates_csv, + name="export_candidates_csv", + ), + path( + "jobs//candidates//update_status///", + views_frontend.update_candidate_status, + name="update_candidate_status", + ), # Sync URLs - path('jobs//sync-hired-candidates/', views_frontend.sync_hired_candidates, name='sync_hired_candidates'), - path('sources//test-connection/', views_frontend.test_source_connection, name='test_source_connection'), - - path('jobs///reschedule_meeting_for_candidate//', views.reschedule_meeting_for_candidate, name='reschedule_meeting_for_candidate'), - - path('jobs//update_candidate_exam_status/', views.update_candidate_exam_status, name='update_candidate_exam_status'), - path('jobs//bulk_update_candidate_exam_status/', views.bulk_update_candidate_exam_status, name='bulk_update_candidate_exam_status'), - - path('htmx//candidate_criteria_view/', views.candidate_criteria_view_htmx, name='candidate_criteria_view_htmx'), - path('htmx//candidate_set_exam_date/', views.candidate_set_exam_date, name='candidate_set_exam_date'), - - path('htmx//candidate_update_status/', views.candidate_update_status, name='candidate_update_status'), - + path( + "jobs//sync-hired-candidates/", + views_frontend.sync_hired_candidates, + name="sync_hired_candidates", + ), + path( + "sources//test-connection/", + views_frontend.test_source_connection, + name="test_source_connection", + ), + path( + "jobs///reschedule_meeting_for_candidate//", + views.reschedule_meeting_for_candidate, + name="reschedule_meeting_for_candidate", + ), + path( + "jobs//update_candidate_exam_status/", + views.update_candidate_exam_status, + name="update_candidate_exam_status", + ), + path( + "jobs//bulk_update_candidate_exam_status/", + views.bulk_update_candidate_exam_status, + name="bulk_update_candidate_exam_status", + ), + path( + "htmx//candidate_criteria_view/", + views.candidate_criteria_view_htmx, + name="candidate_criteria_view_htmx", + ), + path( + "htmx//candidate_set_exam_date/", + views.candidate_set_exam_date, + name="candidate_set_exam_date", + ), + path( + "htmx//candidate_update_status/", + views.candidate_update_status, + name="candidate_update_status", + ), # path('forms/form//submit/', views.submit_form, name='submit_form'), # path('forms/form//', views.form_wizard_view, name='form_wizard'), - path('forms//submissions//', views.form_submission_details, name='form_submission_details'), - path('forms/template//submissions/', views.form_template_submissions_list, name='form_template_submissions_list'), - path('forms/template//all-submissions/', views.form_template_all_submissions, name='form_template_all_submissions'), - + path( + "forms//submissions//", + views.form_submission_details, + name="form_submission_details", + ), + path( + "forms/template//submissions/", + views.form_template_submissions_list, + name="form_template_submissions_list", + ), + path( + "forms/template//all-submissions/", + views.form_template_all_submissions, + name="form_template_all_submissions", + ), # path('forms//', views.form_preview, name='form_preview'), # path('forms//submit/', views.form_submit, name='form_submit'), # path('forms//embed/', views.form_embed, name='form_embed'), @@ -109,74 +275,188 @@ urlpatterns = [ # path('api/templates/save/', views.save_form_template, name='save_form_template'), # path('api/templates//', views.load_form_template, name='load_form_template'), # path('api/templates//delete/', views.delete_form_template, name='delete_form_template'), - - - path('jobs//calendar/', views.interview_calendar_view, name='interview_calendar'), - path('jobs//calendar/interview//', views.interview_detail_view, name='interview_detail'), - + path( + "jobs//calendar/", + views.interview_calendar_view, + name="interview_calendar", + ), + path( + "jobs//calendar/interview//", + views.interview_detail_view, + name="interview_detail", + ), # Candidate Meeting Scheduling/Rescheduling URLs - path('jobs//candidates//schedule-meeting/', views.schedule_candidate_meeting, name='schedule_candidate_meeting'), - path('api/jobs//candidates//schedule-meeting/', views.api_schedule_candidate_meeting, name='api_schedule_candidate_meeting'), - path('jobs//candidates//reschedule-meeting//', views.reschedule_candidate_meeting, name='reschedule_candidate_meeting'), - path('api/jobs//candidates//reschedule-meeting//', views.api_reschedule_candidate_meeting, name='api_reschedule_candidate_meeting'), + path( + "jobs//candidates//schedule-meeting/", + views.schedule_candidate_meeting, + name="schedule_candidate_meeting", + ), + path( + "api/jobs//candidates//schedule-meeting/", + views.api_schedule_candidate_meeting, + name="api_schedule_candidate_meeting", + ), + path( + "jobs//candidates//reschedule-meeting//", + views.reschedule_candidate_meeting, + name="reschedule_candidate_meeting", + ), + path( + "api/jobs//candidates//reschedule-meeting//", + views.api_reschedule_candidate_meeting, + name="api_reschedule_candidate_meeting", + ), # New URL for simple page-based meeting scheduling - path('jobs//candidates//schedule-meeting-page/', views.schedule_meeting_for_candidate, name='schedule_meeting_for_candidate'), - path('jobs//candidates//delete_meeting_for_candidate//', views.delete_meeting_for_candidate, name='delete_meeting_for_candidate'), - - + path( + "jobs//candidates//schedule-meeting-page/", + views.schedule_meeting_for_candidate, + name="schedule_meeting_for_candidate", + ), + path( + "jobs//candidates//delete_meeting_for_candidate//", + views.delete_meeting_for_candidate, + name="delete_meeting_for_candidate", + ), # users urls - path('user/',views.user_detail,name='user_detail'), - path('user/user_profile_image_update/',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//',views.set_staff_password,name='set_staff_password'), - path('account_toggle_status/',views.account_toggle_status,name='account_toggle_status'), - - - + path("user/", views.user_detail, name="user_detail"), + path( + "user/user_profile_image_update/", + 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//", + views.set_staff_password, + name="set_staff_password", + ), + path( + "account_toggle_status/", + 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//', views_source.SourceDetailView.as_view(), name='source_detail'), - path('sources//update/', views_source.SourceUpdateView.as_view(), name='source_update'), - path('sources//delete/', views_source.SourceDeleteView.as_view(), name='source_delete'), - path('sources//generate-keys/', views_source.generate_api_keys_view, name='generate_api_keys'), - path('sources//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//", + views_source.SourceDetailView.as_view(), + name="source_detail", + ), + path( + "sources//update/", + views_source.SourceUpdateView.as_view(), + name="source_update", + ), + path( + "sources//delete/", + views_source.SourceDeleteView.as_view(), + name="source_delete", + ), + path( + "sources//generate-keys/", + views_source.generate_api_keys_view, + name="generate_api_keys", + ), + path( + "sources//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//comments/add/', views.add_meeting_comment, name='add_meeting_comment'), - path('meetings//comments//edit/', views.edit_meeting_comment, name='edit_meeting_comment'), - - path('meetings//comments//delete/', views.delete_meeting_comment, name='delete_meeting_comment'), - - path('meetings//set_meeting_candidate/', views.set_meeting_candidate, name='set_meeting_candidate'), - + path( + "meetings//comments/add/", + views.add_meeting_comment, + name="add_meeting_comment", + ), + path( + "meetings//comments//edit/", + views.edit_meeting_comment, + name="edit_meeting_comment", + ), + path( + "meetings//comments//delete/", + views.delete_meeting_comment, + name="delete_meeting_comment", + ), + path( + "meetings//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//', views.agency_detail, name='agency_detail'), - path('agencies//update/', views.agency_update, name='agency_update'), - path('agencies//delete/', views.agency_delete, name='agency_delete'), - path('agencies//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//", views.agency_detail, name="agency_detail"), + path("agencies//update/", views.agency_update, name="agency_update"), + path("agencies//delete/", views.agency_delete, name="agency_delete"), + path( + "agencies//candidates/", + views.agency_candidates, + name="agency_candidates", + ), # path('agencies//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//create/', views.agency_assignment_create, name='agency_assignment_create'), - path('agency-assignments//', views.agency_assignment_detail, name='agency_assignment_detail'), - path('agency-assignments//update/', views.agency_assignment_update, name='agency_assignment_update'), - path('agency-assignments//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//create/", + views.agency_assignment_create, + name="agency_assignment_create", + ), + path( + "agency-assignments//", + views.agency_assignment_detail, + name="agency_assignment_detail", + ), + path( + "agency-assignments//update/", + views.agency_assignment_update, + name="agency_assignment_update", + ), + path( + "agency-assignments//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//', views.agency_access_link_detail, name='agency_access_link_detail'), - path('agency-access-links//deactivate/', views.agency_access_link_deactivate, name='agency_access_link_deactivate'), - path('agency-access-links//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//", + views.agency_access_link_detail, + name="agency_access_link_detail", + ), + path( + "agency-access-links//deactivate/", + views.agency_access_link_deactivate, + name="agency_access_link_deactivate", + ), + path( + "agency-access-links//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'), @@ -184,35 +464,62 @@ urlpatterns = [ # path('admin/messages//reply/', views.admin_message_reply, name='admin_message_reply'), # path('admin/messages//mark-read/', views.admin_mark_message_read, name='admin_mark_message_read'), # path('admin/messages//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//', views.agency_portal_assignment_detail, name='agency_portal_assignment_detail'), - path('portal/assignment//submit-candidate/', views.agency_portal_submit_candidate_page, name='agency_portal_submit_candidate_page'), - path('portal/submit-candidate/', views.agency_portal_submit_candidate, name='agency_portal_submit_candidate'), - path('portal/logout/', views.agency_portal_logout, name='agency_portal_logout'), - + path("portal/login/", views.agency_portal_login, name="agency_portal_login"), + path( + "portal/dashboard/", + views.agency_portal_dashboard, + name="agency_portal_dashboard", + ), + # Unified Portal URLs + path("login/", views.portal_login, name="portal_login"), + path( + "candidate/dashboard/", + views.candidate_portal_dashboard, + name="candidate_portal_dashboard", + ), + path( + "portal/assignment//", + views.agency_portal_assignment_detail, + name="agency_portal_assignment_detail", + ), + path( + "portal/assignment//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//edit/', views.agency_portal_edit_candidate, name='agency_portal_edit_candidate'), - path('portal/candidates//delete/', views.agency_portal_delete_candidate, name='agency_portal_delete_candidate'), - + path( + "portal/candidates//edit/", + views.agency_portal_edit_candidate, + name="agency_portal_edit_candidate", + ), + path( + "portal/candidates//delete/", + views.agency_portal_delete_candidate, + name="agency_portal_delete_candidate", + ), # API URLs for messaging (removed) # path('api/agency/messages//', views.api_agency_message_detail, name='api_agency_message_detail'), # path('api/agency/messages//mark-read/', views.api_agency_mark_message_read, name='api_agency_mark_message_read'), - # API URLs for candidate management - path('api/candidate//', views.api_candidate_detail, name='api_candidate_detail'), - + path( + "api/candidate//", + 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//', views.notification_detail, name='notification_detail'), @@ -221,15 +528,36 @@ urlpatterns = [ # path('notifications//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//', views_frontend.ParticipantsDetailView.as_view(), name='participants_detail'), - path('participants//update/', views_frontend.ParticipantsUpdateView.as_view(), name='participants_update'), - path('participants//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//", + views_frontend.ParticipantsDetailView.as_view(), + name="participants_detail", + ), + path( + "participants//update/", + views_frontend.ParticipantsUpdateView.as_view(), + name="participants_update", + ), + path( + "participants//delete/", + views_frontend.ParticipantsDeleteView.as_view(), + name="participants_delete", + ), # Email composition URLs - path('jobs//candidates//compose-email/', views.compose_candidate_email, name='compose_candidate_email'), + path( + "jobs//candidates//compose-email/", + views.compose_candidate_email, + name="compose_candidate_email", + ), ] diff --git a/recruitment/views.py b/recruitment/views.py index b56ea09..4e3ef39 100644 --- a/recruitment/views.py +++ b/recruitment/views.py @@ -1,7 +1,7 @@ import json from django.utils.translation import gettext as _ -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model, authenticate, login, logout from django.contrib.auth.decorators import login_required from django.contrib.admin.views.decorators import staff_member_required from django.contrib.auth.mixins import LoginRequiredMixin @@ -11,39 +11,41 @@ from rich import print from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods from django.http import HttpResponse, JsonResponse -from datetime import datetime,time,timedelta +from datetime import datetime, time, timedelta from django.views import View from django.urls import reverse from django.conf import settings from django.utils import timezone -from django.db.models import FloatField,CharField, DurationField -from django.db.models import F, IntegerField, Count, Avg, Sum, Q, ExpressionWrapper, fields +from django.db.models import FloatField, CharField, DurationField +from django.db.models import ( + F, + IntegerField, + Count, + Avg, + Sum, + Q, + ExpressionWrapper, + fields, +) from django.db.models.functions import Cast, Coalesce, TruncDate from django.db.models.fields.json import KeyTextTransform from django.db.models.expressions import ExpressionWrapper -from django.db.models import Count, Avg, F,Q +from django.db.models import Count, Avg, F, Q from .forms import ( - CandidateExamDateForm, - InterviewForm, ZoomMeetingForm, + CandidateExamDateForm, JobPostingForm, - FormTemplateForm, - InterviewScheduleForm,JobPostingStatusForm, - BreakTimeFormSet, JobPostingImageForm, - ProfileImageUploadForm, - StaffUserCreationForm, MeetingCommentForm, - ToggleAccountForm, + InterviewScheduleForm, + FormTemplateForm, + SourceForm, HiringAgencyForm, + AgencyJobAssignmentForm, + AgencyAccessLinkForm, AgencyCandidateSubmissionForm, AgencyLoginForm, - AgencyAccessLinkForm, - AgencyJobAssignmentForm, - LinkedPostContentForm, - ParticipantsSelectForm, - CandidateEmailForm, - SourceForm + PortalLoginForm, ) from easyaudit.models import CRUDEvent, LoginEvent, RequestEvent from rest_framework import viewsets @@ -78,11 +80,13 @@ from .models import ( JobPosting, ScheduledInterview, JobPostingImage, - Profile,MeetingComment,HiringAgency, + Profile, + MeetingComment, + HiringAgency, AgencyJobAssignment, AgencyAccessLink, Notification, - Source + Source, ) import logging from datastar_py.django import ( @@ -98,6 +102,7 @@ from django.db.models import FloatField logger = logging.getLogger(__name__) + class JobPostingViewSet(viewsets.ModelViewSet): queryset = JobPosting.objects.all() serializer_class = JobPostingSerializer @@ -120,7 +125,9 @@ class ZoomMeetingCreateView(LoginRequiredMixin, CreateView): topic = instance.topic if instance.start_time < timezone.now(): messages.error(self.request, "Start time must be in the future.") - return redirect(reverse("create_meeting",kwargs={"slug": instance.slug})) + return redirect( + reverse("create_meeting", kwargs={"slug": instance.slug}) + ) start_time = instance.start_time duration = instance.duration @@ -138,10 +145,12 @@ class ZoomMeetingCreateView(LoginRequiredMixin, CreateView): return redirect(reverse("list_meetings")) else: messages.error(self.request, result["message"]) - return redirect(reverse("create_meeting",kwargs={"slug": instance.slug})) + return redirect( + reverse("create_meeting", kwargs={"slug": instance.slug}) + ) except Exception as e: messages.error(self.request, f"Error creating meeting: {e}") - return redirect(reverse("create_meeting",kwargs={"slug": instance.slug})) + return redirect(reverse("create_meeting", kwargs={"slug": instance.slug})) class ZoomMeetingListView(LoginRequiredMixin, ListView): @@ -157,14 +166,16 @@ class ZoomMeetingListView(LoginRequiredMixin, ListView): queryset = queryset.prefetch_related( Prefetch( - 'interview', # related_name from ZoomMeeting to ScheduledInterview - queryset=ScheduledInterview.objects.select_related('candidate', 'job'), - to_attr='interview_details' # Changed to not start with underscore + "interview", # related_name from ZoomMeeting to ScheduledInterview + queryset=ScheduledInterview.objects.select_related("candidate", "job"), + to_attr="interview_details", # Changed to not start with underscore ) ) # Handle search by topic or meeting_id - search_query = self.request.GET.get("q", "") # Renamed from 'search' to 'q' for consistency + search_query = self.request.GET.get( + "q", "" + ) # Renamed from 'search' to 'q' for consistency if search_query: queryset = queryset.filter( Q(topic__icontains=search_query) | Q(meeting_id__icontains=search_query) @@ -180,8 +191,8 @@ class ZoomMeetingListView(LoginRequiredMixin, ListView): if candidate_name: # Filter based on the name of the candidate associated with the meeting's interview queryset = queryset.filter( - Q(interview__candidate__first_name__icontains=candidate_name) | - Q(interview__candidate__last_name__icontains=candidate_name) + Q(interview__candidate__first_name__icontains=candidate_name) + | Q(interview__candidate__last_name__icontains=candidate_name) ) return queryset @@ -245,20 +256,33 @@ class ZoomMeetingUpdateView(LoginRequiredMixin, UpdateView): def ZoomMeetingDeleteView(request, slug): meeting = get_object_or_404(ZoomMeeting, slug=slug) if "HX-Request" in request.headers: - return render(request, "meetings/delete_meeting_form.html", {"meeting": meeting,"delete_url": reverse("delete_meeting", kwargs={"slug": meeting.slug})}) + return render( + request, + "meetings/delete_meeting_form.html", + { + "meeting": meeting, + "delete_url": reverse("delete_meeting", kwargs={"slug": meeting.slug}), + }, + ) if request.method == "POST": try: result = delete_zoom_meeting(meeting.meeting_id) - if result["status"] == "success" or "Meeting does not exist" in result["details"]["message"]: + if ( + result["status"] == "success" + or "Meeting does not exist" in result["details"]["message"] + ): meeting.delete() messages.success(request, "Meeting deleted successfully.") else: - messages.error(request, f"{result["message"]} , {result['details']["message"]}") + messages.error( + request, f"{result['message']} , {result['details']['message']}" + ) return redirect(reverse("list_meetings")) except Exception as e: messages.error(request, str(e)) return redirect(reverse("list_meetings")) + # Job Posting # def job_list(request): # """Display the list of job postings order by creation date descending""" @@ -286,17 +310,19 @@ def create_job(request): """Create a new job posting""" if request.method == "POST": - form = JobPostingForm( - request.POST - ) + form = JobPostingForm(request.POST) # to check user is authenticated or not if form.is_valid(): try: job = form.save(commit=False) job.save() - job_apply_url_relative=reverse('application_detail',kwargs={'slug':job.slug}) - job_apply_url_absolute=request.build_absolute_uri(job_apply_url_relative) - job.application_url=job_apply_url_absolute + job_apply_url_relative = reverse( + "application_detail", kwargs={"slug": job.slug} + ) + job_apply_url_absolute = request.build_absolute_uri( + job_apply_url_relative + ) + job.application_url = job_apply_url_absolute # FormTemplate.objects.create(job=job, is_active=False, name=job.title,created_by=request.user) job.save() messages.success(request, f'Job "{job.title}" created successfully!') @@ -316,10 +342,7 @@ def edit_job(request, slug): """Edit an existing job posting""" job = get_object_or_404(JobPosting, slug=slug) if request.method == "POST": - form = JobPostingForm( - request.POST, - instance=job - ) + form = JobPostingForm(request.POST, instance=job) if form.is_valid(): try: form.save() @@ -332,14 +355,14 @@ def edit_job(request, slug): messages.error(request, "Please correct the errors below.") else: job = get_object_or_404(JobPosting, slug=slug) - form = JobPostingForm( - instance=job - ) + form = JobPostingForm(instance=job) return render(request, "jobs/edit_job.html", {"form": form, "job": job}) -SCORE_PATH = 'ai_analysis_data__analysis_data__match_score' -HIGH_POTENTIAL_THRESHOLD=75 +SCORE_PATH = "ai_analysis_data__analysis_data__match_score" +HIGH_POTENTIAL_THRESHOLD = 75 + + @login_required def job_detail(request, slug): """View details of a specific job""" @@ -352,14 +375,14 @@ def job_detail(request, slug): applied_count = applicants.filter(stage="Applied").count() - exam_count=applicants.filter(stage="Exam").count + exam_count = applicants.filter(stage="Exam").count interview_count = applicants.filter(stage="Interview").count() offer_count = applicants.filter(stage="Offer").count() status_form = JobPostingStatusForm(instance=job) - linkedin_content_form=LinkedPostContentForm(instance=job) + linkedin_content_form = LinkedPostContentForm(instance=job) try: # If the related object exists, use its instance data image_upload_form = JobPostingImageForm(instance=job.post_images) @@ -367,35 +390,32 @@ def job_detail(request, slug): # If the related object does NOT exist, create a blank form image_upload_form = JobPostingImageForm() - # 2. Check for POST request (Status Update Submission) - if request.method == 'POST': - + if request.method == "POST": status_form = JobPostingStatusForm(request.POST, instance=job) if status_form.is_valid(): - job_status=status_form.cleaned_data['status'] - form_template=job.form_template - if job_status=='ACTIVE': - form_template.is_active=True - form_template.save(update_fields=['is_active']) + job_status = status_form.cleaned_data["status"] + form_template = job.form_template + if job_status == "ACTIVE": + form_template.is_active = True + form_template.save(update_fields=["is_active"]) else: - form_template.is_active=False - form_template.save(update_fields=['is_active']) + form_template.is_active = False + form_template.save(update_fields=["is_active"]) status_form.save() # Add a success message - messages.success(request, f"Status for '{job.title}' updated to '{job.get_status_display()}' successfully!") + messages.success( + request, + f"Status for '{job.title}' updated to '{job.get_status_display()}' successfully!", + ) - - return redirect('job_detail', slug=slug) + return redirect("job_detail", slug=slug) else: - - messages.error(request, "Failed to update status due to validation errors.") - # --- 2. Quality Metrics (JSON Aggregation) --- # Filter for candidates who have been scored and annotate with a sortable score @@ -409,158 +429,169 @@ def job_detail(request, slug): # # Cast the extracted text score to a FloatField for numerical operations # sortable_score=Cast('score_as_text', output_field=FloatField()) # ) - candidates_with_score = applicants.filter( - is_resume_parsed=True - ).annotate( - annotated_match_score=Coalesce( - Cast(SCORE_PATH, output_field=IntegerField()), - 0 - ) + candidates_with_score = applicants.filter(is_resume_parsed=True).annotate( + annotated_match_score=Coalesce(Cast(SCORE_PATH, output_field=IntegerField()), 0) ) - total_candidates=applicants.count() - avg_match_score_result = candidates_with_score.aggregate(avg_score=Avg('annotated_match_score'))['avg_score'] + total_candidates = applicants.count() + avg_match_score_result = candidates_with_score.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.filter(annotated_match_score__gte=HIGH_POTENTIAL_THRESHOLD).count() - high_potential_ratio = round( (high_potential_count / total_candidates) * 100, 1 ) if total_candidates > 0 else 0 - + high_potential_count = candidates_with_score.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 + ) # --- 3. Time Metrics (Duration Aggregation) --- # Metric: Average Time from Applied to Interview (T2I) - t2i_candidates = applicants.filter( - interview_date__isnull=False - ).annotate( + t2i_candidates = applicants.filter(interview_date__isnull=False).annotate( time_to_interview=ExpressionWrapper( - F('interview_date') - F('created_at'), - output_field=DurationField() + F("interview_date") - F("created_at"), output_field=DurationField() ) ) - avg_t2i_duration = t2i_candidates.aggregate( - avg_t2i=Avg('time_to_interview') - )['avg_t2i'] + avg_t2i_duration = t2i_candidates.aggregate(avg_t2i=Avg("time_to_interview"))[ + "avg_t2i" + ] # Convert timedelta to days - avg_t2i_days = round(avg_t2i_duration.total_seconds() / (60*60*24), 1) if avg_t2i_duration else 0 + avg_t2i_days = ( + round(avg_t2i_duration.total_seconds() / (60 * 60 * 24), 1) + if avg_t2i_duration + else 0 + ) # Metric: Average Time in Exam Stage t_in_exam_candidates = applicants.filter( exam_date__isnull=False, interview_date__isnull=False ).annotate( time_in_exam=ExpressionWrapper( - F('interview_date') - F('exam_date'), - output_field=DurationField() + F("interview_date") - F("exam_date"), output_field=DurationField() ) ) avg_t_in_exam_duration = t_in_exam_candidates.aggregate( - avg_t_in_exam=Avg('time_in_exam') - )['avg_t_in_exam'] + avg_t_in_exam=Avg("time_in_exam") + )["avg_t_in_exam"] # Convert timedelta to days - avg_t_in_exam_days = round(avg_t_in_exam_duration.total_seconds() / (60*60*24), 1) if avg_t_in_exam_duration else 0 + avg_t_in_exam_days = ( + round(avg_t_in_exam_duration.total_seconds() / (60 * 60 * 24), 1) + if avg_t_in_exam_duration + else 0 + ) - category_data = applicants.filter( - ai_analysis_data__analysis_data__category__isnull=False - ).values('ai_analysis_data__analysis_data__category').annotate( - candidate_count=Count('id'), - category=Cast('ai_analysis_data__analysis_data__category',output_field=CharField()) - ).order_by('ai_analysis_data__analysis_data__category') + category_data = ( + applicants.filter(ai_analysis_data__analysis_data__category__isnull=False) + .values("ai_analysis_data__analysis_data__category") + .annotate( + candidate_count=Count("id"), + category=Cast( + "ai_analysis_data__analysis_data__category", output_field=CharField() + ), + ) + .order_by("ai_analysis_data__analysis_data__category") + ) # Prepare data for Chart.js print(category_data) - categories = [item['category'] for item in category_data] - candidate_counts = [item['candidate_count'] for item in category_data] + categories = [item["category"] for item in category_data] + candidate_counts = [item["candidate_count"] for item in category_data] # avg_scores = [round(item['avg_match_score'], 2) if item['avg_match_score'] is not None else 0 for item in category_data] - context = { "job": job, "applicants": applicants, - "total_applicants": total_applicant, # This was total_candidates in the prompt, using total_applicant for consistency + "total_applicants": total_applicant, # This was total_candidates in the prompt, using total_applicant for consistency "applied_count": applied_count, - 'exam_count':exam_count, + "exam_count": exam_count, "interview_count": interview_count, "offer_count": offer_count, - 'status_form':status_form, - 'image_upload_form':image_upload_form, - 'categories': categories, - 'candidate_counts': candidate_counts, + "status_form": status_form, + "image_upload_form": image_upload_form, + "categories": categories, + "candidate_counts": candidate_counts, # 'avg_scores': avg_scores, # New statistics - 'avg_match_score': avg_match_score, - 'high_potential_count': high_potential_count, - 'high_potential_ratio': high_potential_ratio, - 'avg_t2i_days': avg_t2i_days, - 'avg_t_in_exam_days': avg_t_in_exam_days, - 'linkedin_content_form':linkedin_content_form + "avg_match_score": avg_match_score, + "high_potential_count": high_potential_count, + "high_potential_ratio": high_potential_ratio, + "avg_t2i_days": avg_t2i_days, + "avg_t_in_exam_days": avg_t_in_exam_days, + "linkedin_content_form": linkedin_content_form, } return render(request, "jobs/job_detail.html", context) + @login_required def job_image_upload(request, slug): - #only for handling the post request - job=get_object_or_404(JobPosting,slug=slug) + # only for handling the post request + job = get_object_or_404(JobPosting, slug=slug) try: instance = JobPostingImage.objects.get(job=job) except JobPostingImage.DoesNotExist: # If it doesn't exist, create a new instance placeholder instance = None - if request.method == 'POST': + if request.method == "POST": # Pass the existing instance to the form if it exists - image_upload_form = JobPostingImageForm(request.POST, request.FILES, instance=instance) + image_upload_form = JobPostingImageForm( + request.POST, request.FILES, instance=instance + ) if image_upload_form.is_valid(): - # If creating a new one (instance is None), set the job link manually if instance is None: image_instance = image_upload_form.save(commit=False) image_instance.job = job image_instance.save() - messages.success(request, f"Image uploaded successfully for {job.title}.") + messages.success( + request, f"Image uploaded successfully for {job.title}." + ) else: # If updating, the form will update the instance passed to it image_upload_form.save() - messages.success(request, f"Image updated successfully for {job.title}.") + messages.success( + request, f"Image updated successfully for {job.title}." + ) else: - - messages.error(request, "Image upload failed: Please ensure a valid image file was selected.") - return redirect('job_detail', slug=job.slug) - return redirect('job_detail', slug=job.slug) + messages.error( + request, + "Image upload failed: Please ensure a valid image file was selected.", + ) + return redirect("job_detail", slug=job.slug) + return redirect("job_detail", slug=job.slug) @login_required -def edit_linkedin_post_content(request,slug): - - job=get_object_or_404(JobPosting,slug=slug) - linkedin_content_form=LinkedPostContentForm(instance=job) - if request.method=='POST': - linkedin_content_form=LinkedPostContentForm(request.POST,instance=job) +def edit_linkedin_post_content(request, slug): + job = get_object_or_404(JobPosting, slug=slug) + linkedin_content_form = LinkedPostContentForm(instance=job) + if request.method == "POST": + linkedin_content_form = LinkedPostContentForm(request.POST, instance=job) if linkedin_content_form.is_valid(): linkedin_content_form.save() - messages.success(request,"Linked post content updated successfully!") - return redirect('job_detail',job.slug) + messages.success(request, "Linked post content updated successfully!") + return redirect("job_detail", job.slug) else: - messages.error(request,"Error update the Linkedin Post content") - return redirect('job_detail',job.slug) + messages.error(request, "Error update the Linkedin Post content") + return redirect("job_detail", job.slug) else: - linkedin_content_form=LinkedPostContentForm() - return redirect('job_detail',job.slug) - - - - + linkedin_content_form = LinkedPostContentForm() + return redirect("job_detail", job.slug) def kaauh_career(request): - active_jobs = JobPosting.objects.select_related( - 'form_template' - ).filter( - status='ACTIVE', - form_template__is_active=True + active_jobs = JobPosting.objects.select_related("form_template").filter( + status="ACTIVE", form_template__is_active=True ) - return render(request,'jobs/career.html',{'active_jobs':active_jobs}) + return render(request, "jobs/career.html", {"active_jobs": active_jobs}) + # job detail facing the candidate: def application_detail(request, slug): @@ -570,6 +601,7 @@ def application_detail(request, slug): from django_q.tasks import async_task + @login_required def post_to_linkedin(request, slug): """Post a job to LinkedIn""" @@ -579,15 +611,14 @@ def post_to_linkedin(request, slug): return redirect("job_list") if request.method == "POST": - linkedin_access_token=request.session.get("linkedin_access_token") - # Check if user is authenticated with LinkedIn + linkedin_access_token = request.session.get("linkedin_access_token") + # Check if user is authenticated with LinkedIn if not "linkedin_access_token": - messages.error(request, "Please authenticate with LinkedIn first.") - return redirect("linkedin_login") + messages.error(request, "Please authenticate with LinkedIn first.") + return redirect("linkedin_login") try: - # Clear previous LinkedIn data for re-posting - #Prepare the job object for background processing + # Prepare the job object for background processing job.posted_to_linkedin = False job.linkedin_post_id = "" job.linkedin_post_url = "" @@ -599,19 +630,21 @@ def post_to_linkedin(request, slug): # Pass the function path, the job slug, and the token as arguments async_task( - 'recruitment.tasks.linkedin_post_task', - job.slug, - linkedin_access_token + "recruitment.tasks.linkedin_post_task", job.slug, linkedin_access_token ) messages.success( request, - _(f"✅ Job posting process for job with JOB ID: {job.internal_job_id} started! Check the job details page in a moment for the final status.") + _( + f"✅ Job posting process for job with JOB ID: {job.internal_job_id} started! Check the job details page in a moment for the final status." + ), ) except Exception as e: logger.error(f"Error enqueuing LinkedIn post: {e}") - messages.error(request, _("Failed to start the job posting process. Please try again.")) + messages.error( + request, _("Failed to start the job posting process. Please try again.") + ) return redirect("job_detail", slug=job.slug) @@ -655,12 +688,14 @@ def linkedin_callback(request): # applicant views def applicant_job_detail(request, slug): """View job details for applicants""" - job=get_object_or_404(JobPosting,slug=slug,status='ACTIVE') - return render(request,'jobs/applicant_job_detail.html',{'job':job}) + job = get_object_or_404(JobPosting, slug=slug, status="ACTIVE") + return render(request, "jobs/applicant_job_detail.html", {"job": job}) + + +def application_success(request, slug): + job = get_object_or_404(JobPosting, slug=slug) + return render(request, "jobs/application_success.html", {"job": job}) -def application_success(request,slug): - job=get_object_or_404(JobPosting,slug=slug) - return render(request,'jobs/application_success.html',{'job':job}) @ensure_csrf_cookie @login_required @@ -668,10 +703,8 @@ def form_builder(request, template_slug=None): """Render the form builder interface""" context = {} if template_slug: - template = get_object_or_404( - FormTemplate, slug=template_slug - ) - context['template']=template + template = get_object_or_404(FormTemplate, slug=template_slug) + context["template"] = template context["template_slug"] = template.slug context["template_name"] = template.name return render(request, "forms/form_builder.html", context) @@ -689,18 +722,14 @@ def save_form_template(request): if template_slug: # Update existing template - template = get_object_or_404( - FormTemplate, slug=template_slug - ) + template = get_object_or_404(FormTemplate, slug=template_slug) template.name = template_name template.save() # Clear existing stages and fields template.stages.all().delete() else: # Create new template - template = FormTemplate.objects.create( - name=template_name - ) + template = FormTemplate.objects.create(name=template_name) # Create stages and fields for stage_order, stage_data in enumerate(stages_data): @@ -855,20 +884,24 @@ def application_submit_form(request, template_slug): """Display the form as a step-by-step wizard""" template = get_object_or_404(FormTemplate, slug=template_slug, is_active=True) job_id = template.job.internal_job_id - job=template.job + job = template.job is_limit_exceeded = job.is_application_limit_reached if is_limit_exceeded: messages.error( - request, - _('Application limit reached: This job is no longer accepting new applications.') + request, + _( + "Application limit reached: This job is no longer accepting new applications." + ), ) - return redirect('application_detail',slug=job.slug) + return redirect("application_detail", slug=job.slug) if job.is_expired: messages.error( - request, - _('Application deadline passed: This job is no longer accepting new applications.') + request, + _( + "Application deadline passed: This job is no longer accepting new applications." + ), ) - return redirect('application_detail',slug=job.slug) + return redirect("application_detail", slug=job.slug) return render( request, @@ -886,14 +919,19 @@ def application_submit(request, template_slug): if request.method == "POST": try: with transaction.atomic(): - job_posting = JobPosting.objects.select_for_update().get(form_template=template) + job_posting = JobPosting.objects.select_for_update().get( + form_template=template + ) current_count = job_posting.candidates.count() if current_count >= job_posting.max_applications: template.is_active = False template.save() return JsonResponse( - {"success": False, "message": "Application limit reached for this job."} + { + "success": False, + "message": "Application limit reached for this job.", + } ) submission = FormSubmission.objects.create(template=template) @@ -949,15 +987,17 @@ def application_submit(request, template_slug): phone=phone.display_value, address=address.display_value, resume=resume.get_file if resume.is_file else None, - job=job + job=job, ) return JsonResponse( - { - "success": True, - "message": "Form submitted successfully!", - "redirect_url": reverse('application_success',kwargs={'slug':job.slug}), - } - ) + { + "success": True, + "message": "Form submitted successfully!", + "redirect_url": reverse( + "application_success", kwargs={"slug": job.slug} + ), + } + ) # return redirect('application_success',slug=job.slug) except Exception as e: @@ -1007,10 +1047,16 @@ def form_template_all_submissions(request, template_id): template = get_object_or_404(FormTemplate, id=template_id) print(template) # Get all submissions for this template - submissions = FormSubmission.objects.filter(template=template).order_by("-submitted_at") + submissions = FormSubmission.objects.filter(template=template).order_by( + "-submitted_at" + ) # Get all fields for this template, ordered by stage and field order - fields = FormField.objects.filter(stage__template=template).select_related('stage').order_by('stage__order', 'order') + fields = ( + FormField.objects.filter(stage__template=template) + .select_related("stage") + .order_by("stage__order", "order") + ) # Pagination paginator = Paginator(submissions, 10) # Show 10 submissions per page @@ -1062,6 +1108,7 @@ def form_submission_details(request, template_id, slug): }, ) + def _handle_get_request(request, slug, job): """ Handles GET requests, setting up forms and restoring candidate selections @@ -1143,7 +1190,7 @@ def _handle_preview_submission(request, slug, job): interview_duration=interview_duration, buffer_time=buffer_time, break_start_time=break_start_time, - break_end_time=break_end_time + break_end_time=break_end_time, ) # Get available slots (temp_breaks logic moved into get_available_time_slots if needed) @@ -1216,7 +1263,6 @@ def _handle_confirm_schedule(request, slug, job): Creates the main schedule record and queues individual interviews asynchronously. """ - SESSION_DATA_KEY = "interview_schedule_data" SESSION_ID_KEY = f"schedule_candidate_ids_{slug}" @@ -1240,7 +1286,6 @@ def _handle_confirm_schedule(request, slug, job): end_time=time.fromisoformat(schedule_data["end_time"]), interview_duration=schedule_data["interview_duration"], buffer_time=schedule_data["buffer_time"], - # Use the simple break times saved in the session # If the value is None (because required=False in form), handle it gracefully break_start_time=schedule_data.get("break_start_time"), @@ -1249,14 +1294,16 @@ def _handle_confirm_schedule(request, slug, job): except Exception as e: # Handle database creation error messages.error(request, f"Error creating schedule: {e}") - if SESSION_ID_KEY in request.session: del request.session[SESSION_ID_KEY] + if SESSION_ID_KEY in request.session: + del request.session[SESSION_ID_KEY] return redirect("schedule_interviews", slug=slug) - # 3. Setup candidates and get slots candidates = Candidate.objects.filter(id__in=schedule_data["candidate_ids"]) schedule.candidates.set(candidates) - available_slots = get_available_time_slots(schedule) # This should still be synchronous and fast + available_slots = get_available_time_slots( + schedule + ) # This should still be synchronous and fast # 4. Queue scheduled interviews asynchronously (FAST RESPONSE) queued_count = 0 @@ -1270,8 +1317,8 @@ def _handle_confirm_schedule(request, slug, job): candidate.pk, job.pk, schedule.pk, - slot['date'], - slot['time'], + slot["date"], + slot["time"], schedule.interview_duration, ) queued_count += 1 @@ -1279,22 +1326,27 @@ def _handle_confirm_schedule(request, slug, job): # 5. Success and Cleanup (IMMEDIATE RESPONSE) messages.success( request, - f"Schedule successfully created. Queued {queued_count} interviews to be booked asynchronously. Check back in a moment!" + f"Schedule successfully created. Queued {queued_count} interviews to be booked asynchronously. Check back in a moment!", ) # Clear both session data keys upon successful completion - if SESSION_DATA_KEY in request.session: del request.session[SESSION_DATA_KEY] - if SESSION_ID_KEY in request.session: del request.session[SESSION_ID_KEY] + if SESSION_DATA_KEY in request.session: + del request.session[SESSION_DATA_KEY] + if SESSION_ID_KEY in request.session: + del request.session[SESSION_ID_KEY] return redirect("job_detail", slug=slug) + def schedule_interviews_view(request, slug): job = get_object_or_404(JobPosting, slug=slug) if request.method == "POST": - # return _handle_confirm_schedule(request, slug, job) + # return _handle_confirm_schedule(request, slug, job) return _handle_preview_submission(request, slug, job) else: return _handle_get_request(request, slug, job) + + def confirm_schedule_interviews_view(request, slug): job = get_object_or_404(JobPosting, slug=slug) if request.method == "POST": @@ -1310,10 +1362,10 @@ def candidate_screening_view(request, slug): candidates = job.screening_candidates # Get filter parameters - min_ai_score_str = request.GET.get('min_ai_score') - min_experience_str = request.GET.get('min_experience') - screening_rating = request.GET.get('screening_rating') - tier1_count_str = request.GET.get('tier1_count') + min_ai_score_str = request.GET.get("min_ai_score") + min_experience_str = request.GET.get("min_experience") + screening_rating = request.GET.get("screening_rating") + tier1_count_str = request.GET.get("tier1_count") try: # Check if the string value exists and is not an empty string before conversion @@ -1340,13 +1392,19 @@ def candidate_screening_view(request, slug): # Apply filters if min_ai_score > 0: - candidates = candidates.filter(ai_analysis_data__analysis_data__match_score__gte=min_ai_score) + candidates = candidates.filter( + ai_analysis_data__analysis_data__match_score__gte=min_ai_score + ) if min_experience > 0: - candidates = candidates.filter(ai_analysis_data__analysis_data__years_of_experience__gte=min_experience) + candidates = candidates.filter( + ai_analysis_data__analysis_data__years_of_experience__gte=min_experience + ) if screening_rating: - candidates = candidates.filter(ai_analysis_data__analysis_data__screening_stage_rating=screening_rating) + candidates = candidates.filter( + ai_analysis_data__analysis_data__screening_stage_rating=screening_rating + ) if tier1_count > 0: candidates = candidates[:tier1_count] @@ -1354,11 +1412,11 @@ def candidate_screening_view(request, slug): context = { "job": job, "candidates": candidates, - 'min_ai_score':min_ai_score, - 'min_experience':min_experience, - 'screening_rating':screening_rating, - 'tier1_count':tier1_count, - "current_stage" : "Applied" + "min_ai_score": min_ai_score, + "min_experience": min_experience, + "screening_rating": screening_rating, + "tier1_count": tier1_count, + "current_stage": "Applied", } return render(request, "recruitment/candidate_screening_view.html", context) @@ -1370,11 +1428,7 @@ def candidate_exam_view(request, slug): Manage candidate tiers and stage transitions """ job = get_object_or_404(JobPosting, slug=slug) - context = { - "job": job, - "candidates": job.exam_candidates, - 'current_stage' : "Exam" - } + context = {"job": job, "candidates": job.exam_candidates, "current_stage": "Exam"} return render(request, "recruitment/candidate_exam_view.html", context) @@ -1388,11 +1442,17 @@ def update_candidate_exam_status(request, slug): return redirect("candidate_exam_view", slug=candidate.job.slug) else: form = CandidateExamDateForm(request.POST, instance=candidate) - return render(request, "includes/candidate_exam_status_form.html", {"candidate": candidate,"form": form}) + return render( + request, + "includes/candidate_exam_status_form.html", + {"candidate": candidate, "form": form}, + ) + + @login_required -def bulk_update_candidate_exam_status(request,slug): +def bulk_update_candidate_exam_status(request, slug): job = get_object_or_404(JobPosting, slug=slug) - status = request.headers.get('status') + status = request.headers.get("status") if status: for candidate in get_candidates_from_request(request): try: @@ -1407,9 +1467,12 @@ def bulk_update_candidate_exam_status(request,slug): messages.success(request, f"Updated exam status selected candidates") return redirect("candidate_exam_view", slug=job.slug) + def candidate_criteria_view_htmx(request, pk): candidate = get_object_or_404(Candidate, pk=pk) - return render(request, "includes/candidate_modal_body.html", {"candidate": candidate}) + return render( + request, "includes/candidate_modal_body.html", {"candidate": candidate} + ) @login_required @@ -1417,89 +1480,127 @@ def candidate_set_exam_date(request, slug): candidate = get_object_or_404(Candidate, slug=slug) candidate.exam_date = timezone.now() candidate.save() - messages.success(request, f"Set exam date for {candidate.name} to {candidate.exam_date}") + messages.success( + request, f"Set exam date for {candidate.name} to {candidate.exam_date}" + ) return redirect("candidate_screening_view", slug=candidate.job.slug) + @login_required def candidate_update_status(request, slug): job = get_object_or_404(JobPosting, slug=slug) - mark_as = request.POST.get('mark_as') - - if mark_as != '----------': + mark_as = request.POST.get("mark_as") + + if mark_as != "----------": candidate_ids = request.POST.getlist("candidate_ids") print(candidate_ids) - if c := Candidate.objects.filter(pk__in = candidate_ids): - - if mark_as=='Exam': - c.update(exam_date=timezone.now(),interview_date=None,offer_date=None,hired_date=None,stage=mark_as,applicant_status="Candidate" if mark_as in ["Exam","Interview","Offer"] else "Applicant") - elif mark_as=='Interview': + if c := Candidate.objects.filter(pk__in=candidate_ids): + if mark_as == "Exam": + c.update( + exam_date=timezone.now(), + interview_date=None, + offer_date=None, + hired_date=None, + stage=mark_as, + applicant_status="Candidate" + if mark_as in ["Exam", "Interview", "Offer"] + else "Applicant", + ) + elif mark_as == "Interview": # interview_date update when scheduling the interview - c.update(stage=mark_as,offer_date=None,hired_date=None,applicant_status="Candidate" if mark_as in ["Exam","Interview","Offer"] else "Applicant") - elif mark_as=='Offer': - c.update(stage=mark_as,offer_date=timezone.now(),hired_date=None,applicant_status="Candidate" if mark_as in ["Exam","Interview","Offer"] else "Applicant") - elif mark_as=='Hired': - print('hired') - c.update(stage=mark_as,hired_date=timezone.now(),applicant_status="Candidate" if mark_as in ["Exam","Interview","Offer"] else "Applicant") + c.update( + stage=mark_as, + offer_date=None, + hired_date=None, + applicant_status="Candidate" + if mark_as in ["Exam", "Interview", "Offer"] + else "Applicant", + ) + elif mark_as == "Offer": + c.update( + stage=mark_as, + offer_date=timezone.now(), + hired_date=None, + applicant_status="Candidate" + if mark_as in ["Exam", "Interview", "Offer"] + else "Applicant", + ) + elif mark_as == "Hired": + print("hired") + c.update( + stage=mark_as, + hired_date=timezone.now(), + applicant_status="Candidate" + if mark_as in ["Exam", "Interview", "Offer"] + else "Applicant", + ) else: - c.update(stage=mark_as,exam_date=None,interview_date=None,offer_date=None,hired_date=None,applicant_status="Candidate" if mark_as in ["Exam","Interview","Offer"] else "Applicant") - - + c.update( + stage=mark_as, + exam_date=None, + interview_date=None, + offer_date=None, + hired_date=None, + applicant_status="Candidate" + if mark_as in ["Exam", "Interview", "Offer"] + else "Applicant", + ) messages.success(request, f"Candidates Updated") response = HttpResponse(redirect("candidate_screening_view", slug=job.slug)) response.headers["HX-Refresh"] = "true" return response + @login_required -def candidate_interview_view(request,slug): - job = get_object_or_404(JobPosting,slug=slug) +def candidate_interview_view(request, slug): + job = get_object_or_404(JobPosting, slug=slug) if request.method == "POST": form = ParticipantsSelectForm(request.POST, instance=job) print(form.errors) if form.is_valid(): + # Save the main instance (JobPosting) + job_instance = form.save(commit=False) + job_instance.save() - # Save the main instance (JobPosting) - job_instance = form.save(commit=False) - job_instance.save() + # MANUALLY set the M2M relationships based on submitted data + job_instance.participants.set(form.cleaned_data["participants"]) + job_instance.users.set(form.cleaned_data["users"]) - # MANUALLY set the M2M relationships based on submitted data - job_instance.participants.set(form.cleaned_data['participants']) - job_instance.users.set(form.cleaned_data['users']) - - messages.success(request, "Interview participants updated successfully.") - return redirect("candidate_interview_view", slug=job.slug) + messages.success(request, "Interview participants updated successfully.") + return redirect("candidate_interview_view", slug=job.slug) else: initial_data = { - 'participants': job.participants.all(), - 'users': job.users.all(), + "participants": job.participants.all(), + "users": job.users.all(), } form = ParticipantsSelectForm(instance=job, initial=initial_data) else: form = ParticipantsSelectForm(instance=job) - context = { - "job":job, - "candidates":job.interview_candidates, - 'current_stage':'Interview', - 'form':form, - 'participants_count': job.participants.count() + job.users.count(), + "job": job, + "candidates": job.interview_candidates, + "current_stage": "Interview", + "form": form, + "participants_count": job.participants.count() + job.users.count(), } - return render(request,"recruitment/candidate_interview_view.html",context) + return render(request, "recruitment/candidate_interview_view.html", context) + @login_required -def reschedule_meeting_for_candidate(request,slug,candidate_id,meeting_id): - job = get_object_or_404(JobPosting,slug=slug) - candidate = get_object_or_404(Candidate,pk=candidate_id) - meeting = get_object_or_404(ZoomMeeting,pk=meeting_id) +def reschedule_meeting_for_candidate(request, slug, candidate_id, meeting_id): + job = get_object_or_404(JobPosting, slug=slug) + candidate = get_object_or_404(Candidate, pk=candidate_id) + meeting = get_object_or_404(ZoomMeeting, pk=meeting_id) form = ZoomMeetingForm(instance=meeting) if request.method == "POST": - form = ZoomMeetingForm(request.POST,instance=meeting) + form = ZoomMeetingForm(request.POST, instance=meeting) if form.is_valid(): instance = form.save(commit=False) updated_data = { @@ -1509,7 +1610,12 @@ def reschedule_meeting_for_candidate(request,slug,candidate_id,meeting_id): } if instance.start_time < timezone.now(): messages.error(request, "Start time must be in the future.") - return redirect("reschedule_meeting_for_candidate",slug=job.slug,candidate_id=candidate_id,meeting_id=meeting_id) + return redirect( + "reschedule_meeting_for_candidate", + slug=job.slug, + candidate_id=candidate_id, + meeting_id=meeting_id, + ) result = update_meeting(instance, updated_data) @@ -1517,28 +1623,45 @@ def reschedule_meeting_for_candidate(request,slug,candidate_id,meeting_id): messages.success(request, result["message"]) else: messages.error(request, result["message"]) - return redirect(reverse("candidate_interview_view", kwargs={"slug": job.slug})) + return redirect( + reverse("candidate_interview_view", kwargs={"slug": job.slug}) + ) - context = {"job":job,"candidate":candidate,"meeting":meeting,"form":form} - return render(request,"meetings/reschedule_meeting.html",context) + context = {"job": job, "candidate": candidate, "meeting": meeting, "form": form} + return render(request, "meetings/reschedule_meeting.html", context) @login_required -def delete_meeting_for_candidate(request,slug,candidate_pk,meeting_id): - job = get_object_or_404(JobPosting,slug=slug) - candidate = get_object_or_404(Candidate,pk=candidate_pk) - meeting = get_object_or_404(ZoomMeeting,pk=meeting_id) +def delete_meeting_for_candidate(request, slug, candidate_pk, meeting_id): + job = get_object_or_404(JobPosting, slug=slug) + candidate = get_object_or_404(Candidate, pk=candidate_pk) + meeting = get_object_or_404(ZoomMeeting, pk=meeting_id) if request.method == "POST": result = delete_zoom_meeting(meeting.meeting_id) - if result["status"] == "success" or "Meeting does not exist" in result["details"]["message"]: + if ( + result["status"] == "success" + or "Meeting does not exist" in result["details"]["message"] + ): meeting.delete() messages.success(request, "Meeting deleted successfully") else: messages.error(request, result["message"]) return redirect(reverse("candidate_interview_view", kwargs={"slug": job.slug})) - context = {"job":job,"candidate":candidate,"meeting":meeting,'delete_url':reverse("delete_meeting_for_candidate",kwargs={"slug":job.slug,"candidate_pk":candidate_pk,"meeting_id":meeting_id})} - return render(request,"meetings/delete_meeting_form.html",context) + context = { + "job": job, + "candidate": candidate, + "meeting": meeting, + "delete_url": reverse( + "delete_meeting_for_candidate", + kwargs={ + "slug": job.slug, + "candidate_pk": candidate_pk, + "meeting_id": meeting_id, + }, + ), + } + return render(request, "meetings/delete_meeting_form.html", context) @login_required @@ -1546,17 +1669,16 @@ def interview_calendar_view(request, slug): job = get_object_or_404(JobPosting, slug=slug) # Get all scheduled interviews for this job - scheduled_interviews = ScheduledInterview.objects.filter( - job=job - ).select_related('candidate', 'zoom_meeting') + scheduled_interviews = ScheduledInterview.objects.filter(job=job).select_related( + "candidate", "zoom_meeting" + ) # Convert interviews to calendar events events = [] for interview in scheduled_interviews: # Create start datetime start_datetime = datetime.combine( - interview.interview_date, - interview.interview_time + interview.interview_date, interview.interview_time ) # Calculate end datetime based on interview duration @@ -1564,53 +1686,55 @@ def interview_calendar_view(request, slug): end_datetime = start_datetime + timedelta(minutes=duration) # Determine event color based on status - color = '#00636e' # Default color - if interview.status == 'confirmed': - color = '#00a86b' # Green for confirmed - elif interview.status == 'cancelled': - color = '#e74c3c' # Red for cancelled - elif interview.status == 'completed': - color = '#95a5a6' # Gray for completed + color = "#00636e" # Default color + if interview.status == "confirmed": + color = "#00a86b" # Green for confirmed + elif interview.status == "cancelled": + color = "#e74c3c" # Red for cancelled + elif interview.status == "completed": + color = "#95a5a6" # Gray for completed - events.append({ - 'title': f"Interview: {interview.candidate.name}", - 'start': start_datetime.isoformat(), - 'end': end_datetime.isoformat(), - 'url': f"{request.path}interview/{interview.id}/", - 'color': color, - 'extendedProps': { - 'candidate': interview.candidate.name, - 'email': interview.candidate.email, - 'status': interview.status, - 'meeting_id': interview.zoom_meeting.meeting_id if interview.zoom_meeting else None, - 'join_url': interview.zoom_meeting.join_url if interview.zoom_meeting else None, + events.append( + { + "title": f"Interview: {interview.candidate.name}", + "start": start_datetime.isoformat(), + "end": end_datetime.isoformat(), + "url": f"{request.path}interview/{interview.id}/", + "color": color, + "extendedProps": { + "candidate": interview.candidate.name, + "email": interview.candidate.email, + "status": interview.status, + "meeting_id": interview.zoom_meeting.meeting_id + if interview.zoom_meeting + else None, + "join_url": interview.zoom_meeting.join_url + if interview.zoom_meeting + else None, + }, } - }) + ) context = { - 'job': job, - 'events': events, - 'calendar_color': '#00636e', + "job": job, + "events": events, + "calendar_color": "#00636e", } - return render(request, 'recruitment/interview_calendar.html', context) + return render(request, "recruitment/interview_calendar.html", context) @login_required def interview_detail_view(request, slug, interview_id): job = get_object_or_404(JobPosting, slug=slug) - interview = get_object_or_404( - ScheduledInterview, - id=interview_id, - job=job - ) + interview = get_object_or_404(ScheduledInterview, id=interview_id, job=job) context = { - 'job': job, - 'interview': interview, + "job": job, + "interview": interview, } - return render(request, 'recruitment/interview_detail.html', context) + return render(request, "recruitment/interview_detail.html", context) # Candidate Meeting Scheduling/Rescheduling Views @@ -1624,11 +1748,13 @@ def api_schedule_candidate_meeting(request, job_slug, candidate_pk): candidate = get_object_or_404(Candidate, pk=candidate_pk, job=job) topic = f"Interview: {job.title} with {candidate.name}" - start_time_str = request.POST.get('start_time') - duration = int(request.POST.get('duration', 60)) + start_time_str = request.POST.get("start_time") + duration = int(request.POST.get("duration", 60)) if not start_time_str: - return JsonResponse({'success': False, 'error': 'Start time is required.'}, status=400) + return JsonResponse( + {"success": False, "error": "Start time is required."}, status=400 + ) try: # Parse datetime from datetime-local input (YYYY-MM-DDTHH:MM) @@ -1638,12 +1764,17 @@ def api_schedule_candidate_meeting(request, job_slug, candidate_pk): # For simplicity, assuming create_zoom_meeting handles naive datetimes or they are in UTC. # If start_time is expected to be in a specific timezone, convert it here. # e.g., start_time = timezone.make_aware(naive_start_time, timezone.get_current_timezone()) - start_time = naive_start_time # Or timezone.make_aware(naive_start_time) + start_time = naive_start_time # Or timezone.make_aware(naive_start_time) except ValueError: - return JsonResponse({'success': False, 'error': 'Invalid date/time format for start time.'}, status=400) + return JsonResponse( + {"success": False, "error": "Invalid date/time format for start time."}, + status=400, + ) if start_time <= timezone.now(): - return JsonResponse({'success': False, 'error': 'Start time must be in the future.'}, status=400) + return JsonResponse( + {"success": False, "error": "Start time must be in the future."}, status=400 + ) result = create_zoom_meeting(topic=topic, start_time=start_time, duration=duration) @@ -1651,7 +1782,7 @@ def api_schedule_candidate_meeting(request, job_slug, candidate_pk): zoom_meeting_details = result["meeting_details"] zoom_meeting = ZoomMeeting.objects.create( topic=topic, - start_time=start_time, # Store in local timezone + start_time=start_time, # Store in local timezone duration=duration, meeting_id=zoom_meeting_details["meeting_id"], join_url=zoom_meeting_details["join_url"], @@ -1666,24 +1797,26 @@ def api_schedule_candidate_meeting(request, job_slug, candidate_pk): zoom_meeting=zoom_meeting, interview_date=start_time.date(), interview_time=start_time.time(), - status='scheduled' # Or 'confirmed' depending on your workflow + status="scheduled", # Or 'confirmed' depending on your workflow ) messages.success(request, f"Meeting scheduled with {candidate.name}.") # Return updated table row or a success message # For HTMX, you might want to return a fragment of the updated table # For now, returning JSON to indicate success and close modal - return JsonResponse({ - 'success': True, - 'message': 'Meeting scheduled successfully!', - 'join_url': zoom_meeting.join_url, - 'meeting_id': zoom_meeting.meeting_id, - 'candidate_name': candidate.name, - 'interview_datetime': start_time.strftime("%Y-%m-%d %H:%M") - }) + return JsonResponse( + { + "success": True, + "message": "Meeting scheduled successfully!", + "join_url": zoom_meeting.join_url, + "meeting_id": zoom_meeting.meeting_id, + "candidate_name": candidate.name, + "interview_datetime": start_time.strftime("%Y-%m-%d %H:%M"), + } + ) else: messages.error(request, result["message"]) - return JsonResponse({'success': False, 'error': result["message"]}, status=400) + return JsonResponse({"success": False, "error": result["message"]}, status=400) def schedule_candidate_meeting(request, job_slug, candidate_pk): @@ -1699,10 +1832,13 @@ def schedule_candidate_meeting(request, job_slug, candidate_pk): # GET request - render the form snippet for HTMX context = { - 'job': job, - 'candidate': candidate, - 'action_url': reverse('api_schedule_candidate_meeting', kwargs={'job_slug': job_slug, 'candidate_pk': candidate_pk}), - 'scheduled_interview': None, # Explicitly None for schedule + "job": job, + "candidate": candidate, + "action_url": reverse( + "api_schedule_candidate_meeting", + kwargs={"job_slug": job_slug, "candidate_pk": candidate_pk}, + ), + "scheduled_interview": None, # Explicitly None for schedule } # Render just the form part, or the whole modal body content return render(request, "includes/meeting_form.html", context) @@ -1719,29 +1855,39 @@ def api_schedule_candidate_meeting(request, job_slug, candidate_pk): if request.method == "GET": # This GET is for HTMX to fetch the form context = { - 'job': job, - 'candidate': candidate, - 'action_url': reverse('api_schedule_candidate_meeting', kwargs={'job_slug': job_slug, 'candidate_pk': candidate_pk}), - 'scheduled_interview': None, + "job": job, + "candidate": candidate, + "action_url": reverse( + "api_schedule_candidate_meeting", + kwargs={"job_slug": job_slug, "candidate_pk": candidate_pk}, + ), + "scheduled_interview": None, } return render(request, "includes/meeting_form.html", context) # POST logic (remains the same) topic = f"Interview: {job.title} with {candidate.name}" - start_time_str = request.POST.get('start_time') - duration = int(request.POST.get('duration', 60)) + start_time_str = request.POST.get("start_time") + duration = int(request.POST.get("duration", 60)) if not start_time_str: - return JsonResponse({'success': False, 'error': 'Start time is required.'}, status=400) + return JsonResponse( + {"success": False, "error": "Start time is required."}, status=400 + ) try: naive_start_time = datetime.fromisoformat(start_time_str) start_time = naive_start_time except ValueError: - return JsonResponse({'success': False, 'error': 'Invalid date/time format for start time.'}, status=400) + return JsonResponse( + {"success": False, "error": "Invalid date/time format for start time."}, + status=400, + ) if start_time <= timezone.now(): - return JsonResponse({'success': False, 'error': 'Start time must be in the future.'}, status=400) + return JsonResponse( + {"success": False, "error": "Start time must be in the future."}, status=400 + ) result = create_zoom_meeting(topic=topic, start_time=start_time, duration=duration) @@ -1764,20 +1910,22 @@ def api_schedule_candidate_meeting(request, job_slug, candidate_pk): zoom_meeting=zoom_meeting, interview_date=start_time.date(), interview_time=start_time.time(), - status='scheduled' + status="scheduled", ) messages.success(request, f"Meeting scheduled with {candidate.name}.") - return JsonResponse({ - 'success': True, - 'message': 'Meeting scheduled successfully!', - 'join_url': zoom_meeting.join_url, - 'meeting_id': zoom_meeting.meeting_id, - 'candidate_name': candidate.name, - 'interview_datetime': start_time.strftime("%Y-%m-%d %H:%M") - }) + return JsonResponse( + { + "success": True, + "message": "Meeting scheduled successfully!", + "join_url": zoom_meeting.join_url, + "meeting_id": zoom_meeting.meeting_id, + "candidate_name": candidate.name, + "interview_datetime": start_time.strftime("%Y-%m-%d %H:%M"), + } + ) else: messages.error(request, result["message"]) - return JsonResponse({'success': False, 'error': result["message"]}, status=400) + return JsonResponse({"success": False, "error": result["message"]}, status=400) @require_http_methods(["GET", "POST"]) @@ -1787,44 +1935,58 @@ def api_reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_ """ job = get_object_or_404(JobPosting, slug=job_slug) scheduled_interview = get_object_or_404( - ScheduledInterview.objects.select_related('zoom_meeting'), + ScheduledInterview.objects.select_related("zoom_meeting"), pk=interview_pk, candidate__pk=candidate_pk, - job=job + job=job, ) zoom_meeting = scheduled_interview.zoom_meeting if request.method == "GET": # This GET is for HTMX to fetch the form initial_data = { - 'topic': zoom_meeting.topic, - 'start_time': zoom_meeting.start_time.strftime('%Y-%m-%dT%H:%M'), - 'duration': zoom_meeting.duration, + "topic": zoom_meeting.topic, + "start_time": zoom_meeting.start_time.strftime("%Y-%m-%dT%H:%M"), + "duration": zoom_meeting.duration, } context = { - 'job': job, - 'candidate': scheduled_interview.candidate, - 'scheduled_interview': scheduled_interview, # Pass for conditional logic in template - 'initial_data': initial_data, - 'action_url': reverse('api_reschedule_candidate_meeting', kwargs={'job_slug': job_slug, 'candidate_pk': candidate_pk, 'interview_pk': interview_pk}) + "job": job, + "candidate": scheduled_interview.candidate, + "scheduled_interview": scheduled_interview, # Pass for conditional logic in template + "initial_data": initial_data, + "action_url": reverse( + "api_reschedule_candidate_meeting", + kwargs={ + "job_slug": job_slug, + "candidate_pk": candidate_pk, + "interview_pk": interview_pk, + }, + ), } return render(request, "includes/meeting_form.html", context) # POST logic (remains the same) - new_start_time_str = request.POST.get('start_time') - new_duration = int(request.POST.get('duration', zoom_meeting.duration)) + new_start_time_str = request.POST.get("start_time") + new_duration = int(request.POST.get("duration", zoom_meeting.duration)) if not new_start_time_str: - return JsonResponse({'success': False, 'error': 'New start time is required.'}, status=400) + return JsonResponse( + {"success": False, "error": "New start time is required."}, status=400 + ) try: naive_new_start_time = datetime.fromisoformat(new_start_time_str) new_start_time = naive_new_start_time except ValueError: - return JsonResponse({'success': False, 'error': 'Invalid date/time format for new start time.'}, status=400) + return JsonResponse( + {"success": False, "error": "Invalid date/time format for new start time."}, + status=400, + ) if new_start_time <= timezone.now(): - return JsonResponse({'success': False, 'error': 'Start time must be in the future.'}, status=400) + return JsonResponse( + {"success": False, "error": "Start time must be in the future."}, status=400 + ) updated_data = { "topic": f"Interview: {job.title} with {scheduled_interview.candidate.name}", @@ -1841,36 +2003,52 @@ def api_reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_ zoom_meeting.topic = updated_zoom_details.get("topic", zoom_meeting.topic) zoom_meeting.start_time = new_start_time zoom_meeting.duration = new_duration - zoom_meeting.join_url = updated_zoom_details.get("join_url", zoom_meeting.join_url) - zoom_meeting.password = updated_zoom_details.get("password", zoom_meeting.password) - zoom_meeting.status = updated_zoom_details.get("status", zoom_meeting.status) + zoom_meeting.join_url = updated_zoom_details.get( + "join_url", zoom_meeting.join_url + ) + zoom_meeting.password = updated_zoom_details.get( + "password", zoom_meeting.password + ) + zoom_meeting.status = updated_zoom_details.get( + "status", zoom_meeting.status + ) zoom_meeting.zoom_gateway_response = updated_zoom_details zoom_meeting.save() scheduled_interview.interview_date = new_start_time.date() scheduled_interview.interview_time = new_start_time.time() - scheduled_interview.status = 'rescheduled' + scheduled_interview.status = "rescheduled" scheduled_interview.save() - messages.success(request, f"Meeting for {scheduled_interview.candidate.name} rescheduled.") + messages.success( + request, + f"Meeting for {scheduled_interview.candidate.name} rescheduled.", + ) else: - logger.warning(f"Zoom meeting {zoom_meeting.meeting_id} updated, but failed to fetch latest details.") + logger.warning( + f"Zoom meeting {zoom_meeting.meeting_id} updated, but failed to fetch latest details." + ) zoom_meeting.start_time = new_start_time zoom_meeting.duration = new_duration zoom_meeting.save() scheduled_interview.interview_date = new_start_time.date() scheduled_interview.interview_time = new_start_time.time() scheduled_interview.save() - messages.success(request, f"Meeting for {scheduled_interview.candidate.name} rescheduled. (Note: Could not refresh all details from Zoom.)") + messages.success( + request, + f"Meeting for {scheduled_interview.candidate.name} rescheduled. (Note: Could not refresh all details from Zoom.)", + ) - return JsonResponse({ - 'success': True, - 'message': 'Meeting rescheduled successfully!', - 'join_url': zoom_meeting.join_url, - 'new_interview_datetime': new_start_time.strftime("%Y-%m-%d %H:%M") - }) + return JsonResponse( + { + "success": True, + "message": "Meeting rescheduled successfully!", + "join_url": zoom_meeting.join_url, + "new_interview_datetime": new_start_time.strftime("%Y-%m-%d %H:%M"), + } + ) else: messages.error(request, result["message"]) - return JsonResponse({'success': False, 'error': result["message"]}, status=400) + return JsonResponse({"success": False, "error": result["message"]}, status=400) # The original schedule_candidate_meeting and reschedule_candidate_meeting (without api_ prefix) @@ -1888,10 +2066,10 @@ def reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_pk): job = get_object_or_404(JobPosting, slug=job_slug) candidate = get_object_or_404(Candidate, pk=candidate_pk, job=job) scheduled_interview = get_object_or_404( - ScheduledInterview.objects.select_related('zoom_meeting'), + ScheduledInterview.objects.select_related("zoom_meeting"), pk=interview_pk, candidate=candidate, - job=job + job=job, ) zoom_meeting = scheduled_interview.zoom_meeting @@ -1909,9 +2087,9 @@ def reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_pk): if request.method == "POST": form = ZoomMeetingForm(request.POST) if form.is_valid(): - new_topic = form.cleaned_data.get('topic') - new_start_time = form.cleaned_data.get('start_time') - new_duration = form.cleaned_data.get('duration') + new_topic = form.cleaned_data.get("topic") + new_start_time = form.cleaned_data.get("start_time") + new_duration = form.cleaned_data.get("duration") # Use a default topic if not provided, keeping with the original structure if not new_topic: @@ -1921,17 +2099,30 @@ def reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_pk): if new_start_time <= timezone.now(): messages.error(request, "Start time must be in the future.") # Re-render form with error and initial data - return render(request, "recruitment/schedule_meeting_form.html", { # Reusing the same form template - 'form': form, - 'job': job, - 'candidate': candidate, - 'scheduled_interview': scheduled_interview, - 'initial_topic': new_topic, - 'initial_start_time': new_start_time.strftime('%Y-%m-%dT%H:%M') if new_start_time else '', - 'initial_duration': new_duration, - 'action_url': reverse('reschedule_candidate_meeting', kwargs={'job_slug': job_slug, 'candidate_pk': candidate_pk, 'interview_pk': interview_pk}), - 'has_future_meeting': has_other_future_meetings # Pass status for template - }) + return render( + request, + "recruitment/schedule_meeting_form.html", + { # Reusing the same form template + "form": form, + "job": job, + "candidate": candidate, + "scheduled_interview": scheduled_interview, + "initial_topic": new_topic, + "initial_start_time": new_start_time.strftime("%Y-%m-%dT%H:%M") + if new_start_time + else "", + "initial_duration": new_duration, + "action_url": reverse( + "reschedule_candidate_meeting", + kwargs={ + "job_slug": job_slug, + "candidate_pk": candidate_pk, + "interview_pk": interview_pk, + }, + ), + "has_future_meeting": has_other_future_meetings, # Pass status for template + }, + ) # Prepare data for Zoom API update # The update_zoom_meeting expects start_time as ISO string with 'Z' @@ -1942,7 +2133,9 @@ def reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_pk): } # Update Zoom meeting using utility function - zoom_update_result = update_zoom_meeting(zoom_meeting.meeting_id, zoom_update_data) + zoom_update_result = update_zoom_meeting( + zoom_meeting.meeting_id, zoom_update_data + ) if zoom_update_result["status"] == "success": # Fetch the latest details from Zoom after successful update @@ -1952,20 +2145,35 @@ def reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_pk): updated_zoom_details = details_result["meeting_details"] # Update local ZoomMeeting record zoom_meeting.topic = updated_zoom_details.get("topic", new_topic) - zoom_meeting.start_time = new_start_time # Store the original datetime + zoom_meeting.start_time = ( + new_start_time # Store the original datetime + ) zoom_meeting.duration = new_duration - zoom_meeting.join_url = updated_zoom_details.get("join_url", zoom_meeting.join_url) - zoom_meeting.password = updated_zoom_details.get("password", zoom_meeting.password) - zoom_meeting.status = updated_zoom_details.get("status", zoom_meeting.status) - zoom_meeting.zoom_gateway_response = details_result.get("meeting_details") + zoom_meeting.join_url = updated_zoom_details.get( + "join_url", zoom_meeting.join_url + ) + zoom_meeting.password = updated_zoom_details.get( + "password", zoom_meeting.password + ) + zoom_meeting.status = updated_zoom_details.get( + "status", zoom_meeting.status + ) + zoom_meeting.zoom_gateway_response = details_result.get( + "meeting_details" + ) zoom_meeting.save() # Update ScheduledInterview record scheduled_interview.interview_date = new_start_time.date() scheduled_interview.interview_time = new_start_time.time() - scheduled_interview.status = 'rescheduled' # Or 'scheduled' if you prefer + scheduled_interview.status = ( + "rescheduled" # Or 'scheduled' if you prefer + ) scheduled_interview.save() - messages.success(request, f"Meeting for {candidate.name} rescheduled successfully.") + messages.success( + request, + f"Meeting for {candidate.name} rescheduled successfully.", + ) else: # If fetching details fails, update with form data and log a warning logger.warning( @@ -1980,52 +2188,98 @@ def reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_pk): scheduled_interview.interview_date = new_start_time.date() scheduled_interview.interview_time = new_start_time.time() scheduled_interview.save() - messages.success(request, f"Meeting for {candidate.name} rescheduled. (Note: Could not refresh all details from Zoom.)") + messages.success( + request, + f"Meeting for {candidate.name} rescheduled. (Note: Could not refresh all details from Zoom.)", + ) - return redirect('candidate_interview_view', slug=job.slug) + return redirect("candidate_interview_view", slug=job.slug) else: - messages.error(request, f"Failed to update Zoom meeting: {zoom_update_result['message']}") + messages.error( + request, + f"Failed to update Zoom meeting: {zoom_update_result['message']}", + ) # Re-render form with error - return render(request, "recruitment/schedule_meeting_form.html", { - 'form': form, - 'job': job, - 'candidate': candidate, - 'scheduled_interview': scheduled_interview, - 'initial_topic': new_topic, - 'initial_start_time': new_start_time.strftime('%Y-%m-%dT%H:%M') if new_start_time else '', - 'initial_duration': new_duration, - 'action_url': reverse('reschedule_candidate_meeting', kwargs={'job_slug': job_slug, 'candidate_pk': candidate_pk, 'interview_pk': interview_pk}), - 'has_future_meeting': has_other_future_meetings - }) + return render( + request, + "recruitment/schedule_meeting_form.html", + { + "form": form, + "job": job, + "candidate": candidate, + "scheduled_interview": scheduled_interview, + "initial_topic": new_topic, + "initial_start_time": new_start_time.strftime("%Y-%m-%dT%H:%M") + if new_start_time + else "", + "initial_duration": new_duration, + "action_url": reverse( + "reschedule_candidate_meeting", + kwargs={ + "job_slug": job_slug, + "candidate_pk": candidate_pk, + "interview_pk": interview_pk, + }, + ), + "has_future_meeting": has_other_future_meetings, + }, + ) else: # Form validation errors - return render(request, "recruitment/schedule_meeting_form.html", { - 'form': form, - 'job': job, - 'candidate': candidate, - 'scheduled_interview': scheduled_interview, - 'initial_topic': request.POST.get('topic', new_topic), - 'initial_start_time': request.POST.get('start_time', new_start_time.strftime('%Y-%m-%dT%H:%M') if new_start_time else ''), - 'initial_duration': request.POST.get('duration', new_duration), - 'action_url': reverse('reschedule_candidate_meeting', kwargs={'job_slug': job_slug, 'candidate_pk': candidate_pk, 'interview_pk': interview_pk}), - 'has_future_meeting': has_other_future_meetings - }) - else: # GET request + return render( + request, + "recruitment/schedule_meeting_form.html", + { + "form": form, + "job": job, + "candidate": candidate, + "scheduled_interview": scheduled_interview, + "initial_topic": request.POST.get("topic", new_topic), + "initial_start_time": request.POST.get( + "start_time", + new_start_time.strftime("%Y-%m-%dT%H:%M") + if new_start_time + else "", + ), + "initial_duration": request.POST.get("duration", new_duration), + "action_url": reverse( + "reschedule_candidate_meeting", + kwargs={ + "job_slug": job_slug, + "candidate_pk": candidate_pk, + "interview_pk": interview_pk, + }, + ), + "has_future_meeting": has_other_future_meetings, + }, + ) + else: # GET request # Pre-populate form with existing meeting details initial_data = { - 'topic': zoom_meeting.topic, - 'start_time': zoom_meeting.start_time.strftime('%Y-%m-%dT%H:%M'), - 'duration': zoom_meeting.duration, + "topic": zoom_meeting.topic, + "start_time": zoom_meeting.start_time.strftime("%Y-%m-%dT%H:%M"), + "duration": zoom_meeting.duration, } form = ZoomMeetingForm(initial=initial_data) - return render(request, "recruitment/schedule_meeting_form.html", { - 'form': form, - 'job': job, - 'candidate': candidate, - 'scheduled_interview': scheduled_interview, # Pass to template for title/differentiation - 'action_url': reverse('reschedule_candidate_meeting', kwargs={'job_slug': job_slug, 'candidate_pk': candidate_pk, 'interview_pk': interview_pk}), - 'has_future_meeting': has_other_future_meetings # Pass status for template - }) + return render( + request, + "recruitment/schedule_meeting_form.html", + { + "form": form, + "job": job, + "candidate": candidate, + "scheduled_interview": scheduled_interview, # Pass to template for title/differentiation + "action_url": reverse( + "reschedule_candidate_meeting", + kwargs={ + "job_slug": job_slug, + "candidate_pk": candidate_pk, + "interview_pk": interview_pk, + }, + ), + "has_future_meeting": has_other_future_meetings, # Pass status for template + }, + ) def schedule_meeting_for_candidate(request, slug, candidate_pk): @@ -2039,9 +2293,9 @@ def schedule_meeting_for_candidate(request, slug, candidate_pk): if request.method == "POST": form = ZoomMeetingForm(request.POST) if form.is_valid(): - topic_val = form.cleaned_data.get('topic') - start_time_val = form.cleaned_data.get('start_time') - duration_val = form.cleaned_data.get('duration') + topic_val = form.cleaned_data.get("topic") + start_time_val = form.cleaned_data.get("start_time") + duration_val = form.cleaned_data.get("duration") # Use a default topic if not provided if not topic_val: @@ -2051,7 +2305,7 @@ def schedule_meeting_for_candidate(request, slug, candidate_pk): if start_time_val <= timezone.now(): messages.error(request, "Start time must be in the future.") # Re-render form with error and initial data - return redirect('candidate_interview_view', slug=job.slug) + return redirect("candidate_interview_view", slug=job.slug) # return render(request, "recruitment/schedule_meeting_form.html", { # 'form': form, # 'job': job, @@ -2066,20 +2320,22 @@ def schedule_meeting_for_candidate(request, slug, candidate_pk): # and handles its own conversion to UTC for the API call. zoom_creation_result = create_zoom_meeting( topic=topic_val, - start_time=start_time_val, # Pass the datetime object - duration=duration_val + start_time=start_time_val, # Pass the datetime object + duration=duration_val, ) if zoom_creation_result["status"] == "success": zoom_details = zoom_creation_result["meeting_details"] zoom_meeting_instance = ZoomMeeting.objects.create( topic=topic_val, - start_time=start_time_val, # Store the original datetime + start_time=start_time_val, # Store the original datetime duration=duration_val, meeting_id=zoom_details["meeting_id"], join_url=zoom_details["join_url"], - password=zoom_details.get("password"), # password might be None - status=zoom_creation_result["zoom_gateway_response"].get("status", "waiting"), + password=zoom_details.get("password"), # password might be None + status=zoom_creation_result["zoom_gateway_response"].get( + "status", "waiting" + ), zoom_gateway_response=zoom_creation_result["zoom_gateway_response"], ) # Create a ScheduledInterview record @@ -2089,71 +2345,95 @@ def schedule_meeting_for_candidate(request, slug, candidate_pk): zoom_meeting=zoom_meeting_instance, interview_date=start_time_val.date(), interview_time=start_time_val.time(), - status='scheduled' + status="scheduled", ) messages.success(request, f"Meeting scheduled with {candidate.name}.") - return redirect('candidate_interview_view', slug=job.slug) + return redirect("candidate_interview_view", slug=job.slug) else: - messages.error(request, f"Failed to create Zoom meeting: {zoom_creation_result['message']}") + messages.error( + request, + f"Failed to create Zoom meeting: {zoom_creation_result['message']}", + ) # Re-render form with error - return render(request, "recruitment/schedule_meeting_form.html", { - 'form': form, - 'job': job, - 'candidate': candidate, - 'initial_topic': topic_val, - 'initial_start_time': start_time_val.strftime('%Y-%m-%dT%H:%M') if start_time_val else '', - 'initial_duration': duration_val - }) + return render( + request, + "recruitment/schedule_meeting_form.html", + { + "form": form, + "job": job, + "candidate": candidate, + "initial_topic": topic_val, + "initial_start_time": start_time_val.strftime("%Y-%m-%dT%H:%M") + if start_time_val + else "", + "initial_duration": duration_val, + }, + ) else: # Form validation errors - return render(request, "meetings/schedule_meeting_form.html", { - 'form': form, - 'job': job, - 'candidate': candidate, - 'initial_topic': request.POST.get('topic', f"Interview: {job.title} with {candidate.name}"), - 'initial_start_time': request.POST.get('start_time', ''), - 'initial_duration': request.POST.get('duration', 60) - }) - else: # GET request + return render( + request, + "meetings/schedule_meeting_form.html", + { + "form": form, + "job": job, + "candidate": candidate, + "initial_topic": request.POST.get( + "topic", f"Interview: {job.title} with {candidate.name}" + ), + "initial_start_time": request.POST.get("start_time", ""), + "initial_duration": request.POST.get("duration", 60), + }, + ) + else: # GET request initial_data = { - 'topic': f"Interview: {job.title} with {candidate.name}", - 'start_time': (timezone.now() + timedelta(hours=1)).strftime('%Y-%m-%dT%H:%M'), # Default to 1 hour from now - 'duration': 60, # Default duration + "topic": f"Interview: {job.title} with {candidate.name}", + "start_time": (timezone.now() + timedelta(hours=1)).strftime( + "%Y-%m-%dT%H:%M" + ), # Default to 1 hour from now + "duration": 60, # Default duration } form = ZoomMeetingForm(initial=initial_data) - return render(request, "meetings/schedule_meeting_form.html", { - 'form': form, - 'job': job, - 'candidate': candidate - }) + return render( + request, + "meetings/schedule_meeting_form.html", + {"form": form, "job": job, "candidate": candidate}, + ) from django.core.exceptions import ObjectDoesNotExist + def user_profile_image_update(request, pk): user = get_object_or_404(User, pk=pk) try: - instance =user.profile + instance = user.profile except ObjectDoesNotExist as e: Profile.objects.create(user=user) - if request.method == 'POST': - profile_form = ProfileImageUploadForm(request.POST, request.FILES, instance=user.profile) + if request.method == "POST": + profile_form = ProfileImageUploadForm( + request.POST, request.FILES, instance=user.profile + ) if profile_form.is_valid(): profile_form.save() - messages.success(request, 'Image uploaded successfully') - return redirect('user_detail', pk=user.pk) + messages.success(request, "Image uploaded successfully") + return redirect("user_detail", pk=user.pk) else: - messages.error(request, 'An error occurred while uploading image. Please check the errors below.') + messages.error( + request, + "An error occurred while uploading image. Please check the errors below.", + ) else: profile_form = ProfileImageUploadForm(instance=user.profile) context = { - 'profile_form': profile_form, - 'user': user, + "profile_form": profile_form, + "user": user, } - return render(request, 'user/profile.html', context) + return render(request, "user/profile.html", context) + def user_detail(request, pk): user = get_object_or_404(User, pk=pk) @@ -2164,23 +2444,16 @@ def user_detail(request, pk): except: profile_form = ProfileImageUploadForm() - if request.method == 'POST': - first_name=request.POST.get('first_name') - last_name=request.POST.get('last_name') + if request.method == "POST": + first_name = request.POST.get("first_name") + last_name = request.POST.get("last_name") if first_name: - user.first_name=first_name + user.first_name = first_name if last_name: - user.last_name=last_name + user.last_name = last_name user.save() - context = { - - 'user': user, - 'profile_form':profile_form - - } - return render(request, 'user/profile.html', context) - - + context = {"user": user, "profile_form": profile_form} + return render(request, "user/profile.html", context) def easy_logs(request): @@ -2189,56 +2462,50 @@ def easy_logs(request): """ logs_per_page = 20 + active_tab = request.GET.get("tab", "crud") - active_tab = request.GET.get('tab', 'crud') - - if active_tab == 'login': - queryset = LoginEvent.objects.order_by('-datetime') + if active_tab == "login": + queryset = LoginEvent.objects.order_by("-datetime") tab_title = _("User Authentication") - elif active_tab == 'request': - queryset = RequestEvent.objects.order_by('-datetime') + elif active_tab == "request": + queryset = RequestEvent.objects.order_by("-datetime") tab_title = _("HTTP Requests") else: - queryset = CRUDEvent.objects.order_by('-datetime') + queryset = CRUDEvent.objects.order_by("-datetime") tab_title = _("Model Changes (CRUD)") - active_tab = 'crud' - + active_tab = "crud" paginator = Paginator(queryset, logs_per_page) - page = request.GET.get('page') + page = request.GET.get("page") try: - logs_page = paginator.page(page) except PageNotAnInteger: - logs_page = paginator.page(1) except EmptyPage: - logs_page = paginator.page(paginator.num_pages) context = { - 'logs': logs_page, - 'total_count': queryset.count(), - 'active_tab': active_tab, - 'tab_title': tab_title, + "logs": logs_page, + "total_count": queryset.count(), + "active_tab": active_tab, + "tab_title": tab_title, } return render(request, "includes/easy_logs.html", context) - from allauth.account.views import SignupView from django.contrib.auth.decorators import user_passes_test + def is_superuser_check(user): return user.is_superuser @user_passes_test(is_superuser_check) def create_staff_user(request): - if request.method == 'POST': - + if request.method == "POST": form = StaffUserCreationForm(request.POST) print(form) if form.is_valid(): @@ -2246,68 +2513,70 @@ def create_staff_user(request): messages.success( request, f"Staff user {form.cleaned_data['first_name']} {form.cleaned_data['last_name']} " - f"({form.cleaned_data['email']}) created successfully!" + f"({form.cleaned_data['email']}) created successfully!", ) - return redirect('admin_settings') + return redirect("admin_settings") else: form = StaffUserCreationForm() - return render(request, 'user/create_staff.html', {'form': form}) - - + return render(request, "user/create_staff.html", {"form": form}) @user_passes_test(is_superuser_check) def admin_settings(request): - staffs=User.objects.filter(is_superuser=False) + staffs = User.objects.filter(is_superuser=False) form = ToggleAccountForm() - context={ - 'staffs':staffs, - 'form':form - } - return render(request,'user/admin_settings.html',context) + context = {"staffs": staffs, "form": form} + return render(request, "user/admin_settings.html", context) from django.contrib.auth.forms import SetPasswordForm + @user_passes_test(is_superuser_check) -def set_staff_password(request,pk): - user=get_object_or_404(User,pk=pk) +def set_staff_password(request, pk): + user = get_object_or_404(User, pk=pk) print(request.POST) - if request.method=='POST': + if request.method == "POST": form = SetPasswordForm(user, data=request.POST) if form.is_valid(): - form.save() - messages.success(request,f'Password successfully changed') - return redirect('admin_settings') + form.save() + messages.success(request, f"Password successfully changed") + return redirect("admin_settings") else: - form=SetPasswordForm(user=user) - messages.error(request,f'Password does not match please try again.') - return redirect('admin_settings') + form = SetPasswordForm(user=user) + messages.error(request, f"Password does not match please try again.") + return redirect("admin_settings") else: - form=SetPasswordForm(user=user) - return render(request,'user/staff_password_create.html',{'form':form,'user':user}) + form = SetPasswordForm(user=user) + return render( + request, "user/staff_password_create.html", {"form": form, "user": user} + ) @user_passes_test(is_superuser_check) -def account_toggle_status(request,pk): - user=get_object_or_404(User,pk=pk) - if request.method=='POST': +def account_toggle_status(request, pk): + user = get_object_or_404(User, pk=pk) + if request.method == "POST": print(user.is_active) - form=ToggleAccountForm(request.POST) + form = ToggleAccountForm(request.POST) if form.is_valid(): if user.is_active: - user.is_active=False + user.is_active = False user.save() - messages.success(request,f'Staff with email: {user.email} deactivated successfully') - return redirect('admin_settings') + messages.success( + request, f"Staff with email: {user.email} deactivated successfully" + ) + return redirect("admin_settings") else: - user.is_active=True + user.is_active = True user.save() - messages.success(request,f'Staff with email: {user.email} activated successfully') - return redirect('admin_settings') + messages.success( + request, f"Staff with email: {user.email} activated successfully" + ) + return redirect("admin_settings") else: - messages.error(f'Please correct the error below') + messages.error(f"Please correct the error below") # @login_required @@ -2322,7 +2591,7 @@ def zoom_webhook_view(request): print(settings.ZOOM_WEBHOOK_API_KEY) # if api_key != settings.ZOOM_WEBHOOK_API_KEY: # return HttpResponse(status=405) - if request.method == 'POST': + if request.method == "POST": try: payload = json.loads(request.body) async_task("recruitment.tasks.handle_zoom_webhook_event", payload) @@ -2338,38 +2607,40 @@ def add_meeting_comment(request, slug): """Add a comment to a meeting""" meeting = get_object_or_404(ZoomMeeting, slug=slug) - if request.method == 'POST': + if request.method == "POST": form = MeetingCommentForm(request.POST) if form.is_valid(): comment = form.save(commit=False) comment.meeting = meeting comment.author = request.user comment.save() - messages.success(request, 'Comment added successfully!') + messages.success(request, "Comment added successfully!") # HTMX response - return just the comment section - if 'HX-Request' in request.headers: - return render(request, 'includes/comment_list.html', { - 'comments': meeting.comments.all().order_by('-created_at'), - 'meeting': meeting - }) + if "HX-Request" in request.headers: + return render( + request, + "includes/comment_list.html", + { + "comments": meeting.comments.all().order_by("-created_at"), + "meeting": meeting, + }, + ) - return redirect('meeting_details', slug=slug) + return redirect("meeting_details", slug=slug) else: form = MeetingCommentForm() context = { - 'form': form, - 'meeting': meeting, + "form": form, + "meeting": meeting, } # HTMX response - return the comment form - if 'HX-Request' in request.headers: - return render(request, 'includes/comment_form.html', context) - - return redirect('meeting_details', slug=slug) - + if "HX-Request" in request.headers: + return render(request, "includes/comment_form.html", context) + return redirect("meeting_details", slug=slug) @login_required @@ -2380,32 +2651,32 @@ def edit_meeting_comment(request, slug, comment_id): # Check if user is author if comment.author != request.user and not request.user.is_staff: - messages.error(request, 'You can only edit your own comments.') - return redirect('meeting_details', slug=slug) + messages.error(request, "You can only edit your own comments.") + return redirect("meeting_details", slug=slug) - if request.method == 'POST': + if request.method == "POST": form = MeetingCommentForm(request.POST, instance=comment) if form.is_valid(): comment = form.save() - messages.success(request, 'Comment updated successfully!') + messages.success(request, "Comment updated successfully!") # HTMX response - return just comment section - if 'HX-Request' in request.headers: - return render(request, 'includes/comment_list.html', { - 'comments': meeting.comments.all().order_by('-created_at'), - 'meeting': meeting - }) + if "HX-Request" in request.headers: + return render( + request, + "includes/comment_list.html", + { + "comments": meeting.comments.all().order_by("-created_at"), + "meeting": meeting, + }, + ) - return redirect('meeting_details', slug=slug) + return redirect("meeting_details", slug=slug) else: form = MeetingCommentForm(instance=comment) - context = { - 'form': form, - 'meeting': meeting, - 'comment': comment - } - return render(request, 'includes/edit_comment_form.html', context) + context = {"form": form, "meeting": meeting, "comment": comment} + return render(request, "includes/edit_comment_form.html", context) @login_required @@ -2416,37 +2687,48 @@ def delete_meeting_comment(request, slug, comment_id): # Check if user is the author if comment.author != request.user and not request.user.is_staff: - messages.error(request, 'You can only delete your own comments.') - return redirect('meeting_details', slug=slug) + messages.error(request, "You can only delete your own comments.") + return redirect("meeting_details", slug=slug) - if request.method == 'POST': + if request.method == "POST": comment.delete() - messages.success(request, 'Comment deleted successfully!') + messages.success(request, "Comment deleted successfully!") # HTMX response - return just the comment section - if 'HX-Request' in request.headers: - return render(request, 'includes/comment_list.html', { - 'comments': meeting.comments.all().order_by('-created_at'), - 'meeting': meeting - }) + if "HX-Request" in request.headers: + return render( + request, + "includes/comment_list.html", + { + "comments": meeting.comments.all().order_by("-created_at"), + "meeting": meeting, + }, + ) - return redirect('meeting_details', slug=slug) + return redirect("meeting_details", slug=slug) # HTMX response - return the delete confirmation modal - if 'HX-Request' in request.headers: - return render(request, 'includes/delete_comment_form.html', { - 'meeting': meeting, - 'comment': comment, - 'delete_url': reverse('delete_meeting_comment', kwargs={'slug': slug, 'comment_id': comment_id}) - }) + if "HX-Request" in request.headers: + return render( + request, + "includes/delete_comment_form.html", + { + "meeting": meeting, + "comment": comment, + "delete_url": reverse( + "delete_meeting_comment", + kwargs={"slug": slug, "comment_id": comment_id}, + ), + }, + ) - return redirect('meeting_details', slug=slug) + return redirect("meeting_details", slug=slug) @login_required -def set_meeting_candidate(request,slug): +def set_meeting_candidate(request, slug): meeting = get_object_or_404(ZoomMeeting, slug=slug) - if request.method == 'POST' and 'HX-Request' not in request.headers: + if request.method == "POST" and "HX-Request" not in request.headers: form = InterviewForm(request.POST) if form.is_valid(): candidate = form.save(commit=False) @@ -2454,80 +2736,79 @@ def set_meeting_candidate(request,slug): candidate.interview_date = meeting.start_time.date() candidate.interview_time = meeting.start_time.time() candidate.save() - messages.success(request, 'Candidate added successfully!') - return redirect('list_meetings') + messages.success(request, "Candidate added successfully!") + return redirect("list_meetings") job = request.GET.get("job") form = InterviewForm() if job: - form.fields['candidate'].queryset = Candidate.objects.filter(job=job) + form.fields["candidate"].queryset = Candidate.objects.filter(job=job) else: - form.fields['candidate'].queryset = Candidate.objects.none() - form.fields['job'].widget.attrs.update({ - 'hx-get': reverse('set_meeting_candidate', kwargs={'slug': slug}), - 'hx-target': '#div_id_candidate', - 'hx-select': '#div_id_candidate', - 'hx-swap': 'outerHTML' - }) - context = { - "form": form, - "meeting": meeting - } - return render(request, 'meetings/set_candidate_form.html', context) + form.fields["candidate"].queryset = Candidate.objects.none() + form.fields["job"].widget.attrs.update( + { + "hx-get": reverse("set_meeting_candidate", kwargs={"slug": slug}), + "hx-target": "#div_id_candidate", + "hx-select": "#div_id_candidate", + "hx-swap": "outerHTML", + } + ) + context = {"form": form, "meeting": meeting} + return render(request, "meetings/set_candidate_form.html", context) # Hiring Agency CRUD Views @login_required def agency_list(request): """List all hiring agencies with search and pagination""" - search_query = request.GET.get('q', '') + search_query = request.GET.get("q", "") agencies = HiringAgency.objects.all() if search_query: agencies = agencies.filter( - Q(name__icontains=search_query) | - Q(contact_person__icontains=search_query) | - Q(email__icontains=search_query) | - Q(country__icontains=search_query) + Q(name__icontains=search_query) + | Q(contact_person__icontains=search_query) + | Q(email__icontains=search_query) + | Q(country__icontains=search_query) ) # Order by most recently created - agencies = agencies.order_by('-created_at') + agencies = agencies.order_by("-created_at") # Pagination paginator = Paginator(agencies, 10) # Show 10 agencies per page - page_number = request.GET.get('page') + page_number = request.GET.get("page") page_obj = paginator.get_page(page_number) context = { - 'page_obj': page_obj, - 'search_query': search_query, - 'total_agencies': agencies.count(), + "page_obj": page_obj, + "search_query": search_query, + "total_agencies": agencies.count(), } - return render(request, 'recruitment/agency_list.html', context) + return render(request, "recruitment/agency_list.html", context) @login_required def agency_create(request): """Create a new hiring agency""" - if request.method == 'POST': + if request.method == "POST": form = HiringAgencyForm(request.POST) if form.is_valid(): agency = form.save() messages.success(request, f'Agency "{agency.name}" created successfully!') - return redirect('agency_detail', slug=agency.slug) + return redirect("agency_detail", slug=agency.slug) else: - messages.error(request, 'Please correct the errors below.') + messages.error(request, "Please correct the errors below.") else: form = HiringAgencyForm() context = { - 'form': form, - 'title': 'Create New Agency', - 'button_text': 'Create Agency', + "form": form, + "title": "Create New Agency", + "button_text": "Create Agency", } - return render(request, 'recruitment/agency_form.html', context) + return render(request, "recruitment/agency_form.html", context) @login_required @@ -2536,23 +2817,25 @@ def agency_detail(request, slug): agency = get_object_or_404(HiringAgency, slug=slug) # Get candidates associated with this agency - candidates = Candidate.objects.filter(hiring_agency=agency).order_by('-created_at') + candidates = Candidate.objects.filter(hiring_agency=agency).order_by("-created_at") # Statistics total_candidates = candidates.count() - active_candidates = candidates.filter(stage__in=['Applied', 'Screening', 'Exam', 'Interview', 'Offer']).count() - hired_candidates = candidates.filter(stage='Hired').count() - rejected_candidates = candidates.filter(stage='Rejected').count() + active_candidates = candidates.filter( + stage__in=["Applied", "Screening", "Exam", "Interview", "Offer"] + ).count() + hired_candidates = candidates.filter(stage="Hired").count() + rejected_candidates = candidates.filter(stage="Rejected").count() context = { - 'agency': agency, - 'candidates': candidates[:10], # Show recent 10 candidates - 'total_candidates': total_candidates, - 'active_candidates': active_candidates, - 'hired_candidates': hired_candidates, - 'rejected_candidates': rejected_candidates, + "agency": agency, + "candidates": candidates[:10], # Show recent 10 candidates + "total_candidates": total_candidates, + "active_candidates": active_candidates, + "hired_candidates": hired_candidates, + "rejected_candidates": rejected_candidates, } - return render(request, 'recruitment/agency_detail.html', context) + return render(request, "recruitment/agency_detail.html", context) @login_required @@ -2560,24 +2843,24 @@ def agency_update(request, slug): """Update an existing hiring agency""" agency = get_object_or_404(HiringAgency, slug=slug) - if request.method == 'POST': + if request.method == "POST": form = HiringAgencyForm(request.POST, instance=agency) if form.is_valid(): agency = form.save() messages.success(request, f'Agency "{agency.name}" updated successfully!') - return redirect('agency_detail', slug=agency.slug) + return redirect("agency_detail", slug=agency.slug) else: - messages.error(request, 'Please correct the errors below.') + messages.error(request, "Please correct the errors below.") else: form = HiringAgencyForm(instance=agency) context = { - 'form': form, - 'agency': agency, - 'title': f'Edit Agency: {agency.name}', - 'button_text': 'Update Agency', + "form": form, + "agency": agency, + "title": f"Edit Agency: {agency.name}", + "button_text": "Update Agency", } - return render(request, 'recruitment/agency_form.html', context) + return render(request, "recruitment/agency_form.html", context) @login_required @@ -2585,19 +2868,19 @@ def agency_delete(request, slug): """Delete a hiring agency""" agency = get_object_or_404(HiringAgency, slug=slug) - if request.method == 'POST': + if request.method == "POST": agency_name = agency.name agency.delete() messages.success(request, f'Agency "{agency_name}" deleted successfully!') - return redirect('agency_list') + return redirect("agency_list") context = { - 'agency': agency, - 'title': 'Delete Agency', - 'message': f'Are you sure you want to delete the agency "{agency.name}"?', - 'cancel_url': reverse('agency_detail', kwargs={'slug': agency.slug}), + "agency": agency, + "title": "Delete Agency", + "message": f'Are you sure you want to delete the agency "{agency.name}"?', + "cancel_url": reverse("agency_detail", kwargs={"slug": agency.slug}), } - return render(request, 'recruitment/agency_confirm_delete.html', context) + return render(request, "recruitment/agency_confirm_delete.html", context) # Notification Views @@ -2902,23 +3185,23 @@ def agency_delete(request, slug): # response['X-Accel-Buffering'] = 'no' # Disable buffering for nginx # response['Connection'] = 'keep-alive' - # context = { - # 'agency': agency, - # 'page_obj': page_obj, - # 'stage_filter': stage_filter, - # 'total_candidates': candidates.count(), - # } - # return render(request, 'recruitment/agency_candidates.html', context) +# context = { +# 'agency': agency, +# 'page_obj': page_obj, +# 'stage_filter': stage_filter, +# 'total_candidates': candidates.count(), +# } +# return render(request, 'recruitment/agency_candidates.html', context) @login_required def agency_candidates(request, slug): """View all candidates from a specific agency""" agency = get_object_or_404(HiringAgency, slug=slug) - candidates = Candidate.objects.filter(hiring_agency=agency).order_by('-created_at') + candidates = Candidate.objects.filter(hiring_agency=agency).order_by("-created_at") # Filter by stage if provided - stage_filter = request.GET.get('stage') + stage_filter = request.GET.get("stage") if stage_filter: candidates = candidates.filter(stage=stage_filter) @@ -2927,35 +3210,33 @@ def agency_candidates(request, slug): # Pagination paginator = Paginator(candidates, 20) # Show 20 candidates per page - page_number = request.GET.get('page') + page_number = request.GET.get("page") page_obj = paginator.get_page(page_number) context = { - 'agency': agency, - 'page_obj': page_obj, - 'stage_filter': stage_filter, - 'total_candidates': total_candidates, + "agency": agency, + "page_obj": page_obj, + "stage_filter": stage_filter, + "total_candidates": total_candidates, } - return render(request, 'recruitment/agency_candidates.html', context) - - + return render(request, "recruitment/agency_candidates.html", context) # Agency Portal Management Views @login_required def agency_assignment_list(request): """List all agency job assignments""" - search_query = request.GET.get('q', '') - status_filter = request.GET.get('status', '') + search_query = request.GET.get("q", "") + status_filter = request.GET.get("status", "") - assignments = AgencyJobAssignment.objects.select_related( - 'agency', 'job' - ).order_by('-created_at') + assignments = AgencyJobAssignment.objects.select_related("agency", "job").order_by( + "-created_at" + ) if search_query: assignments = assignments.filter( - Q(agency__name__icontains=search_query) | - Q(job__title__icontains=search_query) + Q(agency__name__icontains=search_query) + | Q(job__title__icontains=search_query) ) if status_filter: @@ -2963,90 +3244,91 @@ def agency_assignment_list(request): # Pagination paginator = Paginator(assignments, 15) # Show 15 assignments per page - page_number = request.GET.get('page') + page_number = request.GET.get("page") page_obj = paginator.get_page(page_number) context = { - 'page_obj': page_obj, - 'search_query': search_query, - 'status_filter': status_filter, - 'total_assignments': assignments.count(), + "page_obj": page_obj, + "search_query": search_query, + "status_filter": status_filter, + "total_assignments": assignments.count(), } - return render(request, 'recruitment/agency_assignment_list.html', context) + return render(request, "recruitment/agency_assignment_list.html", context) @login_required -def agency_assignment_create(request,slug=None): +def agency_assignment_create(request, slug=None): """Create a new agency job assignment""" agency = HiringAgency.objects.get(slug=slug) if slug else None - if request.method == 'POST': + if request.method == "POST": form = AgencyJobAssignmentForm(request.POST) # if agency: # form.instance.agency = agency if form.is_valid(): assignment = form.save() - messages.success(request, f'Assignment created for {assignment.agency.name} - {assignment.job.title}!') - return redirect('agency_assignment_detail', slug=assignment.slug) + messages.success( + request, + f"Assignment created for {assignment.agency.name} - {assignment.job.title}!", + ) + return redirect("agency_assignment_detail", slug=assignment.slug) else: - messages.error(request, f'Please correct the errors below.{form.errors.as_text()}') + messages.error( + request, f"Please correct the errors below.{form.errors.as_text()}" + ) print(form.errors.as_json()) else: form = AgencyJobAssignmentForm() try: # from django.forms import HiddenInput - form.initial['agency'] = agency + form.initial["agency"] = agency # form.fields['agency'].widget = HiddenInput() except HiringAgency.DoesNotExist: pass context = { - 'form': form, - 'title': 'Create New Assignment', - 'button_text': 'Create Assignment', + "form": form, + "title": "Create New Assignment", + "button_text": "Create Assignment", } - return render(request, 'recruitment/agency_assignment_form.html', context) + return render(request, "recruitment/agency_assignment_form.html", context) @login_required def agency_assignment_detail(request, slug): """View details of a specific agency assignment""" assignment = get_object_or_404( - AgencyJobAssignment.objects.select_related('agency', 'job'), - slug=slug + AgencyJobAssignment.objects.select_related("agency", "job"), slug=slug ) # Get candidates submitted by this agency for this job candidates = Candidate.objects.filter( - hiring_agency=assignment.agency, - job=assignment.job - ).order_by('-created_at') + hiring_agency=assignment.agency, job=assignment.job + ).order_by("-created_at") # Get access link if exists - access_link = getattr(assignment, 'access_link', None) + access_link = getattr(assignment, "access_link", None) # Get messages for this assignment - total_candidates = candidates.count() max_candidates = assignment.max_candidates circumference = 326.73 # 2 * π * r where r=52 if max_candidates > 0: - progress_percentage = (total_candidates / max_candidates) + progress_percentage = total_candidates / max_candidates stroke_dashoffset = circumference - (circumference * progress_percentage) else: stroke_dashoffset = circumference context = { - 'assignment': assignment, - 'candidates': candidates, - 'access_link': access_link, - - 'total_candidates': candidates.count(), - 'stroke_dashoffset': stroke_dashoffset, + "assignment": assignment, + "candidates": candidates, + "access_link": access_link, + "total_candidates": candidates.count(), + "stroke_dashoffset": stroke_dashoffset, } - return render(request, 'recruitment/agency_assignment_detail.html', context) + return render(request, "recruitment/agency_assignment_detail.html", context) @login_required @@ -3054,75 +3336,67 @@ def agency_assignment_update(request, slug): """Update an existing agency assignment""" assignment = get_object_or_404(AgencyJobAssignment, slug=slug) - if request.method == 'POST': + if request.method == "POST": form = AgencyJobAssignmentForm(request.POST, instance=assignment) if form.is_valid(): assignment = form.save() - messages.success(request, f'Assignment updated successfully!') - return redirect('agency_assignment_detail', slug=assignment.slug) + messages.success(request, f"Assignment updated successfully!") + return redirect("agency_assignment_detail", slug=assignment.slug) else: - messages.error(request, 'Please correct the errors below.') + messages.error(request, "Please correct the errors below.") else: form = AgencyJobAssignmentForm(instance=assignment) context = { - 'form': form, - 'assignment': assignment, - 'title': f'Edit Assignment: {assignment.agency.name} - {assignment.job.title}', - 'button_text': 'Update Assignment', + "form": form, + "assignment": assignment, + "title": f"Edit Assignment: {assignment.agency.name} - {assignment.job.title}", + "button_text": "Update Assignment", } - return render(request, 'recruitment/agency_assignment_form.html', context) + return render(request, "recruitment/agency_assignment_form.html", context) @login_required def agency_access_link_create(request): """Create access link for agency assignment""" - if request.method == 'POST': + if request.method == "POST": form = AgencyAccessLinkForm(request.POST) if form.is_valid(): access_link = form.save() - messages.success(request, f'Access link created for {access_link.assignment.agency.name}!') - return redirect('agency_assignment_detail', slug=access_link.assignment.slug) + messages.success( + request, + f"Access link created for {access_link.assignment.agency.name}!", + ) + return redirect( + "agency_assignment_detail", slug=access_link.assignment.slug + ) else: - messages.error(request, 'Please correct the errors below.') + messages.error(request, "Please correct the errors below.") else: form = AgencyAccessLinkForm() context = { - 'form': form, - 'title': 'Create Access Link', - 'button_text': 'Create Link', + "form": form, + "title": "Create Access Link", + "button_text": "Create Link", } - return render(request, 'recruitment/agency_access_link_form.html', context) + return render(request, "recruitment/agency_access_link_form.html", context) @login_required def agency_access_link_detail(request, slug): """View details of an access link""" access_link = get_object_or_404( - AgencyAccessLink.objects.select_related('assignment__agency', 'assignment__job'), - slug=slug + AgencyAccessLink.objects.select_related( + "assignment__agency", "assignment__job" + ), + slug=slug, ) - context = { - 'access_link': access_link, + "access_link": access_link, } - return render(request, 'recruitment/agency_access_link_detail.html', context) - - - - - - - - - - - - - - + return render(request, "recruitment/agency_access_link_detail.html", context) @login_required @@ -3130,254 +3404,360 @@ def agency_assignment_extend_deadline(request, slug): """Extend deadline for an agency assignment""" assignment = get_object_or_404(AgencyJobAssignment, slug=slug) - if request.method == 'POST': - new_deadline = request.POST.get('new_deadline') + if request.method == "POST": + new_deadline = request.POST.get("new_deadline") if new_deadline: try: from datetime import datetime - new_deadline_dt = datetime.fromisoformat(new_deadline.replace('Z', '+00:00')) + + new_deadline_dt = datetime.fromisoformat( + new_deadline.replace("Z", "+00:00") + ) # Ensure the new deadline is timezone-aware if timezone.is_naive(new_deadline_dt): new_deadline_dt = timezone.make_aware(new_deadline_dt) if assignment.extend_deadline(new_deadline_dt): - messages.success(request, f'Deadline extended to {new_deadline_dt.strftime("%Y-%m-%d %H:%M")}!') + messages.success( + request, + f"Deadline extended to {new_deadline_dt.strftime('%Y-%m-%d %H:%M')}!", + ) else: - messages.error(request, 'New deadline must be later than current deadline.') + messages.error( + request, "New deadline must be later than current deadline." + ) except ValueError: - messages.error(request, 'Invalid date format.') + messages.error(request, "Invalid date format.") else: - messages.error(request, 'Please provide a new deadline.') + messages.error(request, "Please provide a new deadline.") - return redirect('agency_assignment_detail', slug=assignment.slug) + return redirect("agency_assignment_detail", slug=assignment.slug) # Agency Portal Views (for external agencies) def agency_portal_login(request): """Agency login page""" - if request.session.get('agency_assignment_id'): - return redirect('agency_portal_dashboard') - if request.method == 'POST': + if request.session.get("agency_assignment_id"): + return redirect("agency_portal_dashboard") + if request.method == "POST": form = AgencyLoginForm(request.POST) if form.is_valid(): # Check if validated_access_link attribute exists - if hasattr(form, 'validated_access_link'): + if hasattr(form, "validated_access_link"): access_link = form.validated_access_link access_link.record_access() # Store assignment in session - request.session['agency_assignment_id'] = access_link.assignment.id - request.session['agency_name'] = access_link.assignment.agency.name + request.session["agency_assignment_id"] = access_link.assignment.id + request.session["agency_name"] = access_link.assignment.agency.name - messages.success(request, f'Welcome, {access_link.assignment.agency.name}!') - return redirect('agency_portal_dashboard') + messages.success(request, f"Welcome, {access_link.assignment.agency.name}!") + return redirect("agency_portal_dashboard") else: - messages.error(request, 'Invalid token or password.') + messages.error(request, "Invalid token or password.") else: form = AgencyLoginForm() context = { - 'form': form, + "form": form, } - return render(request, 'recruitment/agency_portal_login.html', context) + return render(request, "recruitment/agency_portal_login.html", context) +def portal_login(request): + """Unified portal login for agency and candidate""" + if request.method == "POST": + form = PortalLoginForm(request.POST) + + if form.is_valid(): + email = form.cleaned_data["email"] + password = form.cleaned_data["password"] + user_type = form.cleaned_data["user_type"] + + # Authenticate user + user = authenticate(request, username=email, password=password) + if user is not None: + # Check if user type matches + if hasattr(user, "user_type") and user.user_type == user_type: + login(request, user) + + if user_type == "agency": + # Check if user has agency profile + if hasattr(user, "agency_profile") and user.agency_profile: + messages.success( + request, f"Welcome, {user.agency_profile.name}!" + ) + return redirect("agency_portal_dashboard") + else: + messages.error( + request, "No agency profile found for this user." + ) + logout(request) + + elif user_type == "candidate": + # Check if user has candidate profile + if ( + hasattr(user, "candidate_profile") + and user.candidate_profile + ): + messages.success( + request, + f"Welcome, {user.candidate_profile.first_name}!", + ) + return redirect("candidate_portal_dashboard") + else: + messages.error( + request, "No candidate profile found for this user." + ) + logout(request) + else: + messages.error(request, "Invalid user type selected.") + else: + messages.error(request, "Invalid email or password.") + else: + messages.error(request, "Please correct the errors below.") + else: + form = PortalLoginForm() + + context = { + "form": form, + } + return render(request, "recruitment/portal_login.html", context) + + +def candidate_portal_dashboard(request): + """Candidate portal dashboard""" + if not request.user.is_authenticated: + return redirect("portal_login") + + # Get candidate profile + try: + candidate = request.user.candidate_profile + except: + messages.error(request, "No candidate profile found.") + return redirect("portal_login") + + context = { + "candidate": candidate, + } + return render(request, "recruitment/candidate_portal_dashboard.html", context) + + +@login_required def agency_portal_dashboard(request): """Agency portal dashboard showing all assignments for the agency""" - assignment_id = request.session.get('agency_assignment_id') - if not assignment_id: - return redirect('agency_portal_login') - # Get the current assignment to determine the agency - current_assignment = get_object_or_404( - AgencyJobAssignment.objects.select_related('agency'), - id=assignment_id - ) - - agency = current_assignment.agency + try: + agency = request.user.agency_profile + except Exception as e: + print(e) + messages.error(request, "No agency profile found.") + return redirect("portal_login") # Get ALL assignments for this agency - assignments = AgencyJobAssignment.objects.filter( - agency=agency - ).select_related('job').order_by('-created_at') + assignments = ( + AgencyJobAssignment.objects.filter(agency=agency) + .select_related("job") + .order_by("-created_at") + ) + current_assignment = assignments.filter(is_active=True).first() # Calculate statistics for each assignment assignment_stats = [] for assignment in assignments: candidates = Candidate.objects.filter( - hiring_agency=agency, - job=assignment.job - ).order_by('-created_at') + hiring_agency=agency, job=assignment.job + ).order_by("-created_at") unread_messages = 0 - assignment_stats.append({ - 'assignment': assignment, - 'candidates': candidates, - 'candidate_count': candidates.count(), - 'unread_messages': unread_messages, - 'days_remaining': assignment.days_remaining, - 'is_active': assignment.is_currently_active, - 'can_submit': assignment.can_submit, - }) + assignment_stats.append( + { + "assignment": assignment, + "candidates": candidates, + "candidate_count": candidates.count(), + "unread_messages": unread_messages, + "days_remaining": assignment.days_remaining, + "is_active": assignment.is_currently_active, + "can_submit": assignment.can_submit, + } + ) # Get overall statistics - total_candidates = sum(stats['candidate_count'] for stats in assignment_stats) - total_unread_messages = sum(stats['unread_messages'] for stats in assignment_stats) - active_assignments = sum(1 for stats in assignment_stats if stats['is_active']) + total_candidates = sum(stats["candidate_count"] for stats in assignment_stats) + total_unread_messages = sum(stats["unread_messages"] for stats in assignment_stats) + active_assignments = sum(1 for stats in assignment_stats if stats["is_active"]) context = { - 'agency': agency, - 'current_assignment': current_assignment, - 'assignment_stats': assignment_stats, - 'total_assignments': assignments.count(), - 'active_assignments': active_assignments, - 'total_candidates': total_candidates, - 'total_unread_messages': total_unread_messages, + "agency": agency, + "current_assignment": current_assignment, + "assignment_stats": assignment_stats, + "total_assignments": assignments.count(), + "active_assignments": active_assignments, + "total_candidates": total_candidates, + "total_unread_messages": total_unread_messages, } - return render(request, 'recruitment/agency_portal_dashboard.html', context) + return render(request, "recruitment/agency_portal_dashboard.html", context) def agency_portal_submit_candidate_page(request, slug): """Dedicated page for submitting a candidate""" - assignment_id = request.session.get('agency_assignment_id') + assignment_id = request.session.get("agency_assignment_id") if not assignment_id: - return redirect('agency_portal_login') + return redirect("agency_portal_login") # Get the specific assignment by slug and verify it belongs to the same agency current_assignment = get_object_or_404( - AgencyJobAssignment.objects.select_related('agency'), - id=assignment_id + AgencyJobAssignment.objects.select_related("agency"), id=assignment_id ) assignment = get_object_or_404( - AgencyJobAssignment.objects.select_related('agency', 'job'), - slug=slug + AgencyJobAssignment.objects.select_related("agency", "job"), slug=slug ) if assignment.is_full: - messages.error(request, 'Maximum candidate limit reached for this assignment.') - return redirect('agency_portal_assignment_detail', slug=assignment.slug) + messages.error(request, "Maximum candidate limit reached for this assignment.") + return redirect("agency_portal_assignment_detail", slug=assignment.slug) # Verify this assignment belongs to the same agency as the logged-in session if assignment.agency.id != current_assignment.agency.id: - messages.error(request, 'Access denied: This assignment does not belong to your agency.') - return redirect('agency_portal_dashboard') + messages.error( + request, "Access denied: This assignment does not belong to your agency." + ) + return redirect("agency_portal_dashboard") # Check if assignment allows submission if not assignment.can_submit: - messages.error(request, 'Cannot submit candidates: Assignment is not active, expired, or full.') - return redirect('agency_portal_assignment_detail', slug=assignment.slug) + messages.error( + request, + "Cannot submit candidates: Assignment is not active, expired, or full.", + ) + return redirect("agency_portal_assignment_detail", slug=assignment.slug) # Get total submitted candidates for this assignment total_submitted = Candidate.objects.filter( - hiring_agency=assignment.agency, - job=assignment.job + hiring_agency=assignment.agency, job=assignment.job ).count() - if request.method == 'POST': + if request.method == "POST": form = AgencyCandidateSubmissionForm(assignment, request.POST, request.FILES) if form.is_valid(): candidate = form.save(commit=False) - candidate.hiring_source = 'AGENCY' + candidate.hiring_source = "AGENCY" candidate.hiring_agency = assignment.agency candidate.save() assignment.increment_submission_count() # Handle AJAX requests - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return JsonResponse({ - 'success': True, - 'message': f'Candidate {candidate.name} submitted successfully!', - 'candidate_id': candidate.id - }) + if request.headers.get("X-Requested-With") == "XMLHttpRequest": + return JsonResponse( + { + "success": True, + "message": f"Candidate {candidate.name} submitted successfully!", + "candidate_id": candidate.id, + } + ) else: - messages.success(request, f'Candidate {candidate.name} submitted successfully!') - return redirect('agency_portal_assignment_detail', slug=assignment.slug) + messages.success( + request, f"Candidate {candidate.name} submitted successfully!" + ) + return redirect("agency_portal_assignment_detail", slug=assignment.slug) else: # Handle form validation errors for AJAX - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + if request.headers.get("X-Requested-With") == "XMLHttpRequest": error_messages = [] for field, errors in form.errors.items(): for error in errors: - error_messages.append(f'{field}: {error}') - return JsonResponse({ - 'success': False, - 'message': 'Please correct the following errors: ' + '; '.join(error_messages) - }) + error_messages.append(f"{field}: {error}") + return JsonResponse( + { + "success": False, + "message": "Please correct the following errors: " + + "; ".join(error_messages), + } + ) else: - messages.error(request, 'Please correct errors below.') + messages.error(request, "Please correct errors below.") else: form = AgencyCandidateSubmissionForm(assignment) context = { - 'form': form, - 'assignment': assignment, - 'total_submitted': total_submitted, + "form": form, + "assignment": assignment, + "total_submitted": total_submitted, } - return render(request, 'recruitment/agency_portal_submit_candidate.html', context) + return render(request, "recruitment/agency_portal_submit_candidate.html", context) def agency_portal_submit_candidate(request): """Handle candidate submission via AJAX (for embedded form)""" - assignment_id = request.session.get('agency_assignment_id') + assignment_id = request.session.get("agency_assignment_id") if not assignment_id: - return redirect('agency_portal_login') + return redirect("agency_portal_login") assignment = get_object_or_404( - AgencyJobAssignment.objects.select_related('agency', 'job'), - id=assignment_id + AgencyJobAssignment.objects.select_related("agency", "job"), id=assignment_id ) if assignment.is_full: - messages.error(request, 'Maximum candidate limit reached for this assignment.') - return redirect('agency_portal_assignment_detail', slug=assignment.slug) + messages.error(request, "Maximum candidate limit reached for this assignment.") + return redirect("agency_portal_assignment_detail", slug=assignment.slug) # Check if assignment allows submission if not assignment.can_submit: - messages.error(request, 'Cannot submit candidates: Assignment is not active, expired, or full.') - return redirect('agency_portal_dashboard') + messages.error( + request, + "Cannot submit candidates: Assignment is not active, expired, or full.", + ) + return redirect("agency_portal_dashboard") - if request.method == 'POST': + if request.method == "POST": form = AgencyCandidateSubmissionForm(assignment, request.POST, request.FILES) if form.is_valid(): candidate = form.save(commit=False) - candidate.hiring_source = 'AGENCY' + candidate.hiring_source = "AGENCY" candidate.hiring_agency = assignment.agency candidate.save() # Increment the assignment's submitted count assignment.increment_submission_count() - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return JsonResponse({'success': True, 'message': f'Candidate {candidate.name} submitted successfully!'}) + if request.headers.get("X-Requested-With") == "XMLHttpRequest": + return JsonResponse( + { + "success": True, + "message": f"Candidate {candidate.name} submitted successfully!", + } + ) else: - messages.success(request, f'Candidate {candidate.name} submitted successfully!') - return redirect('agency_portal_dashboard') + messages.success( + request, f"Candidate {candidate.name} submitted successfully!" + ) + return redirect("agency_portal_dashboard") else: - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return JsonResponse({'success': False, 'message': 'Please correct the errors below.'}) + if request.headers.get("X-Requested-With") == "XMLHttpRequest": + return JsonResponse( + {"success": False, "message": "Please correct the errors below."} + ) else: - messages.error(request, 'Please correct errors below.') + messages.error(request, "Please correct errors below.") else: form = AgencyCandidateSubmissionForm(assignment) context = { - 'form': form, - 'assignment': assignment, - 'title': f'Submit Candidate for {assignment.job.title}', - 'button_text': 'Submit Candidate', + "form": form, + "assignment": assignment, + "title": f"Submit Candidate for {assignment.job.title}", + "button_text": "Submit Candidate", } - return render(request, 'recruitment/agency_portal_submit_candidate.html', context) - - + return render(request, "recruitment/agency_portal_submit_candidate.html", context) def agency_portal_assignment_detail(request, slug): """View details of a specific assignment - routes to admin or agency template""" print(slug) # Check if this is an agency portal user (via session) - assignment_id = request.session.get('agency_assignment_id') + assignment_id = request.session.get("agency_assignment_id") is_agency_user = bool(assignment_id) return agency_assignment_detail_agency(request, slug, assignment_id) # if is_agency_user: @@ -3391,25 +3771,24 @@ def agency_assignment_detail_agency(request, slug, assignment_id): """Handle agency portal assignment detail view""" # Get the assignment by slug and verify it belongs to same agency assignment = get_object_or_404( - AgencyJobAssignment.objects.select_related('agency', 'job'), - slug=slug + AgencyJobAssignment.objects.select_related("agency", "job"), slug=slug ) # Verify this assignment belongs to the same agency as the logged-in session current_assignment = get_object_or_404( - AgencyJobAssignment.objects.select_related('agency'), - id=assignment_id + AgencyJobAssignment.objects.select_related("agency"), id=assignment_id ) if assignment.agency.id != current_assignment.agency.id: - messages.error(request, 'Access denied: This assignment does not belong to your agency.') - return redirect('agency_portal_dashboard') + messages.error( + request, "Access denied: This assignment does not belong to your agency." + ) + return redirect("agency_portal_dashboard") # Get candidates submitted by this agency for this job candidates = Candidate.objects.filter( - hiring_agency=assignment.agency, - job=assignment.job - ).order_by('-created_at') + hiring_agency=assignment.agency, job=assignment.job + ).order_by("-created_at") # Get messages for this assignment messages = [] @@ -3419,12 +3798,12 @@ def agency_assignment_detail_agency(request, slug, assignment_id): # Pagination for candidates paginator = Paginator(candidates, 20) # Show 20 candidates per page - page_number = request.GET.get('page') + page_number = request.GET.get("page") page_obj = paginator.get_page(page_number) # Pagination for messages message_paginator = Paginator(messages, 15) # Show 15 messages per page - message_page_number = request.GET.get('message_page') + message_page_number = request.GET.get("message_page") message_page_obj = message_paginator.get_page(message_page_number) # Calculate progress ring offset for circular progress indicator @@ -3433,59 +3812,56 @@ def agency_assignment_detail_agency(request, slug, assignment_id): circumference = 326.73 # 2 * π * r where r=52 if max_candidates > 0: - progress_percentage = (total_candidates / max_candidates) + progress_percentage = total_candidates / max_candidates stroke_dashoffset = circumference - (circumference * progress_percentage) else: stroke_dashoffset = circumference context = { - 'assignment': assignment, - 'page_obj': page_obj, - 'message_page_obj': message_page_obj, - 'total_candidates': total_candidates, - 'stroke_dashoffset': stroke_dashoffset, + "assignment": assignment, + "page_obj": page_obj, + "message_page_obj": message_page_obj, + "total_candidates": total_candidates, + "stroke_dashoffset": stroke_dashoffset, } - return render(request, 'recruitment/agency_portal_assignment_detail.html', context) + return render(request, "recruitment/agency_portal_assignment_detail.html", context) def agency_assignment_detail_admin(request, slug): """Handle admin assignment detail view""" assignment = get_object_or_404( - AgencyJobAssignment.objects.select_related('agency', 'job'), - slug=slug + AgencyJobAssignment.objects.select_related("agency", "job"), slug=slug ) # Get candidates submitted by this agency for this job candidates = Candidate.objects.filter( - hiring_agency=assignment.agency, - job=assignment.job - ).order_by('-created_at') + hiring_agency=assignment.agency, job=assignment.job + ).order_by("-created_at") # Get access link if exists - access_link = getattr(assignment, 'access_link', None) + access_link = getattr(assignment, "access_link", None) # Get messages for this assignment messages = [] context = { - 'assignment': assignment, - 'candidates': candidates, - 'access_link': access_link, - 'total_candidates': candidates.count(), + "assignment": assignment, + "candidates": candidates, + "access_link": access_link, + "total_candidates": candidates.count(), } - return render(request, 'recruitment/agency_assignment_detail.html', context) + return render(request, "recruitment/agency_assignment_detail.html", context) def agency_portal_edit_candidate(request, candidate_id): """Edit a candidate for agency portal""" - assignment_id = request.session.get('agency_assignment_id') + assignment_id = request.session.get("agency_assignment_id") if not assignment_id: - return redirect('agency_portal_login') + return redirect("agency_portal_login") # Get current assignment to determine agency current_assignment = get_object_or_404( - AgencyJobAssignment.objects.select_related('agency'), - id=assignment_id + AgencyJobAssignment.objects.select_related("agency"), id=assignment_id ) agency = current_assignment.agency @@ -3493,53 +3869,59 @@ def agency_portal_edit_candidate(request, candidate_id): # Get candidate and verify it belongs to this agency candidate = get_object_or_404(Candidate, id=candidate_id, hiring_agency=agency) - if request.method == 'POST': + if request.method == "POST": # Handle form submission - candidate.first_name = request.POST.get('first_name', candidate.first_name) - candidate.last_name = request.POST.get('last_name', candidate.last_name) - candidate.email = request.POST.get('email', candidate.email) - candidate.phone = request.POST.get('phone', candidate.phone) - candidate.address = request.POST.get('address', candidate.address) + candidate.first_name = request.POST.get("first_name", candidate.first_name) + candidate.last_name = request.POST.get("last_name", candidate.last_name) + candidate.email = request.POST.get("email", candidate.email) + candidate.phone = request.POST.get("phone", candidate.phone) + candidate.address = request.POST.get("address", candidate.address) # Handle resume upload if provided - if 'resume' in request.FILES: - candidate.resume = request.FILES['resume'] + if "resume" in request.FILES: + candidate.resume = request.FILES["resume"] try: candidate.save() - messages.success(request, f'Candidate {candidate.name} updated successfully!') - return redirect('agency_assignment_detail', slug=candidate.job.agencyjobassignment_set.first().slug) + messages.success( + request, f"Candidate {candidate.name} updated successfully!" + ) + return redirect( + "agency_assignment_detail", + slug=candidate.job.agencyjobassignment_set.first().slug, + ) except Exception as e: - messages.error(request, f'Error updating candidate: {e}') + messages.error(request, f"Error updating candidate: {e}") # For GET requests or POST errors, return JSON response for AJAX - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return JsonResponse({ - 'success': True, - 'candidate': { - 'id': candidate.id, - 'first_name': candidate.first_name, - 'last_name': candidate.last_name, - 'email': candidate.email, - 'phone': candidate.phone, - 'address': candidate.address, + if request.headers.get("X-Requested-With") == "XMLHttpRequest": + return JsonResponse( + { + "success": True, + "candidate": { + "id": candidate.id, + "first_name": candidate.first_name, + "last_name": candidate.last_name, + "email": candidate.email, + "phone": candidate.phone, + "address": candidate.address, + }, } - }) + ) # Fallback for non-AJAX requests - return redirect('agency_portal_dashboard') + return redirect("agency_portal_dashboard") def agency_portal_delete_candidate(request, candidate_id): """Delete a candidate for agency portal""" - assignment_id = request.session.get('agency_assignment_id') + assignment_id = request.session.get("agency_assignment_id") if not assignment_id: - return redirect('agency_portal_login') + return redirect("agency_portal_login") # Get current assignment to determine agency current_assignment = get_object_or_404( - AgencyJobAssignment.objects.select_related('agency'), - id=assignment_id + AgencyJobAssignment.objects.select_related("agency"), id=assignment_id ) agency = current_assignment.agency @@ -3547,128 +3929,119 @@ def agency_portal_delete_candidate(request, candidate_id): # Get candidate and verify it belongs to this agency candidate = get_object_or_404(Candidate, id=candidate_id, hiring_agency=agency) - if request.method == 'POST': + if request.method == "POST": try: candidate_name = candidate.name candidate.delete() current_assignment.candidates_submitted -= 1 current_assignment.status = current_assignment.AssignmentStatus.ACTIVE - current_assignment.save(update_fields=['candidates_submitted','status']) + current_assignment.save(update_fields=["candidates_submitted", "status"]) - messages.success(request, f'Candidate {candidate_name} removed successfully!') - return JsonResponse({'success': True}) + messages.success( + request, f"Candidate {candidate_name} removed successfully!" + ) + return JsonResponse({"success": True}) except Exception as e: - return JsonResponse({'success': False, 'error': str(e)}) + return JsonResponse({"success": False, "error": str(e)}) # For GET requests, return error - return JsonResponse({'success': False, 'error': 'Method not allowed'}) + return JsonResponse({"success": False, "error": "Method not allowed"}) -def agency_portal_logout(request): - """Logout from agency portal""" - if 'agency_assignment_id' in request.session: - del request.session['agency_assignment_id'] - if 'agency_name' in request.session: - del request.session['agency_name'] +def portal_logout(request): + """Logout from portal""" + logout(request) - messages.success(request, 'You have been logged out.') - return redirect('agency_portal_login') + messages.success(request, "You have been logged out.") + return redirect("portal_login") @login_required def agency_access_link_deactivate(request, slug): """Deactivate an agency access link""" access_link = get_object_or_404( - AgencyAccessLink.objects.select_related('assignment__agency', 'assignment__job'), - slug=slug + AgencyAccessLink.objects.select_related( + "assignment__agency", "assignment__job" + ), + slug=slug, ) - if request.method == 'POST': + if request.method == "POST": access_link.is_active = False - access_link.save(update_fields=['is_active']) + access_link.save(update_fields=["is_active"]) messages.success( request, - f'Access link for {access_link.assignment.agency.name} - {access_link.assignment.job.title} has been deactivated.' + f"Access link for {access_link.assignment.agency.name} - {access_link.assignment.job.title} has been deactivated.", ) # Handle HTMX requests - if 'HX-Request' in request.headers: + if "HX-Request" in request.headers: return HttpResponse(status=200) # HTMX success response - return redirect('agency_assignment_detail', slug=access_link.assignment.slug) + return redirect("agency_assignment_detail", slug=access_link.assignment.slug) # For GET requests, show confirmation page context = { - 'access_link': access_link, - 'title': 'Deactivate Access Link', - 'message': f'Are you sure you want to deactivate the access link for {access_link.assignment.agency.name}?', - 'cancel_url': reverse('agency_assignment_detail', kwargs={'slug': access_link.assignment.slug}), + "access_link": access_link, + "title": "Deactivate Access Link", + "message": f"Are you sure you want to deactivate the access link for {access_link.assignment.agency.name}?", + "cancel_url": reverse( + "agency_assignment_detail", kwargs={"slug": access_link.assignment.slug} + ), } - return render(request, 'recruitment/agency_access_link_confirm.html', context) + return render(request, "recruitment/agency_access_link_confirm.html", context) @login_required def agency_access_link_reactivate(request, slug): """Reactivate an agency access link""" access_link = get_object_or_404( - AgencyAccessLink.objects.select_related('assignment__agency', 'assignment__job'), - slug=slug + AgencyAccessLink.objects.select_related( + "assignment__agency", "assignment__job" + ), + slug=slug, ) - if request.method == 'POST': + if request.method == "POST": access_link.is_active = True - access_link.save(update_fields=['is_active']) + access_link.save(update_fields=["is_active"]) messages.success( request, - f'Access link for {access_link.assignment.agency.name} - {access_link.assignment.job.title} has been reactivated.' + f"Access link for {access_link.assignment.agency.name} - {access_link.assignment.job.title} has been reactivated.", ) # Handle HTMX requests - if 'HX-Request' in request.headers: + if "HX-Request" in request.headers: return HttpResponse(status=200) # HTMX success response - return redirect('agency_assignment_detail', slug=access_link.assignment.slug) + return redirect("agency_assignment_detail", slug=access_link.assignment.slug) # For GET requests, show confirmation page context = { - 'access_link': access_link, - 'title': 'Reactivate Access Link', - 'message': f'Are you sure you want to reactivate the access link for {access_link.assignment.agency.name}?', - 'cancel_url': reverse('agency_assignment_detail', kwargs={'slug': access_link.assignment.slug}), + "access_link": access_link, + "title": "Reactivate Access Link", + "message": f"Are you sure you want to reactivate the access link for {access_link.assignment.agency.name}?", + "cancel_url": reverse( + "agency_assignment_detail", kwargs={"slug": access_link.assignment.slug} + ), } - return render(request, 'recruitment/agency_access_link_confirm.html', context) - - - - - - - - - - - - - - - + return render(request, "recruitment/agency_access_link_confirm.html", context) def api_candidate_detail(request, candidate_id): """API endpoint to get candidate details for agency portal""" try: # Get candidate from session-based agency access - assignment_id = request.session.get('agency_assignment_id') + assignment_id = request.session.get("agency_assignment_id") if not assignment_id: - return JsonResponse({'success': False, 'error': 'Access denied'}) + return JsonResponse({"success": False, "error": "Access denied"}) # Get current assignment to determine agency current_assignment = get_object_or_404( - AgencyJobAssignment.objects.select_related('agency'), - id=assignment_id + AgencyJobAssignment.objects.select_related("agency"), id=assignment_id ) agency = current_assignment.agency @@ -3678,19 +4051,19 @@ def api_candidate_detail(request, candidate_id): # Return candidate data response_data = { - 'success': True, - 'id': candidate.id, - 'first_name': candidate.first_name, - 'last_name': candidate.last_name, - 'email': candidate.email, - 'phone': candidate.phone, - 'address': candidate.address, + "success": True, + "id": candidate.id, + "first_name": candidate.first_name, + "last_name": candidate.last_name, + "email": candidate.email, + "phone": candidate.phone, + "address": candidate.address, } return JsonResponse(response_data) except Exception as e: - return JsonResponse({'success': False, 'error': str(e)}) + return JsonResponse({"success": False, "error": str(e)}) @login_required @@ -3700,46 +4073,51 @@ def compose_candidate_email(request, job_slug, candidate_slug): job = get_object_or_404(JobPosting, slug=job_slug) candidate = get_object_or_404(Candidate, slug=candidate_slug, job=job) - if request.method == 'POST': + if request.method == "POST": form = CandidateEmailForm(job, candidate, request.POST) if form.is_valid(): # Get email addresses email_addresses = form.get_email_addresses() if not email_addresses: - messages.error(request, 'No valid email addresses found for selected recipients.') - return render(request, 'includes/email_compose_form.html', { - 'form': form, - 'job': job, - 'candidate': candidate - }) + messages.error( + request, "No valid email addresses found for selected recipients." + ) + return render( + request, + "includes/email_compose_form.html", + {"form": form, "job": job, "candidate": candidate}, + ) # Check if this is an interview invitation - subject = form.cleaned_data.get('subject', '').lower() - is_interview_invitation = 'interview' in subject or 'meeting' in subject + subject = form.cleaned_data.get("subject", "").lower() + is_interview_invitation = "interview" in subject or "meeting" in subject if is_interview_invitation: # Use HTML template for interview invitations meeting_details = None - if form.cleaned_data.get('include_meeting_details'): + if form.cleaned_data.get("include_meeting_details"): # Try to get meeting details from candidate meeting_details = { - 'topic': f'Interview for {job.title}', - 'date_time': getattr(candidate, 'interview_date', 'To be scheduled'), - 'duration': '60 minutes', - 'join_url': getattr(candidate, 'meeting_url', ''), + "topic": f"Interview for {job.title}", + "date_time": getattr( + candidate, "interview_date", "To be scheduled" + ), + "duration": "60 minutes", + "join_url": getattr(candidate, "meeting_url", ""), } from .email_service import send_interview_invitation_email + email_result = send_interview_invitation_email( candidate=candidate, job=job, meeting_details=meeting_details, - recipient_list=email_addresses + recipient_list=email_addresses, ) else: # Get formatted message for regular emails message = form.get_formatted_message() - subject = form.cleaned_data.get('subject') + subject = form.cleaned_data.get("subject") # Send emails using email service (no attachments, synchronous to avoid pickle issues) email_result = send_bulk_email( @@ -3747,35 +4125,47 @@ def compose_candidate_email(request, job_slug, candidate_slug): message=message, recipient_list=email_addresses, request=request, - async_task_=False # Changed to False to avoid pickle issues + async_task_=False, # Changed to False to avoid pickle issues ) - if email_result['success']: - messages.success(request, f'Email sent successfully to {len(email_addresses)} recipient(s).') + if email_result["success"]: + messages.success( + request, + f"Email sent successfully to {len(email_addresses)} recipient(s).", + ) # For HTMX requests, return success response - if 'HX-Request' in request.headers: - return JsonResponse({ - 'success': True, - 'message': f'Email sent successfully to {len(email_addresses)} recipient(s).' - }) + if "HX-Request" in request.headers: + return JsonResponse( + { + "success": True, + "message": f"Email sent successfully to {len(email_addresses)} recipient(s).", + } + ) - return redirect('candidate_interview_view', slug=job.slug) + return redirect("candidate_interview_view", slug=job.slug) else: - messages.error(request, f'Failed to send email: {email_result.get("message", "Unknown error")}') + messages.error( + request, + f"Failed to send email: {email_result.get('message', 'Unknown error')}", + ) # For HTMX requests, return error response - if 'HX-Request' in request.headers: - return JsonResponse({ - 'success': False, - 'error': email_result.get("message", "Failed to send email") - }) + if "HX-Request" in request.headers: + return JsonResponse( + { + "success": False, + "error": email_result.get( + "message", "Failed to send email" + ), + } + ) - return render(request, 'includes/email_compose_form.html', { - 'form': form, - 'job': job, - 'candidate': candidate - }) + return render( + request, + "includes/email_compose_form.html", + {"form": form, "job": job, "candidate": candidate}, + ) # except Exception as e: # logger.error(f"Error sending candidate email: {e}") @@ -3796,82 +4186,84 @@ def compose_candidate_email(request, job_slug, candidate_slug): else: # Form validation errors print(form.errors) - messages.error(request, 'Please correct the errors below.') + messages.error(request, "Please correct the errors below.") # For HTMX requests, return error response - if 'HX-Request' in request.headers: - return JsonResponse({ - 'success': False, - 'error': 'Please correct the form errors and try again.' - }) + if "HX-Request" in request.headers: + return JsonResponse( + { + "success": False, + "error": "Please correct the form errors and try again.", + } + ) - return render(request, 'includes/email_compose_form.html', { - 'form': form, - 'job': job, - 'candidate': candidate - }) + return render( + request, + "includes/email_compose_form.html", + {"form": form, "job": job, "candidate": candidate}, + ) else: # GET request - show the form form = CandidateEmailForm(job, candidate) - return render(request, 'includes/email_compose_form.html', { - 'form': form, - 'job': job, - 'candidate': candidate - }) + return render( + request, + "includes/email_compose_form.html", + {"form": form, "job": job, "candidate": candidate}, + ) # Source CRUD Views @login_required def source_list(request): """List all sources with search and pagination""" - search_query = request.GET.get('q', '') + search_query = request.GET.get("q", "") sources = Source.objects.all() if search_query: sources = sources.filter( - Q(name__icontains=search_query) | - Q(source_type__icontains=search_query) | - Q(description__icontains=search_query) + Q(name__icontains=search_query) + | Q(source_type__icontains=search_query) + | Q(description__icontains=search_query) ) # Order by most recently created - sources = sources.order_by('-created_at') + sources = sources.order_by("-created_at") # Pagination paginator = Paginator(sources, 15) # Show 15 sources per page - page_number = request.GET.get('page') + page_number = request.GET.get("page") page_obj = paginator.get_page(page_number) context = { - 'page_obj': page_obj, - 'search_query': search_query, - 'total_sources': sources.count(), + "page_obj": page_obj, + "search_query": search_query, + "total_sources": sources.count(), } - return render(request, 'recruitment/source_list.html', context) + return render(request, "recruitment/source_list.html", context) @login_required def source_create(request): """Create a new source""" - if request.method == 'POST': + if request.method == "POST": form = SourceForm(request.POST) if form.is_valid(): source = form.save() messages.success(request, f'Source "{source.name}" created successfully!') - return redirect('source_detail', slug=source.slug) + return redirect("source_detail", slug=source.slug) else: - messages.error(request, 'Please correct the errors below.') + messages.error(request, "Please correct the errors below.") else: form = SourceForm() context = { - 'form': form, - 'title': 'Create New Source', - 'button_text': 'Create Source', + "form": form, + "title": "Create New Source", + "button_text": "Create Source", } - return render(request, 'recruitment/source_form.html', context) + return render(request, "recruitment/source_form.html", context) @login_required @@ -3880,21 +4272,25 @@ def source_detail(request, slug): source = get_object_or_404(Source, slug=slug) # Get integration logs for this source - integration_logs = source.integration_logs.order_by('-created_at')[:10] # Show recent 10 logs + integration_logs = source.integration_logs.order_by("-created_at")[ + :10 + ] # Show recent 10 logs # Statistics total_logs = source.integration_logs.count() - successful_logs = source.integration_logs.filter(method='POST').count() - failed_logs = source.integration_logs.filter(method='POST', status_code__gte=400).count() + successful_logs = source.integration_logs.filter(method="POST").count() + failed_logs = source.integration_logs.filter( + method="POST", status_code__gte=400 + ).count() context = { - 'source': source, - 'integration_logs': integration_logs, - 'total_logs': total_logs, - 'successful_logs': successful_logs, - 'failed_logs': failed_logs, + "source": source, + "integration_logs": integration_logs, + "total_logs": total_logs, + "successful_logs": successful_logs, + "failed_logs": failed_logs, } - return render(request, 'recruitment/source_detail.html', context) + return render(request, "recruitment/source_detail.html", context) @login_required @@ -3902,24 +4298,24 @@ def source_update(request, slug): """Update an existing source""" source = get_object_or_404(Source, slug=slug) - if request.method == 'POST': + if request.method == "POST": form = SourceForm(request.POST, instance=source) if form.is_valid(): source = form.save() messages.success(request, f'Source "{source.name}" updated successfully!') - return redirect('source_detail', slug=source.slug) + return redirect("source_detail", slug=source.slug) else: - messages.error(request, 'Please correct the errors below.') + messages.error(request, "Please correct the errors below.") else: form = SourceForm(instance=source) context = { - 'form': form, - 'source': source, - 'title': f'Edit Source: {source.name}', - 'button_text': 'Update Source', + "form": form, + "source": source, + "title": f"Edit Source: {source.name}", + "button_text": "Update Source", } - return render(request, 'recruitment/source_form.html', context) + return render(request, "recruitment/source_form.html", context) @login_required @@ -3927,19 +4323,19 @@ def source_delete(request, slug): """Delete a source""" source = get_object_or_404(Source, slug=slug) - if request.method == 'POST': + if request.method == "POST": source_name = source.name source.delete() messages.success(request, f'Source "{source_name}" deleted successfully!') - return redirect('source_list') + return redirect("source_list") context = { - 'source': source, - 'title': 'Delete Source', - 'message': f'Are you sure you want to delete the source "{source.name}"?', - 'cancel_url': reverse('source_detail', kwargs={'slug': source.slug}), + "source": source, + "title": "Delete Source", + "message": f'Are you sure you want to delete the source "{source.name}"?', + "cancel_url": reverse("source_detail", kwargs={"slug": source.slug}), } - return render(request, 'recruitment/source_confirm_delete.html', context) + return render(request, "recruitment/source_confirm_delete.html", context) @login_required @@ -3947,24 +4343,25 @@ def source_generate_keys(request, slug): """Generate new API keys for a source""" source = get_object_or_404(Source, slug=slug) - if request.method == 'POST': + if request.method == "POST": # Generate new API key and secret from .forms import generate_api_key, generate_api_secret + source.api_key = generate_api_key() source.api_secret = generate_api_secret() - source.save(update_fields=['api_key', 'api_secret']) + source.save(update_fields=["api_key", "api_secret"]) messages.success(request, f'New API keys generated for "{source.name}"!') - return redirect('source_detail', slug=source.slug) + return redirect("source_detail", slug=source.slug) # For GET requests, show confirmation page context = { - 'source': source, - 'title': 'Generate New API Keys', - 'message': f'Are you sure you want to generate new API keys for "{source.name}"? This will invalidate the existing keys.', - 'cancel_url': reverse('source_detail', kwargs={'slug': source.slug}), + "source": source, + "title": "Generate New API Keys", + "message": f'Are you sure you want to generate new API keys for "{source.name}"? This will invalidate the existing keys.', + "cancel_url": reverse("source_detail", kwargs={"slug": source.slug}), } - return render(request, 'recruitment/source_confirm_generate_keys.html', context) + return render(request, "recruitment/source_confirm_generate_keys.html", context) @login_required @@ -3972,18 +4369,18 @@ def source_toggle_status(request, slug): """Toggle active status of a source""" source = get_object_or_404(Source, slug=slug) - if request.method == 'POST': + if request.method == "POST": source.is_active = not source.is_active - source.save(update_fields=['is_active']) + source.save(update_fields=["is_active"]) - status_text = 'activated' if source.is_active else 'deactivated' + status_text = "activated" if source.is_active else "deactivated" messages.success(request, f'Source "{source.name}" has been {status_text}!') # Handle HTMX requests - if 'HX-Request' in request.headers: + if "HX-Request" in request.headers: return HttpResponse(status=200) # HTMX success response - return redirect('source_detail', slug=source.slug) + return redirect("source_detail", slug=source.slug) # For GET requests, return error - return JsonResponse({'success': False, 'error': 'Method not allowed'}) + return JsonResponse({"success": False, "error": "Method not allowed"}) diff --git a/templates/agency_base.html b/templates/portal_base.html similarity index 92% rename from templates/agency_base.html rename to templates/portal_base.html index 03b71e3..0e1b28b 100644 --- a/templates/agency_base.html +++ b/templates/portal_base.html @@ -49,16 +49,23 @@ - + {# Using inline style for nav background color - replace with a dedicated CSS class (e.g., .bg-kaauh-nav) if defined in main.css #} -

+
@@ -146,7 +157,7 @@ - + {# JavaScript (Left unchanged as it was mostly correct) #} +{% endblock %} \ No newline at end of file