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 02337a2..dc7eec9 100644 Binary files a/recruitment/__pycache__/admin.cpython-313.pyc and b/recruitment/__pycache__/admin.cpython-313.pyc differ diff --git a/recruitment/__pycache__/forms.cpython-313.pyc b/recruitment/__pycache__/forms.cpython-313.pyc index a43798f..10fc957 100644 Binary files a/recruitment/__pycache__/forms.cpython-313.pyc and b/recruitment/__pycache__/forms.cpython-313.pyc differ diff --git a/recruitment/__pycache__/models.cpython-313.pyc b/recruitment/__pycache__/models.cpython-313.pyc index 87d5a3f..ad0d791 100644 Binary files a/recruitment/__pycache__/models.cpython-313.pyc and b/recruitment/__pycache__/models.cpython-313.pyc differ diff --git a/recruitment/__pycache__/views.cpython-313.pyc b/recruitment/__pycache__/views.cpython-313.pyc index fe1c524..bc38135 100644 Binary files a/recruitment/__pycache__/views.cpython-313.pyc and b/recruitment/__pycache__/views.cpython-313.pyc differ 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 2374032..8a43ab6 100644 Binary files a/recruitment/management/__pycache__/__init__.cpython-313.pyc and b/recruitment/management/__pycache__/__init__.cpython-313.pyc differ diff --git a/recruitment/migrations/0003_auto_20251105_1616.py b/recruitment/migrations/0003_auto_20251105_1616.py new file mode 100644 index 0000000..1feecfd --- /dev/null +++ b/recruitment/migrations/0003_auto_20251105_1616.py @@ -0,0 +1,38 @@ +# Generated migration for adding user relationships + +from django.db import migrations, models +import django.db.models.deletion +from django.conf import settings + + +class Migration(migrations.Migration): + dependencies = [ + ("recruitment", "0002_alter_jobposting_job_type_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="candidate", + name="user", + field=models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="candidate_profile", + to=settings.AUTH_USER_MODEL, + verbose_name="User", + ), + ), + migrations.AddField( + model_name="hiringagency", + name="user", + field=models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="agency_profile", + to=settings.AUTH_USER_MODEL, + verbose_name="User", + ), + ), + ] diff --git a/recruitment/migrations/0004_alter_candidate_ai_analysis_data.py b/recruitment/migrations/0004_alter_candidate_ai_analysis_data.py new file mode 100644 index 0000000..7f7beaf --- /dev/null +++ b/recruitment/migrations/0004_alter_candidate_ai_analysis_data.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.6 on 2025-11-05 13:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0003_auto_20251105_1616'), + ] + + operations = [ + migrations.AlterField( + model_name='candidate', + name='ai_analysis_data', + field=models.JSONField(blank=True, default=dict, help_text='Full JSON output from the resume scoring model.', null=True, verbose_name='AI Analysis Data'), + ), + ] diff --git a/recruitment/migrations/0005_customuser.py b/recruitment/migrations/0005_customuser.py new file mode 100644 index 0000000..0f209bf --- /dev/null +++ b/recruitment/migrations/0005_customuser.py @@ -0,0 +1,44 @@ +# Generated by Django 5.2.6 on 2025-11-05 13:37 + +import django.contrib.auth.models +import django.contrib.auth.validators +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ('recruitment', '0004_alter_candidate_ai_analysis_data'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('user_type', models.CharField(choices=[('staff', 'Staff'), ('agency', 'Agency'), ('candidate', 'Candidate')], default='staff', max_length=20, verbose_name='User Type')), + ('phone', models.CharField(blank=True, max_length=20, null=True, verbose_name='Phone')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'User', + 'verbose_name_plural': 'Users', + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + ] diff --git a/recruitment/models.py b/recruitment/models.py index 5a4e464..11ca53f 100644 --- a/recruitment/models.py +++ b/recruitment/models.py @@ -1,11 +1,12 @@ from django.db import models from django.urls import reverse -from typing import List,Dict,Any +from typing import List, Dict, Any from django.utils import timezone -from django.db.models import FloatField,CharField,IntegerField -from django.db.models.functions import Cast,Coalesce +from django.db.models import FloatField, CharField, IntegerField +from django.db.models.functions import Cast, Coalesce from django.db.models import F -from django.contrib.auth.models import User +from django.contrib.auth.models import AbstractUser +from django.contrib.auth import get_user_model from django.core.validators import URLValidator from django_countries.fields import CountryField from django.core.exceptions import ValidationError @@ -14,6 +15,31 @@ from django.utils.html import strip_tags from django.utils.translation import gettext_lazy as _ from django_extensions.db.fields import RandomCharField from .validators import validate_hash_tags, validate_image_size +from django.contrib.auth.models import AbstractUser + + +class CustomUser(AbstractUser): + """Custom user model extending AbstractUser""" + + USER_TYPES = [ + ("staff", _("Staff")), + ("agency", _("Agency")), + ("candidate", _("Candidate")), + ] + + user_type = models.CharField( + max_length=20, choices=USER_TYPES, default="staff", verbose_name=_("User Type") + ) + phone = models.CharField( + max_length=20, blank=True, null=True, verbose_name=_("Phone") + ) + + class Meta: + verbose_name = _("User") + verbose_name_plural = _("Users") + + +User = get_user_model() class Base(models.Model): @@ -26,10 +52,18 @@ class Base(models.Model): class Meta: abstract = True + class Profile(models.Model): - profile_image = models.ImageField(null=True, blank=True, upload_to="profile_pic/",validators=[validate_image_size]) - designation = models.CharField(max_length=100, blank=True,null=True) - phone=models.CharField(blank=True,null=True,verbose_name=_("Phone Number"),max_length=12) + profile_image = models.ImageField( + null=True, + blank=True, + upload_to="profile_pic/", + validators=[validate_image_size], + ) + designation = models.CharField(max_length=100, blank=True, null=True) + phone = models.CharField( + blank=True, null=True, verbose_name=_("Phone Number"), max_length=12 + ) user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile") def __str__(self): @@ -54,17 +88,22 @@ class JobPosting(Base): (_("Hybrid"), _("Hybrid")), ] - users=models.ManyToManyField( + users = models.ManyToManyField( User, - blank=True,related_name="jobs_assigned", + blank=True, + related_name="jobs_assigned", verbose_name=_("Internal Participant"), help_text=_("Internal staff involved in the recruitment process for this job"), ) - participants=models.ManyToManyField('Participants', - blank=True,related_name="jobs_participating", + participants = models.ManyToManyField( + "Participants", + blank=True, + related_name="jobs_participating", verbose_name=_("External Participant"), - help_text=_("External participants involved in the recruitment process for this job"), + help_text=_( + "External participants involved in the recruitment process for this job" + ), ) # Core Fields @@ -82,17 +121,15 @@ class JobPosting(Base): # Job Details description = CKEditor5Field( - 'Description', - config_name='extends' # Matches the config name you defined in settings.py + "Description", + config_name="extends", # Matches the config name you defined in settings.py ) - qualifications = CKEditor5Field(blank=True, null=True, - config_name='extends' - ) + qualifications = CKEditor5Field(blank=True, null=True, config_name="extends") salary_range = models.CharField( max_length=200, blank=True, help_text="e.g., $60,000 - $80,000" ) - benefits = CKEditor5Field(blank=True, null=True, config_name='extends') + benefits = CKEditor5Field(blank=True, null=True, config_name="extends") # Application Information ---job detail apply link for the candidates application_url = models.URLField( @@ -104,7 +141,7 @@ class JobPosting(Base): application_deadline = models.DateField(db_index=True) # Added index application_instructions = CKEditor5Field( - blank=True, null=True, config_name='extends' + blank=True, null=True, config_name="extends" ) # Internal Tracking @@ -123,7 +160,10 @@ class JobPosting(Base): ("ARCHIVED", "Archived"), ] status = models.CharField( - db_index=True, max_length=20, choices=STATUS_CHOICES, default="DRAFT" # Added index + db_index=True, + max_length=20, + choices=STATUS_CHOICES, + default="DRAFT", # Added index ) # hashtags for social media @@ -146,9 +186,11 @@ class JobPosting(Base): max_length=50, blank=True, help_text="Status of LinkedIn posting" ) linkedin_posted_at = models.DateTimeField(null=True, blank=True) - linkedin_post_formated_data=models.TextField(null=True,blank=True) + linkedin_post_formated_data = models.TextField(null=True, blank=True) - published_at = models.DateTimeField(db_index=True, null=True, blank=True) # Added index + published_at = models.DateTimeField( + db_index=True, null=True, blank=True + ) # Added index # University Specific Fields position_number = models.CharField( max_length=50, blank=True, help_text="University position number" @@ -168,10 +210,13 @@ class JobPosting(Base): null=True, blank=True, help_text="The system or channel from which this job posting originated or was first published.", - db_index=True # Explicitly index ForeignKey + db_index=True, # Explicitly index ForeignKey ) max_applications = models.PositiveIntegerField( - default=1000, help_text="Maximum number of applications allowed", null=True, blank=True + default=1000, + help_text="Maximum number of applications allowed", + null=True, + blank=True, ) hiring_agency = models.ManyToManyField( "HiringAgency", @@ -200,8 +245,8 @@ class JobPosting(Base): verbose_name = "Job Posting" verbose_name_plural = "Job Postings" indexes = [ - models.Index(fields=['status', 'created_at', 'title']), - models.Index(fields=['slug']), + models.Index(fields=["status", "created_at", "title"]), + models.Index(fields=["slug"]), ] def __str__(self): @@ -220,9 +265,8 @@ class JobPosting(Base): year = timezone.now().year # Get next sequential number last_job = ( - JobPosting.objects.select_for_update().filter( - internal_job_id__startswith=f"{prefix}-{year}-" - ) + JobPosting.objects.select_for_update() + .filter(internal_job_id__startswith=f"{prefix}-{year}-") .order_by("internal_job_id") .last() ) @@ -269,7 +313,7 @@ class JobPosting(Base): return False # 1. Replace the common HTML non-breaking space entity with a standard space. - content = field_value.replace(' ', ' ') + content = field_value.replace(" ", " ") # 2. Remove all HTML tags (leaving only text and remaining spaces). stripped = strip_tags(content) @@ -315,8 +359,14 @@ class JobPosting(Base): @property def all_candidates(self): return self.candidates.annotate( - sortable_score=Coalesce(Cast('ai_analysis_data__analysis_data__match_score', output_field=IntegerField()), - 0)).order_by('-sortable_score') + sortable_score=Coalesce( + Cast( + "ai_analysis_data__analysis_data__match_score", + output_field=IntegerField(), + ), + 0, + ) + ).order_by("-sortable_score") @property def screening_candidates(self): @@ -333,9 +383,11 @@ class JobPosting(Base): @property def offer_candidates(self): return self.all_candidates.filter(stage="Offer") + @property def accepted_candidates(self): return self.all_candidates.filter(offer_status="Accepted") + @property def hired_candidates(self): return self.all_candidates.filter(stage="Hired") @@ -343,9 +395,16 @@ class JobPosting(Base): # counts @property def all_candidates_count(self): - return self.candidates.annotate( - sortable_score=Cast('ai_analysis_data__match_score', output_field=CharField())).order_by( - '-sortable_score').count() or 0 + return ( + self.candidates.annotate( + sortable_score=Cast( + "ai_analysis_data__match_score", output_field=CharField() + ) + ) + .order_by("-sortable_score") + .count() + or 0 + ) @property def screening_candidates_count(self): @@ -371,7 +430,7 @@ class JobPosting(Base): def vacancy_fill_rate(self): total_positions = self.open_positions - no_of_positions_filled = self.candidates.filter(stage__in=['HIRED']).count() + no_of_positions_filled = self.candidates.filter(stage__in=["HIRED"]).count() if total_positions > 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