diff --git a/NorahUniversity/__pycache__/settings.cpython-312.pyc b/NorahUniversity/__pycache__/settings.cpython-312.pyc index 160b1cf..42db791 100644 Binary files a/NorahUniversity/__pycache__/settings.cpython-312.pyc and b/NorahUniversity/__pycache__/settings.cpython-312.pyc differ diff --git a/NorahUniversity/__pycache__/urls.cpython-312.pyc b/NorahUniversity/__pycache__/urls.cpython-312.pyc index df79ce7..17d54d8 100644 Binary files a/NorahUniversity/__pycache__/urls.cpython-312.pyc and b/NorahUniversity/__pycache__/urls.cpython-312.pyc differ diff --git a/NorahUniversity/settings.py b/NorahUniversity/settings.py index 5c00084..d741e46 100644 --- a/NorahUniversity/settings.py +++ b/NorahUniversity/settings.py @@ -135,9 +135,9 @@ WSGI_APPLICATION = 'NorahUniversity.wsgi.application' DATABASES = { 'default': { 'ENGINE': 'django.db.backends.postgresql_psycopg2', - 'NAME': 'norahuniversity', - 'USER': 'norahuniversity', - 'PASSWORD': 'norahuniversity', + 'NAME': 'haikal_db', + 'USER': 'faheed', + 'PASSWORD': 'Faheed@215', 'HOST': '127.0.0.1', 'PORT': '5432', } diff --git a/NorahUniversity/urls.py b/NorahUniversity/urls.py index d4a3203..11de484 100644 --- a/NorahUniversity/urls.py +++ b/NorahUniversity/urls.py @@ -22,8 +22,8 @@ urlpatterns = [ # path('', include('recruitment.urls')), path("ckeditor5/", include('django_ckeditor_5.urls')), - path('/', views.form_wizard_view, name='form_wizard'), - path('/submit/', views.submit_form, name='submit_form'), + path('form_wizard//', views.form_wizard_view, name='form_wizard'), + path('form//submit/', views.submit_form, name='submit_form'), path('api/templates/', views.list_form_templates, name='list_form_templates'), path('api/templates/save/', views.save_form_template, name='save_form_template'), diff --git a/locale/ar/LC_MESSAGES/django.po b/locale/ar/LC_MESSAGES/django.po index e112fa0..f28f365 100644 --- a/locale/ar/LC_MESSAGES/django.po +++ b/locale/ar/LC_MESSAGES/django.po @@ -1426,178 +1426,3 @@ msgstr "" #: templates/unfold/components/table.html:43 msgid "No data" msgstr "" - -# Source Management -msgid "Data Sources" -msgstr "مصادر البيانات" - -msgid "Create New Source" -msgstr "إنشاء مصدر جديد" - -msgid "Search by name, type, or description..." -msgstr "البحث بالاسم، النوع، أو الوصف..." - -msgid "Filter" -msgstr "تصفية" - -msgid "Available Sources" -msgstr "المصادر المتاحة" - -msgid "sources" -msgstr "مصادر" - -msgid "View Details" -msgstr "عرض التفاصيل" - -msgid "Edit" -msgstr "تعديل" - -msgid "Delete" -msgstr "حذف" - -msgid "First" -msgstr "الأول" - -msgid "Previous" -msgstr "السابق" - -msgid "Next" -msgstr "التالي" - -msgid "Last" -msgstr "الأخير" - -msgid "No sources found" -msgstr "لم يتم العثور على مصادر" - -msgid "Get started by creating your first data source." -msgstr "ابدأ بإنشاء أول مصدر بيانات لك." - -msgid "Network Configuration" -msgstr "تكوين الشبكة" - -msgid "Settings" -msgstr "الإعدادات" - -msgid "API Configuration" -msgstr "تكوين واجهة برمجة التطبيقات" - -msgid "API Keys" -msgstr "مفاتيح واجهة برمجة التطبيقات" - -msgid "Generate secure API keys for external integrations" -msgstr "إنشاء مفاتيح واجهة برمجة التطبيقات الآمنة للتكاملات الخارجية" - -msgid "Active" -msgstr "نشط" - -msgid "Inactive" -msgstr "غير نشط" - -msgid "Not generated" -msgstr "لم يتم إنشاؤه" - -msgid "Created By" -msgstr "أنشأ بواسطة" - -msgid "Created At" -msgstr "أنشأ في" - -msgid "Updated At" -msgstr "حدث في" - -msgid "IP Address" -msgstr "عنوان IP" - -msgid "Trusted IPs" -msgstr "عناوين IP الموثوقة" - -msgid "Integration Version" -msgstr "إصدار التكامل" - -msgid "API Key" -msgstr "مفتاح واجهة برمجة التطبيقات" - -msgid "API Secret" -msgstr "سر واجهة برمجة التطبيقات" - -msgid "Recent Integration Logs" -msgstr "سجلات التكامل الأخيرة" - -msgid "Time" -msgstr "الوقت" - -msgid "Action" -msgstr "الإجراء" - -msgid "Endpoint" -msgstr "نقطة النهاية" - -msgid "Method" -msgstr "الطريقة" - -msgid "Status" -msgstr "الحالة" - -msgid "Success" -msgstr "نجح" - -msgid "Failed" -msgstr "فشل" - -msgid "No integration logs found" -msgstr "لم يتم العثور على سجلات تكامل" - -msgid "Integration logs will appear here when this source is used for external integrations." -msgstr "ستظهر سجلات التكامل هنا عند استخدام هذا المصدر للتكاملات الخارجية." - -msgid "Delete Source" -msgstr "حذف المصدر" - -msgid "Confirm Deletion" -msgstr "تأكيد الحذف" - -msgid "Are you sure you want to delete the following source? This action cannot be undone." -msgstr "هل أنت متأكد من رغبتك في حذف المصدر التالي؟ لا يمكن التراجع عن هذا الإجراء." - -msgid "Important Note" -msgstr "ملاحظة هامة" - -msgid "All associated API keys will be permanently deleted." -msgstr "ستتم حذف جميع مفاتيح واجهة برمجة التطبيقات المرتبطة بشكل دائم." - -msgid "Integration logs related to this source will remain but will show 'Source deleted'." -msgstr "ستبقى سجلات التكامل المتعلقة بهذا المصدر ولكنها ستظهر 'تم حذف المصدر'." - -msgid "Any active integrations using this source will be disconnected." -msgstr "سيتم فصل أي تكاملات نشطة تستخدم هذا المصدر." - -msgid "Back to List" -msgstr "العودة إلى القائمة" - -msgid "Delete Source" -msgstr "حذف المصدر" - -msgid "Generate API Keys" -msgstr "إنشاء مفاتيح واجهة برمجة التطبيقات" - -msgid "Copy to Clipboard" -msgstr "نسخ إلى الحافظة" - -msgid "Failed to copy to clipboard" -msgstr "فشل نسخ إلى الحافظة" - -msgid "Generate random API key" -msgstr "إنشاء مفتاح واجهة برمجة تطبيقات عشوائي" - -msgid "Generate random API secret" -msgstr "إنشاء سر واجهة برمجة تطبيقات عشوائي" - -msgid "Source updated successfully." -msgstr "تم تحديث المصدر بنجاح." - -msgid "Source created successfully." -msgstr "تم إنشاء المصدر بنجاح." - -msgid "Source deleted successfully." -msgstr "تم حذف المصدر بنجاح." diff --git a/locale/en/LC_MESSAGES/django.po b/locale/en/LC_MESSAGES/django.po index 25de3e8..ecedf37 100644 --- a/locale/en/LC_MESSAGES/django.po +++ b/locale/en/LC_MESSAGES/django.po @@ -1425,178 +1425,3 @@ msgstr "" #: templates/unfold/components/table.html:43 msgid "No data" msgstr "" - -# Source Management -msgid "Data Sources" -msgstr "" - -msgid "Create New Source" -msgstr "" - -msgid "Search by name, type, or description..." -msgstr "" - -msgid "Filter" -msgstr "" - -msgid "Available Sources" -msgstr "" - -msgid "sources" -msgstr "" - -msgid "View Details" -msgstr "" - -msgid "Edit" -msgstr "" - -msgid "Delete" -msgstr "" - -msgid "First" -msgstr "" - -msgid "Previous" -msgstr "" - -msgid "Next" -msgstr "" - -msgid "Last" -msgstr "" - -msgid "No sources found" -msgstr "" - -msgid "Get started by creating your first data source." -msgstr "" - -msgid "Network Configuration" -msgstr "" - -msgid "Settings" -msgstr "" - -msgid "API Configuration" -msgstr "" - -msgid "API Keys" -msgstr "" - -msgid "Generate secure API keys for external integrations" -msgstr "" - -msgid "Active" -msgstr "" - -msgid "Inactive" -msgstr "" - -msgid "Not generated" -msgstr "" - -msgid "Created By" -msgstr "" - -msgid "Created At" -msgstr "" - -msgid "Updated At" -msgstr "" - -msgid "IP Address" -msgstr "" - -msgid "Trusted IPs" -msgstr "" - -msgid "Integration Version" -msgstr "" - -msgid "API Key" -msgstr "" - -msgid "API Secret" -msgstr "" - -msgid "Recent Integration Logs" -msgstr "" - -msgid "Time" -msgstr "" - -msgid "Action" -msgstr "" - -msgid "Endpoint" -msgstr "" - -msgid "Method" -msgstr "" - -msgid "Status" -msgstr "" - -msgid "Success" -msgstr "" - -msgid "Failed" -msgstr "" - -msgid "No integration logs found" -msgstr "" - -msgid "Integration logs will appear here when this source is used for external integrations." -msgstr "" - -msgid "Delete Source" -msgstr "" - -msgid "Confirm Deletion" -msgstr "" - -msgid "Are you sure you want to delete the following source? This action cannot be undone." -msgstr "" - -msgid "Important Note" -msgstr "" - -msgid "All associated API keys will be permanently deleted." -msgstr "" - -msgid "Integration logs related to this source will remain but will show 'Source deleted'." -msgstr "" - -msgid "Any active integrations using this source will be disconnected." -msgstr "" - -msgid "Back to List" -msgstr "" - -msgid "Delete Source" -msgstr "" - -msgid "Generate API Keys" -msgstr "" - -msgid "Copy to Clipboard" -msgstr "" - -msgid "Failed to copy to clipboard" -msgstr "" - -msgid "Generate random API key" -msgstr "" - -msgid "Generate random API secret" -msgstr "" - -msgid "Source updated successfully." -msgstr "" - -msgid "Source created successfully." -msgstr "" - -msgid "Source deleted successfully." -msgstr "" diff --git a/recruitment/__pycache__/admin.cpython-312.pyc b/recruitment/__pycache__/admin.cpython-312.pyc index 0b0a860..cd0678a 100644 Binary files a/recruitment/__pycache__/admin.cpython-312.pyc and b/recruitment/__pycache__/admin.cpython-312.pyc differ diff --git a/recruitment/__pycache__/forms.cpython-312.pyc b/recruitment/__pycache__/forms.cpython-312.pyc index 9725eb3..7d31dce 100644 Binary files a/recruitment/__pycache__/forms.cpython-312.pyc and b/recruitment/__pycache__/forms.cpython-312.pyc differ diff --git a/recruitment/__pycache__/forms.cpython-313.pyc b/recruitment/__pycache__/forms.cpython-313.pyc index a59ff1e..4d4c47a 100644 Binary files a/recruitment/__pycache__/forms.cpython-313.pyc and b/recruitment/__pycache__/forms.cpython-313.pyc differ diff --git a/recruitment/__pycache__/linkedin_service.cpython-312.pyc b/recruitment/__pycache__/linkedin_service.cpython-312.pyc index c18ea68..0559ee7 100644 Binary files a/recruitment/__pycache__/linkedin_service.cpython-312.pyc and b/recruitment/__pycache__/linkedin_service.cpython-312.pyc differ diff --git a/recruitment/__pycache__/models.cpython-312.pyc b/recruitment/__pycache__/models.cpython-312.pyc index 53b4e48..f54b6b9 100644 Binary files a/recruitment/__pycache__/models.cpython-312.pyc and b/recruitment/__pycache__/models.cpython-312.pyc differ diff --git a/recruitment/__pycache__/signals.cpython-312.pyc b/recruitment/__pycache__/signals.cpython-312.pyc index e9aa013..9bc8496 100644 Binary files a/recruitment/__pycache__/signals.cpython-312.pyc and b/recruitment/__pycache__/signals.cpython-312.pyc differ diff --git a/recruitment/__pycache__/urls.cpython-312.pyc b/recruitment/__pycache__/urls.cpython-312.pyc index 0baf2e8..47958fa 100644 Binary files a/recruitment/__pycache__/urls.cpython-312.pyc and b/recruitment/__pycache__/urls.cpython-312.pyc differ diff --git a/recruitment/__pycache__/urls.cpython-313.pyc b/recruitment/__pycache__/urls.cpython-313.pyc index 097b279..0f0a705 100644 Binary files a/recruitment/__pycache__/urls.cpython-313.pyc and b/recruitment/__pycache__/urls.cpython-313.pyc differ diff --git a/recruitment/__pycache__/views.cpython-312.pyc b/recruitment/__pycache__/views.cpython-312.pyc index 4f4ce2a..16fa8e9 100644 Binary files a/recruitment/__pycache__/views.cpython-312.pyc and b/recruitment/__pycache__/views.cpython-312.pyc differ diff --git a/recruitment/__pycache__/views_frontend.cpython-312.pyc b/recruitment/__pycache__/views_frontend.cpython-312.pyc index 188826c..8806d1f 100644 Binary files a/recruitment/__pycache__/views_frontend.cpython-312.pyc and b/recruitment/__pycache__/views_frontend.cpython-312.pyc differ diff --git a/recruitment/forms.py b/recruitment/forms.py index 93d73a1..73fe94d 100644 --- a/recruitment/forms.py +++ b/recruitment/forms.py @@ -1,171 +1,20 @@ from django import forms +from .validators import validate_hash_tags from django.core.validators import URLValidator 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.forms import UserCreationForm -import re from .models import ( ZoomMeeting, Candidate,TrainingMaterial,JobPosting, FormTemplate,InterviewSchedule,BreakTime,JobPostingImage, - Profile,MeetingComment,ScheduledInterview,Source + Profile,MeetingComment,ScheduledInterview ) # from django_summernote.widgets import SummernoteWidget from django_ckeditor_5.widgets import CKEditor5Widget -import secrets -import string -from django.core.exceptions import ValidationError -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)) -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)) - -class SourceForm(forms.ModelForm): - """Form for creating and editing sources with API key generation""" - - # Hidden field to trigger API key generation - generate_keys = forms.CharField( - widget=forms.HiddenInput(), - required=False, - 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'}) - ) - - api_secret_generated = forms.CharField( - label="Generated API Secret", - required=False, - 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' - ] - 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' - }), - } - - 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' - - # 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'), - - # Hidden field for key generation trigger - 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') - ) - - def clean_name(self): - """Ensure source name is unique""" - 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.') - else: # Editing existing instance - if Source.objects.filter(name=name).exclude(pk=instance.pk).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') - if trusted_ips: - # Split by comma and strip whitespace - 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}') - except Exception: - raise ValidationError(f'Invalid IP address: {ip}') - - return ', '.join(ips) - return trusted_ips - - def clean(self): - """Custom validation for the form""" - cleaned_data = super().clean() - - # Check if we need to generate API keys - generate_keys = cleaned_data.get('generate_keys') - - if generate_keys == 'true': - # Generate new API key and 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'] - - return cleaned_data class CandidateForm(forms.ModelForm): class Meta: @@ -223,6 +72,51 @@ class CandidateStageForm(forms.ModelForm): 'stage': forms.Select(attrs={'class': 'form-select'}), } + # def __init__(self, *args, **kwargs): + # # Get the current candidate instance for validation + # self.candidate = kwargs.pop('candidate', None) + # super().__init__(*args, **kwargs) + + # # Dynamically filter stage choices based on current stage + # if self.candidate and self.candidate.pk: + # current_stage = self.candidate.stage + # available_stages = self.candidate.get_available_stages() + + # # Filter choices to only include available stages + # choices = [(stage, self.candidate.Stage(stage).label) + # for stage in available_stages] + # self.fields['stage'].choices = choices + + # # Set initial value to current stage + # self.fields['stage'].initial = current_stage + # else: + # # For new candidates, only show 'Applied' stage + # self.fields['stage'].choices = [('Applied', _('Applied'))] + # self.fields['stage'].initial = 'Applied' + + # def clean_stage(self): + # """Validate stage transition""" + # new_stage = self.cleaned_data.get('stage') + # if not new_stage: + # raise forms.ValidationError(_('Please select a stage.')) + + # # Use model validation for stage transitions + # if self.candidate and self.candidate.pk: + # current_stage = self.candidate.stage + # if new_stage != current_stage: + # if not self.candidate.can_transition_to(new_stage): + # allowed_stages = self.candidate.get_available_stages() + # raise forms.ValidationError( + # _('Cannot transition from "%(current)s" to "%(new)s". ' + # 'Allowed transitions: %(allowed)s') % { + # 'current': current_stage, + # 'new': new_stage, + # 'allowed': ', '.join(allowed_stages) or 'None (final stage)' + # } + # ) + + # return new_stage + class ZoomMeetingForm(forms.ModelForm): class Meta: model = ZoomMeeting @@ -252,6 +146,8 @@ class ZoomMeetingForm(forms.ModelForm): Submit('submit', _('Create Meeting'), css_class='btn btn-primary') ) +# Old JobForm removed - replaced by JobPostingForm + class TrainingMaterialForm(forms.ModelForm): class Meta: model = TrainingMaterial @@ -264,11 +160,13 @@ class TrainingMaterialForm(forms.ModelForm): } widgets = { 'title': forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Enter material title')}), - 'content': CKEditor5Widget(attrs={'placeholder': _('Enter material content')}), + # 💡 Use SummernoteWidget here + # 'content': SummernoteWidget(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'}), } + # The __init__ and FormHelper layout remains the same def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.helper = FormHelper() @@ -277,7 +175,7 @@ class TrainingMaterialForm(forms.ModelForm): self.helper.layout = Layout( 'title', - 'content', + 'content', # Summernote is applied via the widgets dictionary Row( Column('video_link', css_class='col-md-6'), Column('file', css_class='col-md-6'), @@ -290,6 +188,7 @@ class TrainingMaterialForm(forms.ModelForm): ) ) + class JobPostingForm(forms.ModelForm): """Form for creating and editing job postings""" @@ -298,12 +197,13 @@ class JobPostingForm(forms.ModelForm): fields = [ 'title', 'department', 'job_type', 'workplace_type', 'location_city', 'location_state', 'location_country', - 'description', 'qualifications', 'salary_range', 'benefits','application_start_date' - ,'application_deadline', 'application_instructions', - 'position_number', 'reporting_to', 'joining_date', - 'created_by','open_positions','hash_tags','max_applications' + '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': 'Assistant Professor of Computer Science', @@ -321,6 +221,8 @@ class JobPostingForm(forms.ModelForm): 'class': 'form-select', 'required': True }), + + # Location 'location_city': forms.TextInput(attrs={ 'class': 'form-control', 'placeholder': 'Boston' @@ -333,18 +235,39 @@ class JobPostingForm(forms.ModelForm): '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 Information + # 'application_url': forms.URLInput(attrs={ + # 'class': 'form-control', + # 'placeholder': 'https://university.edu/careers/job123', + # 'required': True + # }), 'application_start_date': forms.DateInput(attrs={ 'class': 'form-control', 'type': 'date' }), 'application_deadline': forms.DateInput(attrs={ 'class': 'form-control', - 'type': 'date' + 'type': 'date', + 'required':True }), + 'open_positions': forms.NumberInput(attrs={ 'class': 'form-control', 'min': 1, @@ -353,7 +276,10 @@ class JobPostingForm(forms.ModelForm): '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' @@ -362,14 +288,8 @@ class JobPostingForm(forms.ModelForm): 'class': 'form-control', 'placeholder': 'Department Chair, Director, etc.' }), - 'joining_date': forms.DateInput(attrs={ - 'class': 'form-control', - 'type': 'date' - }), - 'created_by': forms.TextInput(attrs={ - 'class': 'form-control', - 'placeholder': 'University Administrator' - }), + + 'max_applications': forms.NumberInput(attrs={ 'class': 'form-control', 'min': 1, @@ -378,15 +298,16 @@ class JobPostingForm(forms.ModelForm): } def __init__(self,*args,**kwargs): - self.is_anonymous_user = kwargs.pop('is_anonymous_user', False) + + # Now call the parent __init__ with remaining args super().__init__(*args, **kwargs) - if not self.instance.pk: - if not self.is_anonymous_user: - self.fields['created_by'].initial = 'University Administrator' + 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' + def clean_hash_tags(self): hash_tags=self.cleaned_data.get('hash_tags') @@ -396,7 +317,7 @@ class JobPostingForm(forms.ModelForm): if not tag.startswith('#'): raise forms.ValidationError("Each hashtag must start with '#' symbol and must be comma(,) sepearted.") return ','.join(tags) - return hash_tags + return hash_tags # Allow blank def clean_title(self): title=self.cleaned_data.get('title') @@ -410,7 +331,43 @@ class JobPostingForm(forms.ModelForm): 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.") - return description.strip() + return description.strip() # to remove leading/trailing whitespace + + def clean_application_url(self): + 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)') + return url + + # def clean(self): + # """Cross-field validation""" + # cleaned_data = super().clean() + + # # Validate dates + # start_date = cleaned_data.get('start_date') + # application_deadline = cleaned_data.get('application_deadline') + + # # Perform cross-field validation only if both fields have values + # if start_date and application_deadline: + # if application_deadline > start_date: + # self.add_error('application_deadline', + # 'The application deadline must be set BEFORE the job start date.') + + # # # Validate that if status is ACTIVE, we have required fields + # # status = cleaned_data.get('status') + # # if status == 'ACTIVE': + # # if not cleaned_data.get('application_url'): + # # self.add_error('application_url', + # # 'Application URL is required for active jobs.') + # # if not cleaned_data.get('description'): + # # self.add_error('description', + # # 'Job description is required for active jobs.') + + # return cleaned_data class JobPostingImageForm(forms.ModelForm): class Meta: @@ -458,6 +415,90 @@ class FormTemplateForm(forms.ModelForm): Field('is_active', css_class='form-check-input'), Submit('submit', _('Create Template'), css_class='btn btn-primary mt-3') ) +# class BreakTimeForm(forms.ModelForm): +# class Meta: +# model = BreakTime +# fields = ['start_time', 'end_time'] +# widgets = { +# 'start_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}), +# 'end_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}), +# } + +# 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 +# ) +# working_days = forms.MultipleChoiceField( +# choices=[ +# (0, 'Monday'), +# (1, 'Tuesday'), +# (2, 'Wednesday'), +# (3, 'Thursday'), +# (4, 'Friday'), +# (5, 'Saturday'), +# (6, 'Sunday'), +# ], +# widget=forms.CheckboxSelectMultiple, +# required=True +# ) + +# class Meta: +# model = InterviewSchedule +# fields = [ +# 'candidates', 'start_date', 'end_date', 'working_days', +# 'start_time', 'end_time', 'interview_duration', 'buffer_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'}), +# } + +# def __init__(self, slug, *args, **kwargs): +# super().__init__(*args, **kwargs) +# # Filter candidates based on the selected job +# 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') +# # Convert string values to integers +# return [int(day) for day in working_days] + + +class JobPostingCancelReasonForm(forms.ModelForm): + class Meta: + model = JobPosting + fields = ['cancel_reason'] +class JobPostingStatusForm(forms.ModelForm): + class Meta: + model = JobPosting + fields = ['status'] + widgets = { + 'status': forms.Select(attrs={'class': 'form-select'}), + } +class FormTemplateIsActiveForm(forms.ModelForm): + class Meta: + model = FormTemplate + fields = ['is_active'] + +class CandidateExamDateForm(forms.ModelForm): + class Meta: + model = Candidate + fields = ['exam_date'] + widgets = { + 'exam_date': forms.DateTimeInput(attrs={'type': 'datetime-local', 'class': 'form-control'}), + } + class BreakTimeForm(forms.Form): """ @@ -474,8 +515,10 @@ class BreakTimeForm(forms.Form): label="End Time" ) +# Use the non-model form for the formset factory BreakTimeFormSet = formset_factory(BreakTimeForm, extra=1, can_delete=True) +# --- InterviewScheduleForm remains unchanged --- class InterviewScheduleForm(forms.ModelForm): candidates = forms.ModelMultipleChoiceField( queryset=Candidate.objects.none(), @@ -511,6 +554,7 @@ class InterviewScheduleForm(forms.ModelForm): def __init__(self, slug, *args, **kwargs): super().__init__(*args, **kwargs) + # Filter candidates based on the selected job self.fields['candidates'].queryset = Candidate.objects.filter( job__slug=slug, stage='Interview' @@ -518,6 +562,7 @@ class InterviewScheduleForm(forms.ModelForm): def clean_working_days(self): working_days = self.cleaned_data.get('working_days') + # Convert string values to integers return [int(day) for day in working_days] class MeetingCommentForm(forms.ModelForm): @@ -547,7 +592,26 @@ class MeetingCommentForm(forms.ModelForm): Submit('submit', _('Add Comment'), css_class='btn btn-primary mt-3') ) +# --- ScheduleInterviewForCandiateForm remains unchanged --- +class ScheduleInterviewForCandiateForm(forms.ModelForm): + + class Meta: + model = InterviewSchedule + fields = ['start_date', 'end_date', 'start_time', 'end_time', 'interview_duration', 'buffer_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'}), + } + + class InterviewForm(forms.ModelForm): + class Meta: model = ScheduledInterview fields = ['job','candidate'] @@ -557,6 +621,36 @@ class ProfileImageUploadForm(forms.ModelForm): model=Profile fields=['profile_image'] + + +# class UserEditForms(forms.ModelForm): +# class Meta: +# model = User +# fields = ['first_name', 'last_name'] + + +from django.contrib.auth.forms import UserCreationForm +# class StaffUserCreationForm(UserCreationForm): +# email = forms.EmailField(required=True) +# first_name = forms.CharField(max_length=30) +# last_name = forms.CharField(max_length=150) + +# class Meta: +# model = User +# fields = ("email", "first_name", "last_name", "password1", "password2") + +# def save(self, commit=True): +# user = super().save(commit=False) +# user.email = self.cleaned_data["email"] +# user.first_name = self.cleaned_data["first_name"] +# user.last_name = self.cleaned_data["last_name"] +# user.username = self.cleaned_data["email"] # or generate +# user.is_staff = True +# if commit: +# user.save() + # return user + +import re class StaffUserCreationForm(UserCreationForm): email = forms.EmailField(required=True) first_name = forms.CharField(max_length=30, required=True) @@ -590,37 +684,15 @@ class StaffUserCreationForm(UserCreationForm): user.email = self.cleaned_data["email"] user.first_name = self.cleaned_data["first_name"] user.last_name = self.cleaned_data["last_name"] - user.username = self.generate_username(user.email) + user.username = self.generate_username(user.email) # never use raw email if it has dots, etc. user.is_staff = True if commit: user.save() return user + + class ToggleAccountForm(forms.Form): pass -class JobPostingCancelReasonForm(forms.ModelForm): - class Meta: - model = JobPosting - fields = ['cancel_reason'] -class JobPostingStatusForm(forms.ModelForm): - class Meta: - model = JobPosting - fields = ['status'] - widgets = { - 'status': forms.Select(attrs={'class': 'form-select'}), - } - -class FormTemplateIsActiveForm(forms.ModelForm): - class Meta: - model = FormTemplate - fields = ['is_active'] - -class CandidateExamDateForm(forms.ModelForm): - class Meta: - model = Candidate - fields = ['exam_date'] - widgets = { - 'exam_date': forms.DateTimeInput(attrs={'type': 'datetime-local', 'class': 'form-control'}), - } diff --git a/recruitment/migrations/0001_initial.py b/recruitment/migrations/0001_initial.py new file mode 100644 index 0000000..bf6f650 --- /dev/null +++ b/recruitment/migrations/0001_initial.py @@ -0,0 +1,477 @@ +# Generated by Django 5.2.7 on 2025-10-21 22:26 + +import django.core.validators +import django.db.models.deletion +import django_ckeditor_5.fields +import django_countries.fields +import django_extensions.db.fields +import recruitment.validators +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='BreakTime', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('start_time', models.TimeField(verbose_name='Start Time')), + ('end_time', models.TimeField(verbose_name='End Time')), + ], + ), + migrations.CreateModel( + name='FormStage', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), + ('name', models.CharField(help_text='Name of the stage', max_length=200)), + ('order', models.PositiveIntegerField(default=0, help_text='Order of the stage in the form')), + ('is_predefined', models.BooleanField(default=False, help_text='Whether this is a default resume stage')), + ], + options={ + 'verbose_name': 'Form Stage', + 'verbose_name_plural': 'Form Stages', + 'ordering': ['order'], + }, + ), + migrations.CreateModel( + name='HiringAgency', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), + ('name', models.CharField(max_length=200, unique=True, verbose_name='Agency Name')), + ('contact_person', models.CharField(blank=True, max_length=150, verbose_name='Contact Person')), + ('email', models.EmailField(blank=True, max_length=254)), + ('phone', models.CharField(blank=True, max_length=20)), + ('website', models.URLField(blank=True)), + ('notes', models.TextField(blank=True, help_text='Internal notes about the agency')), + ('country', django_countries.fields.CountryField(blank=True, max_length=2, null=True)), + ('address', models.TextField(blank=True, null=True)), + ], + options={ + 'verbose_name': 'Hiring Agency', + 'verbose_name_plural': 'Hiring Agencies', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='Source', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), + ('name', models.CharField(help_text='e.g., ATS, ERP ', max_length=100, unique=True, verbose_name='Source Name')), + ('source_type', models.CharField(help_text='e.g., ATS, ERP ', max_length=100, verbose_name='Source Type')), + ('description', models.TextField(blank=True, help_text='A description of the source', verbose_name='Description')), + ('ip_address', models.GenericIPAddressField(blank=True, help_text='The IP address of the source', null=True, verbose_name='IP Address')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('api_key', models.CharField(blank=True, help_text='API key for authentication (will be encrypted)', max_length=255, null=True, verbose_name='API Key')), + ('api_secret', models.CharField(blank=True, help_text='API secret for authentication (will be encrypted)', max_length=255, null=True, verbose_name='API Secret')), + ('trusted_ips', models.TextField(blank=True, help_text='Comma-separated list of trusted IP addresses', null=True, verbose_name='Trusted IP Addresses')), + ('is_active', models.BooleanField(default=True, help_text='Whether this source is active for integration', verbose_name='Active')), + ('integration_version', models.CharField(blank=True, help_text='Version of the integration protocol', max_length=50, verbose_name='Integration Version')), + ('last_sync_at', models.DateTimeField(blank=True, help_text='Timestamp of the last successful synchronization', null=True, verbose_name='Last Sync At')), + ('sync_status', models.CharField(blank=True, choices=[('IDLE', 'Idle'), ('SYNCING', 'Syncing'), ('ERROR', 'Error'), ('DISABLED', 'Disabled')], default='IDLE', max_length=20, verbose_name='Sync Status')), + ], + options={ + 'verbose_name': 'Source', + 'verbose_name_plural': 'Sources', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='ZoomMeeting', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), + ('topic', models.CharField(max_length=255, verbose_name='Topic')), + ('meeting_id', models.CharField(db_index=True, max_length=20, unique=True, verbose_name='Meeting ID')), + ('start_time', models.DateTimeField(db_index=True, verbose_name='Start Time')), + ('duration', models.PositiveIntegerField(verbose_name='Duration')), + ('timezone', models.CharField(max_length=50, verbose_name='Timezone')), + ('join_url', models.URLField(verbose_name='Join URL')), + ('participant_video', models.BooleanField(default=True, verbose_name='Participant Video')), + ('password', models.CharField(blank=True, max_length=20, null=True, verbose_name='Password')), + ('join_before_host', models.BooleanField(default=False, verbose_name='Join Before Host')), + ('mute_upon_entry', models.BooleanField(default=False, verbose_name='Mute Upon Entry')), + ('waiting_room', models.BooleanField(default=False, verbose_name='Waiting Room')), + ('zoom_gateway_response', models.JSONField(blank=True, null=True, verbose_name='Zoom Gateway Response')), + ('status', models.CharField(blank=True, db_index=True, default='waiting', max_length=20, null=True, verbose_name='Status')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='FormField', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), + ('label', models.CharField(help_text='Label for the field', max_length=200)), + ('field_type', models.CharField(choices=[('text', 'Text Input'), ('email', 'Email'), ('phone', 'Phone'), ('textarea', 'Text Area'), ('file', 'File Upload'), ('date', 'Date Picker'), ('select', 'Dropdown'), ('radio', 'Radio Buttons'), ('checkbox', 'Checkboxes')], help_text='Type of the field', max_length=20)), + ('placeholder', models.CharField(blank=True, help_text='Placeholder text', max_length=200)), + ('required', models.BooleanField(default=False, help_text='Whether the field is required')), + ('order', models.PositiveIntegerField(default=0, help_text='Order of the field in the stage')), + ('is_predefined', models.BooleanField(default=False, help_text='Whether this is a default field')), + ('options', models.JSONField(blank=True, default=list, help_text='Options for selection fields (stored as JSON array)')), + ('file_types', models.CharField(blank=True, help_text="Allowed file types (comma-separated, e.g., '.pdf,.doc,.docx')", max_length=200)), + ('max_file_size', models.PositiveIntegerField(default=5, help_text='Maximum file size in MB (default: 5MB)')), + ('multiple_files', models.BooleanField(default=False, help_text='Allow multiple files to be uploaded')), + ('max_files', models.PositiveIntegerField(default=1, help_text='Maximum number of files allowed (when multiple_files is True)')), + ('stage', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fields', to='recruitment.formstage')), + ], + options={ + 'verbose_name': 'Form Field', + 'verbose_name_plural': 'Form Fields', + 'ordering': ['order'], + }, + ), + migrations.CreateModel( + name='FormTemplate', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), + ('name', models.CharField(help_text='Name of the form template', max_length=200)), + ('description', models.TextField(blank=True, help_text='Description of the form template')), + ('is_active', models.BooleanField(default=False, help_text='Whether this template is active')), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='form_templates', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Form Template', + 'verbose_name_plural': 'Form Templates', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='FormSubmission', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), + ('submitted_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('applicant_name', models.CharField(blank=True, help_text='Name of the applicant', max_length=200)), + ('applicant_email', models.EmailField(blank=True, db_index=True, help_text='Email of the applicant', max_length=254)), + ('submitted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='form_submissions', to=settings.AUTH_USER_MODEL)), + ('template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='submissions', to='recruitment.formtemplate')), + ], + options={ + 'verbose_name': 'Form Submission', + 'verbose_name_plural': 'Form Submissions', + 'ordering': ['-submitted_at'], + }, + ), + migrations.AddField( + model_name='formstage', + name='template', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stages', to='recruitment.formtemplate'), + ), + migrations.CreateModel( + name='Candidate', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), + ('first_name', models.CharField(max_length=255, verbose_name='First Name')), + ('last_name', models.CharField(max_length=255, verbose_name='Last Name')), + ('email', models.EmailField(db_index=True, max_length=254, verbose_name='Email')), + ('phone', models.CharField(max_length=20, verbose_name='Phone')), + ('address', models.TextField(max_length=200, verbose_name='Address')), + ('resume', models.FileField(upload_to='resumes/', verbose_name='Resume')), + ('is_resume_parsed', models.BooleanField(default=False, verbose_name='Resume Parsed')), + ('is_potential_candidate', models.BooleanField(default=False, verbose_name='Potential Candidate')), + ('parsed_summary', models.TextField(blank=True, verbose_name='Parsed Summary')), + ('applied', models.BooleanField(default=False, verbose_name='Applied')), + ('stage', models.CharField(choices=[('Applied', 'Applied'), ('Exam', 'Exam'), ('Interview', 'Interview'), ('Offer', 'Offer')], db_index=True, default='Applied', max_length=100, verbose_name='Stage')), + ('applicant_status', models.CharField(blank=True, choices=[('Applicant', 'Applicant'), ('Candidate', 'Candidate')], default='Applicant', max_length=100, null=True, verbose_name='Applicant Status')), + ('exam_date', models.DateTimeField(blank=True, null=True, verbose_name='Exam Date')), + ('exam_status', models.CharField(blank=True, choices=[('Passed', 'Passed'), ('Failed', 'Failed')], max_length=100, null=True, verbose_name='Exam Status')), + ('interview_date', models.DateTimeField(blank=True, null=True, verbose_name='Interview Date')), + ('interview_status', models.CharField(blank=True, choices=[('Passed', 'Passed'), ('Failed', 'Failed')], max_length=100, null=True, verbose_name='Interview Status')), + ('offer_date', models.DateField(blank=True, null=True, verbose_name='Offer Date')), + ('offer_status', models.CharField(blank=True, choices=[('Accepted', 'Accepted'), ('Rejected', 'Rejected')], max_length=100, null=True, verbose_name='Offer Status')), + ('join_date', models.DateField(blank=True, null=True, verbose_name='Join Date')), + ('ai_analysis_data', models.JSONField(default=dict, help_text='Full JSON output from the resume scoring model.', verbose_name='AI Analysis Data')), + ('submitted_by_agency', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='submitted_candidates', to='recruitment.hiringagency', verbose_name='Submitted by Agency')), + ], + options={ + 'verbose_name': 'Candidate', + 'verbose_name_plural': 'Candidates', + }, + ), + migrations.CreateModel( + name='JobPosting', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), + ('title', models.CharField(max_length=200)), + ('department', models.CharField(blank=True, max_length=100)), + ('job_type', models.CharField(choices=[('FULL_TIME', 'Full-time'), ('PART_TIME', 'Part-time'), ('CONTRACT', 'Contract'), ('INTERNSHIP', 'Internship'), ('FACULTY', 'Faculty'), ('TEMPORARY', 'Temporary')], default='FULL_TIME', max_length=20)), + ('workplace_type', models.CharField(choices=[('ON_SITE', 'On-site'), ('REMOTE', 'Remote'), ('HYBRID', 'Hybrid')], default='ON_SITE', max_length=20)), + ('location_city', models.CharField(blank=True, max_length=100)), + ('location_state', models.CharField(blank=True, max_length=100)), + ('location_country', models.CharField(default='Saudia Arabia', max_length=100)), + ('description', django_ckeditor_5.fields.CKEditor5Field(verbose_name='Description')), + ('qualifications', django_ckeditor_5.fields.CKEditor5Field(blank=True, null=True)), + ('salary_range', models.CharField(blank=True, help_text='e.g., $60,000 - $80,000', max_length=200)), + ('benefits', django_ckeditor_5.fields.CKEditor5Field(blank=True, null=True)), + ('application_url', models.URLField(blank=True, help_text='URL where candidates apply', null=True, validators=[django.core.validators.URLValidator()])), + ('application_start_date', models.DateField()), + ('application_deadline', models.DateField(db_index=True)), + ('application_instructions', django_ckeditor_5.fields.CKEditor5Field(blank=True, null=True)), + ('internal_job_id', models.CharField(editable=False, max_length=50, primary_key=True, serialize=False)), + ('created_by', models.CharField(blank=True, help_text='Name of person who created this job', max_length=100)), + ('status', models.CharField(choices=[('DRAFT', 'Draft'), ('ACTIVE', 'Active'), ('CLOSED', 'Closed'), ('CANCELLED', 'Cancelled'), ('ARCHIVED', 'Archived')], db_index=True, default='DRAFT', max_length=20)), + ('hash_tags', models.CharField(blank=True, help_text='Comma-separated hashtags for linkedin post like #hiring,#jobopening', max_length=200, validators=[recruitment.validators.validate_hash_tags])), + ('linkedin_post_id', models.CharField(blank=True, help_text='LinkedIn post ID after posting', max_length=200)), + ('linkedin_post_url', models.URLField(blank=True, help_text='Direct URL to LinkedIn post')), + ('posted_to_linkedin', models.BooleanField(default=False)), + ('linkedin_post_status', models.CharField(blank=True, help_text='Status of LinkedIn posting', max_length=50)), + ('linkedin_posted_at', models.DateTimeField(blank=True, null=True)), + ('published_at', models.DateTimeField(blank=True, db_index=True, null=True)), + ('position_number', models.CharField(blank=True, help_text='University position number', max_length=50)), + ('reporting_to', models.CharField(blank=True, help_text='Who this position reports to', max_length=100)), + ('open_positions', models.PositiveIntegerField(default=1, help_text='Number of open positions for this job')), + ('max_applications', models.PositiveIntegerField(blank=True, default=1000, help_text='Maximum number of applications allowed', null=True)), + ('cancel_reason', models.TextField(blank=True, help_text='Reason for canceling the job posting', verbose_name='Cancel Reason')), + ('cancelled_by', models.CharField(blank=True, help_text='Name of person who cancelled this job', max_length=100, verbose_name='Cancelled By')), + ('cancelled_at', models.DateTimeField(blank=True, null=True)), + ('hiring_agency', models.ManyToManyField(blank=True, help_text='External agency responsible for sourcing candidates for this role', related_name='jobs', to='recruitment.hiringagency', verbose_name='Hiring Agency')), + ('source', models.ForeignKey(blank=True, help_text='The system or channel from which this job posting originated or was first published.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='job_postings', to='recruitment.source')), + ], + options={ + 'verbose_name': 'Job Posting', + 'verbose_name_plural': 'Job Postings', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='InterviewSchedule', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), + ('start_date', models.DateField(db_index=True, verbose_name='Start Date')), + ('end_date', models.DateField(db_index=True, verbose_name='End Date')), + ('working_days', models.JSONField(verbose_name='Working Days')), + ('start_time', models.TimeField(verbose_name='Start Time')), + ('end_time', models.TimeField(verbose_name='End Time')), + ('break_start_time', models.TimeField(blank=True, null=True, verbose_name='Break Start Time')), + ('break_end_time', models.TimeField(blank=True, null=True, verbose_name='Break End Time')), + ('interview_duration', models.PositiveIntegerField(verbose_name='Interview Duration (minutes)')), + ('buffer_time', models.PositiveIntegerField(default=0, verbose_name='Buffer Time (minutes)')), + ('candidates', models.ManyToManyField(blank=True, null=True, related_name='interview_schedules', to='recruitment.candidate')), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interview_schedules', to='recruitment.jobposting')), + ], + ), + migrations.AddField( + model_name='formtemplate', + name='job', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='form_template', to='recruitment.jobposting'), + ), + migrations.AddField( + model_name='candidate', + name='job', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='candidates', to='recruitment.jobposting', verbose_name='Job'), + ), + migrations.CreateModel( + name='JobPostingImage', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('post_image', models.ImageField(upload_to='post/', validators=[recruitment.validators.validate_image_size])), + ('job', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='post_images', to='recruitment.jobposting')), + ], + ), + migrations.CreateModel( + name='Profile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('profile_image', models.ImageField(blank=True, null=True, upload_to='profile_pic/', validators=[recruitment.validators.validate_image_size])), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='SharedFormTemplate', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), + ('is_public', models.BooleanField(default=False, help_text='Whether this template is publicly available')), + ('shared_with', models.ManyToManyField(blank=True, related_name='shared_templates', to=settings.AUTH_USER_MODEL)), + ('template', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='recruitment.formtemplate')), + ], + options={ + 'verbose_name': 'Shared Form Template', + 'verbose_name_plural': 'Shared Form Templates', + }, + ), + migrations.CreateModel( + name='IntegrationLog', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), + ('action', models.CharField(choices=[('REQUEST', 'Request'), ('RESPONSE', 'Response'), ('ERROR', 'Error'), ('SYNC', 'Sync'), ('CREATE_JOB', 'Create Job'), ('UPDATE_JOB', 'Update Job')], max_length=20, verbose_name='Action')), + ('endpoint', models.CharField(blank=True, max_length=255, verbose_name='Endpoint')), + ('method', models.CharField(blank=True, max_length=10, verbose_name='HTTP Method')), + ('request_data', models.JSONField(blank=True, null=True, verbose_name='Request Data')), + ('response_data', models.JSONField(blank=True, null=True, verbose_name='Response Data')), + ('status_code', models.CharField(blank=True, max_length=10, verbose_name='Status Code')), + ('error_message', models.TextField(blank=True, verbose_name='Error Message')), + ('ip_address', models.GenericIPAddressField(verbose_name='IP Address')), + ('user_agent', models.CharField(blank=True, max_length=255, verbose_name='User Agent')), + ('processing_time', models.FloatField(blank=True, null=True, verbose_name='Processing Time (seconds)')), + ('source', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='integration_logs', to='recruitment.source', verbose_name='Source')), + ], + options={ + 'verbose_name': 'Integration Log', + 'verbose_name_plural': 'Integration Logs', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='TrainingMaterial', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), + ('title', models.CharField(max_length=255, verbose_name='Title')), + ('content', django_ckeditor_5.fields.CKEditor5Field(blank=True, verbose_name='Content')), + ('video_link', models.URLField(blank=True, verbose_name='Video Link')), + ('file', models.FileField(blank=True, upload_to='training_materials/', verbose_name='File')), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Created by')), + ], + options={ + 'verbose_name': 'Training Material', + 'verbose_name_plural': 'Training Materials', + }, + ), + migrations.CreateModel( + name='ScheduledInterview', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), + ('interview_date', models.DateField(db_index=True, verbose_name='Interview Date')), + ('interview_time', models.TimeField(verbose_name='Interview Time')), + ('status', models.CharField(choices=[('scheduled', 'Scheduled'), ('confirmed', 'Confirmed'), ('cancelled', 'Cancelled'), ('completed', 'Completed')], db_index=True, default='scheduled', max_length=20)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('candidate', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.candidate')), + ('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.jobposting')), + ('schedule', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interviews', to='recruitment.interviewschedule')), + ('zoom_meeting', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='interview', to='recruitment.zoommeeting')), + ], + ), + migrations.CreateModel( + name='MeetingComment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), + ('content', django_ckeditor_5.fields.CKEditor5Field(verbose_name='Content')), + ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='meeting_comments', to=settings.AUTH_USER_MODEL, verbose_name='Author')), + ('meeting', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='recruitment.zoommeeting', verbose_name='Meeting')), + ], + options={ + 'verbose_name': 'Meeting Comment', + 'verbose_name_plural': 'Meeting Comments', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='FieldResponse', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), + ('value', models.JSONField(blank=True, help_text='Response value (stored as JSON)', null=True)), + ('uploaded_file', models.FileField(blank=True, null=True, upload_to='form_uploads/')), + ('field', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='responses', to='recruitment.formfield')), + ('submission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='responses', to='recruitment.formsubmission')), + ], + options={ + 'verbose_name': 'Field Response', + 'verbose_name_plural': 'Field Responses', + 'indexes': [models.Index(fields=['submission'], name='recruitment_submiss_474130_idx'), models.Index(fields=['field'], name='recruitment_field_i_097e5b_idx')], + }, + ), + migrations.AddIndex( + model_name='formsubmission', + index=models.Index(fields=['submitted_at'], name='recruitment_submitt_7946c8_idx'), + ), + migrations.AddIndex( + model_name='interviewschedule', + index=models.Index(fields=['start_date'], name='recruitment_start_d_15d55e_idx'), + ), + migrations.AddIndex( + model_name='interviewschedule', + index=models.Index(fields=['end_date'], name='recruitment_end_dat_aeb00e_idx'), + ), + migrations.AddIndex( + model_name='interviewschedule', + index=models.Index(fields=['created_by'], name='recruitment_created_d0bdcc_idx'), + ), + migrations.AddIndex( + model_name='formtemplate', + index=models.Index(fields=['created_at'], name='recruitment_created_c21775_idx'), + ), + migrations.AddIndex( + model_name='formtemplate', + index=models.Index(fields=['is_active'], name='recruitment_is_acti_ae5efb_idx'), + ), + migrations.AddIndex( + model_name='candidate', + index=models.Index(fields=['stage'], name='recruitment_stage_f1c6eb_idx'), + ), + migrations.AddIndex( + model_name='candidate', + index=models.Index(fields=['created_at'], name='recruitment_created_73590f_idx'), + ), + migrations.AddIndex( + model_name='jobposting', + index=models.Index(fields=['status', 'created_at', 'title'], name='recruitment_status_8b77aa_idx'), + ), + migrations.AddIndex( + model_name='jobposting', + index=models.Index(fields=['slug'], name='recruitment_slug_004045_idx'), + ), + migrations.AddIndex( + model_name='scheduledinterview', + index=models.Index(fields=['job', 'status'], name='recruitment_job_id_f09e22_idx'), + ), + migrations.AddIndex( + model_name='scheduledinterview', + index=models.Index(fields=['interview_date', 'interview_time'], name='recruitment_intervi_7f5877_idx'), + ), + migrations.AddIndex( + model_name='scheduledinterview', + index=models.Index(fields=['candidate', 'job'], name='recruitment_candida_43d5b0_idx'), + ), + ] diff --git a/recruitment/migrations/0002_remove_jobposting_application_start_date.py b/recruitment/migrations/0002_remove_jobposting_application_start_date.py new file mode 100644 index 0000000..750027d --- /dev/null +++ b/recruitment/migrations/0002_remove_jobposting_application_start_date.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.7 on 2025-10-21 23:25 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0001_initial'), + ] + + operations = [ + migrations.RemoveField( + model_name='jobposting', + name='application_start_date', + ), + ] diff --git a/recruitment/models.py b/recruitment/models.py index 91a1fb6..00a51ac 100644 --- a/recruitment/models.py +++ b/recruitment/models.py @@ -84,8 +84,8 @@ class JobPosting(Base): null=True, blank=True, ) - application_start_date=models.DateField(null=True, blank=True) - application_deadline = models.DateField(db_index=True, null=True, blank=True) # Added index + + application_deadline = models.DateField(db_index=True) # Added index application_instructions =CKEditor5Field( blank=True, null=True,config_name='extends' ) @@ -137,7 +137,7 @@ class JobPosting(Base): reporting_to = models.CharField( max_length=100, blank=True, help_text="Who this position reports to" ) - joining_date = models.DateField(null=True, blank=True, help_text="Desired start date") + open_positions = models.PositiveIntegerField( default=1, help_text="Number of open positions for this job" ) @@ -732,8 +732,10 @@ class FormTemplate(Base): 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 + ) + # FIXME: on Delete model SETNULl + is_active = models.BooleanField( default=False, help_text="Whether this template is active" ) diff --git a/recruitment/signals.py b/recruitment/signals.py index ebc9f9b..2d1ed97 100644 --- a/recruitment/signals.py +++ b/recruitment/signals.py @@ -10,7 +10,7 @@ logger = logging.getLogger(__name__) @receiver(post_save, sender=JobPosting) def format_job(sender, instance, created, **kwargs): if created: - FormTemplate.objects.create(job=instance, is_active=True, name=instance.title) + # FormTemplate.objects.create(job=instance, is_active=True, name=instance.title) async_task( 'recruitment.tasks.format_job_description', instance.pk, diff --git a/recruitment/urls.py b/recruitment/urls.py index 604ad08..4d391ff 100644 --- a/recruitment/urls.py +++ b/recruitment/urls.py @@ -2,7 +2,6 @@ from django.urls import path from . import views_frontend from . import views from . import views_integration -from . import views_source urlpatterns = [ path('', views_frontend.dashboard_view, name='dashboard'), @@ -127,15 +126,6 @@ urlpatterns = [ - # 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/api/generate-keys/', views_source.generate_api_keys_view, name='generate_api_keys'), - 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'), diff --git a/recruitment/views.py b/recruitment/views.py index 73fc246..3c24c73 100644 --- a/recruitment/views.py +++ b/recruitment/views.py @@ -273,21 +273,12 @@ def create_job(request): if request.method == "POST": form = JobPostingForm( - request.POST, is_anonymous_user=not request.user.is_authenticated + request.POST ) # to check user is authenticated or not if form.is_valid(): try: job = form.save(commit=False) - if request.user.is_authenticated: - job.created_by = ( - request.user.get_full_name() or request.user.username - ) - else: - job.created_by = request.POST.get("created_by", "").strip() - if not job.created_by: - job.created_by = request.user.username - job.save() job_apply_url_relative=reverse('job_detail_candidate',kwargs={'slug':job.slug}) job_apply_url_absolute=request.build_absolute_uri(job_apply_url_relative) @@ -302,7 +293,7 @@ def create_job(request): else: messages.error(request, f"Please correct the errors below.{form.errors}") else: - form = JobPostingForm(is_anonymous_user=not request.user.is_authenticated) + form = JobPostingForm() return render(request, "jobs/create_job.html", {"form": form}) @@ -313,21 +304,11 @@ def edit_job(request, slug): if request.method == "POST": form = JobPostingForm( request.POST, - instance=job, - is_anonymous_user=not request.user.is_authenticated, + instance=job ) if form.is_valid(): try: - job = form.save(commit=False) - if request.user.is_authenticated: - job.created_by = ( - request.user.get_full_name() or request.user.username - ) - else: - job.created_by = request.POST.get("created_by", "").strip() - if not job.created_by: - job.created_by = "University Administrator" - job.save() + form.save() messages.success(request, f'Job "{job.title}" updated successfully!') return redirect("job_list") except Exception as e: @@ -338,7 +319,7 @@ def edit_job(request, slug): else: job = get_object_or_404(JobPosting, slug=slug) form = JobPostingForm( - instance=job, is_anonymous_user=not request.user.is_authenticated + instance=job ) return render(request, "jobs/edit_job.html", {"form": form, "job": job}) @@ -347,6 +328,7 @@ def edit_job(request, slug): def job_detail(request, slug): """View details of a specific job""" job = get_object_or_404(JobPosting, slug=slug) + # Get all candidates for this job, ordered by most recent applicants = job.candidates.all().order_by("-created_at") @@ -632,15 +614,15 @@ def application_success(request,slug): @ensure_csrf_cookie @login_required -def form_builder(request, template_slug=None): +def form_builder(request, template_id=None): """Render the form builder interface""" context = {} - if template_slug: + if template_id: template = get_object_or_404( - FormTemplate, slug=template_slug + FormTemplate, id=template_id, created_by=request.user ) context['template']=template - context["template_slug"] = template.slug + context["template_id"] = template.id context["template_name"] = template.name return render(request, "forms/form_builder.html", context) @@ -653,12 +635,12 @@ def save_form_template(request): data = json.loads(request.body) template_name = data.get("name", "Untitled Form") stages_data = data.get("stages", []) - template_slug = data.get("template_slug") + template_id = data.get("template_id") - if template_slug: + if template_id: # Update existing template template = get_object_or_404( - FormTemplate, slug=template_slug + FormTemplate, id=template_id, created_by=request.user ) template.name = template_name template.save() @@ -667,7 +649,7 @@ def save_form_template(request): else: # Create new template template = FormTemplate.objects.create( - name=template_name + name=template_name, created_by=request.user ) # Create stages and fields @@ -703,7 +685,7 @@ def save_form_template(request): return JsonResponse( { "success": True, - "template_slug": template.slug, + "template_id": template.id, "message": "Form template saved successfully!", } ) @@ -712,9 +694,9 @@ def save_form_template(request): @require_http_methods(["GET"]) -def load_form_template(request, template_slug): +def load_form_template(request, template_id): """Load an existing form template""" - template = get_object_or_404(FormTemplate, slug=template_slug) + template = get_object_or_404(FormTemplate, id=template_id, created_by=request.user) stages = [] for stage in template.stages.all(): @@ -747,7 +729,6 @@ def load_form_template(request, template_slug): "success": True, "template": { "id": template.id, - "template_slug": template.slug, "name": template.name, "description": template.description, "is_active": template.is_active, @@ -762,7 +743,7 @@ def load_form_template(request, template_slug): def form_templates_list(request): """List all form templates for the current user""" query = request.GET.get("q", "") - templates = FormTemplate.objects.filter() + templates = FormTemplate.objects.filter(created_by=request.user) if query: templates = templates.filter( @@ -802,7 +783,7 @@ def create_form_template(request): @require_http_methods(["GET"]) def list_form_templates(request): """List all form templates for the current user""" - templates = FormTemplate.objects.filter().values( + templates = FormTemplate.objects.filter(created_by=request.user).values( "id", "name", "description", "created_at", "updated_at" ) return JsonResponse({"success": True, "templates": list(templates)}) @@ -812,25 +793,26 @@ def list_form_templates(request): @require_http_methods(["DELETE"]) def delete_form_template(request, template_id): """Delete a form template""" - template = get_object_or_404(FormTemplate, id=template_id) + template = get_object_or_404(FormTemplate, id=template_id, created_by=request.user) template.delete() return JsonResponse( {"success": True, "message": "Form template deleted successfully!"} ) -def form_wizard_view(request, template_slug): +def form_wizard_view(request, template_id): """Display the form as a step-by-step wizard""" - template = get_object_or_404(FormTemplate, slug=template_slug, is_active=True) + template = get_object_or_404(FormTemplate, pk=template_id, is_active=True) job_id = template.job.internal_job_id job=template.job - is_limit_exceeded = job.is_application_limit_reached + 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. Please explore other available positions.' ) return redirect('job_detail_candidate',slug=job.slug) + if job.is_expired: messages.error( request, @@ -841,16 +823,14 @@ def form_wizard_view(request, template_slug): return render( request, "forms/form_wizard.html", - {"template_slug": template_slug, "job_id": job_id}, + {"template_id": template_id, "job_id": job_id}, ) -@csrf_exempt @require_POST -def submit_form(request, template_slug): +def submit_form(request, template_id): """Handle form submission""" - template = get_object_or_404(FormTemplate, slug=template_slug) - job = template.job + template = get_object_or_404(FormTemplate, id=template_id) if request.method == "POST": try: with transaction.atomic(): @@ -864,7 +844,6 @@ def submit_form(request, template_slug): {"success": False, "message": "Application limit reached for this job."} ) submission = FormSubmission.objects.create(template=template) - # Process field responses for field_id, value in request.POST.items(): if field_id.startswith("field_"): @@ -909,7 +888,7 @@ def submit_form(request, template_slug): ) submission.applicant_email = email.display_value submission.save() - # time=timezone.now() + time=timezone.now() Candidate.objects.create( first_name=first_name.display_value, last_name=last_name.display_value, @@ -917,16 +896,10 @@ def submit_form(request, template_slug): phone=phone.display_value, address=address.display_value, resume=resume.get_file if resume.is_file else None, - job=job + job=submission.template.job, + ) - return JsonResponse( - { - "success": True, - "message": "Form submitted successfully!", - "redirect_url": reverse('application_success',kwargs={'slug':job.slug}), - } - ) - # return redirect('application_success',slug=job.slug) + return redirect('application_success',slug=job.slug) except Exception as e: logger.error(f"Candidate creation failed,{e}") @@ -2071,6 +2044,7 @@ def user_detail(request, pk): user = get_object_or_404(User, pk=pk) try: + profile_instance = user.profile profile_form = ProfileImageUploadForm(instance=profile_instance) except: @@ -2224,10 +2198,6 @@ def account_toggle_status(request,pk): messages.error(f'Please correct the error below') -@login_required -def user_detail(requests,pk): - user=get_object_or_404(User,pk=pk) - return render(requests,'user/profile.html') @csrf_exempt diff --git a/recruitment/views_frontend.py b/recruitment/views_frontend.py index 50c05e3..61d5fa2 100644 --- a/recruitment/views_frontend.py +++ b/recruitment/views_frontend.py @@ -394,6 +394,27 @@ def dashboard_view(request): ).count() high_potential_ratio = round((high_potential_count / total_candidates) * 100, 1) if total_candidates > 0 else 0 + #donut chart data + jobs=models.JobPosting.objects.all() + selected_job_id=request.GET.get('selected_job_id') + print(jobs) + print(selected_job_id) + apply_counts,exam_counts,interview_counts,offer_counts=[0]*4 + if selected_job_id: + job=jobs.filter(internal_job_id=selected_job_id) + apply_counts=job.screening_candidates_count or 0 + exam_counts=job.exam_candidates_count or 0 + interview_counts=job.interview_candidates_count or 0 + offer_counts=job.offer_candidates_count or 0 + + + + + applicant_stages=['APPLIED','EXAM','INTERVIEW','OFFER'] + stage_counts=[apply_counts,exam_counts,interview_counts,offer_counts] + + + context = { 'total_jobs': total_jobs, @@ -409,8 +430,13 @@ def dashboard_view(request): 'high_potential_count': high_potential_count, 'high_potential_ratio': high_potential_ratio, 'scored_ratio': scored_ratio, + 'applicant_stages':json.dumps(applicant_stages), + 'stage_counts':json.dumps(stage_counts), + 'jobs':'jobs', + 'selected_job_id':selected_job_id } return render(request, 'recruitment/dashboard.html', context) + @login_required def candidate_offer_view(request, slug): """View for candidates in the Offer stage""" diff --git a/recruitment/views_source.py b/recruitment/views_source.py deleted file mode 100644 index 078e485..0000000 --- a/recruitment/views_source.py +++ /dev/null @@ -1,212 +0,0 @@ -from django.shortcuts import render, get_object_or_404, redirect -from django.views.generic import ListView, CreateView, UpdateView, DetailView, DeleteView -from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin -from django.urls import reverse_lazy -from django.contrib import messages -from django.db import transaction -from django.http import JsonResponse -from django.db import models -import secrets -import string -from .models import Source, IntegrationLog -from .forms import SourceForm, generate_api_key, generate_api_secret - -class SourceListView(LoginRequiredMixin, UserPassesTestMixin, ListView): - """List all sources""" - model = Source - template_name = 'recruitment/source_list.html' - context_object_name = 'sources' - paginate_by = 10 - - def test_func(self): - return self.request.user.is_staff - - def get_queryset(self): - queryset = super().get_queryset().order_by('name') - - # Search functionality - search_query = self.request.GET.get('search', '') - if search_query: - queryset = queryset.filter( - models.Q(name__icontains=search_query) | - models.Q(source_type__icontains=search_query) | - models.Q(description__icontains=search_query) - ) - - return queryset - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['search_query'] = self.request.GET.get('search', '') - return context - -class SourceCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView): - """Create a new source""" - model = Source - form_class = SourceForm - template_name = 'recruitment/source_form.html' - success_url = reverse_lazy('source_list') - - def test_func(self): - return self.request.user.is_staff - - def form_valid(self, form): - # Set initial values - form.instance.created_by = self.request.user.get_full_name() or self.request.user.username - - # Check if we need to generate API keys - if form.cleaned_data.get('generate_keys') == 'true': - form.instance.api_key = generate_api_key() - form.instance.api_secret = generate_api_secret() - - # Log the key generation - IntegrationLog.objects.create( - source=form.instance, - action=IntegrationLog.ActionChoices.CREATE, - endpoint='/api/sources/', - method='POST', - request_data={'name': form.instance.name}, - ip_address=self.request.META.get('REMOTE_ADDR'), - user_agent=self.request.META.get('HTTP_USER_AGENT', '') - ) - - response = super().form_valid(form) - - # Add success message - messages.success(self.request, f'Source "{form.instance.name}" created successfully!') - - return response - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['title'] = 'Create New Source' - context['generate_keys'] = self.request.GET.get('generate_keys', 'false') - return context - -class SourceDetailView(LoginRequiredMixin, UserPassesTestMixin, DetailView): - """View source details""" - model = Source - template_name = 'recruitment/source_detail.html' - context_object_name = 'source' - - def test_func(self): - return self.request.user.is_staff - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - - # Mask API keys in display - source = self.object - if source.api_key: - masked_key = source.api_key[:8] + '*' * 24 - context['masked_api_key'] = masked_key - else: - context['masked_api_key'] = 'Not generated' - - if source.api_secret: - masked_secret = source.api_secret[:12] + '*' * 52 - context['masked_api_secret'] = masked_secret - else: - context['masked_api_secret'] = 'Not generated' - - # Get recent integration logs - context['recent_logs'] = IntegrationLog.objects.filter( - source=source - ).order_by('-created_at')[:10] - - return context - -class SourceUpdateView(LoginRequiredMixin, UserPassesTestMixin, UpdateView): - """Update an existing source""" - model = Source - form_class = SourceForm - template_name = 'recruitment/source_form.html' - success_url = reverse_lazy('source_list') - - def test_func(self): - return self.request.user.is_staff - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['title'] = f'Edit Source: {self.object.name}' - context['generate_keys'] = self.request.GET.get('generate_keys', 'false') - return context - - def form_valid(self, form): - # Check if we need to generate new API keys - if form.cleaned_data.get('generate_keys') == 'true': - form.instance.api_key = generate_api_key() - form.instance.api_secret = generate_api_secret() - - # Log the key regeneration - IntegrationLog.objects.create( - source=self.object, - action=IntegrationLog.ActionChoices.CREATE, - endpoint=f'/api/sources/{self.object.pk}/', - method='PUT', - request_data={'name': form.instance.name, 'regenerated_keys': True}, - ip_address=self.request.META.get('REMOTE_ADDR'), - user_agent=self.request.META.get('HTTP_USER_AGENT', '') - ) - - messages.success(self.request, 'New API keys generated successfully!') - - response = super().form_valid(form) - messages.success(self.request, f'Source "{form.instance.name}" updated successfully!') - return response - -class SourceDeleteView(LoginRequiredMixin, UserPassesTestMixin, DeleteView): - """Delete a source""" - model = Source - template_name = 'recruitment/source_confirm_delete.html' - success_url = reverse_lazy('source_list') - - def test_func(self): - return self.request.user.is_staff - - def delete(self, request, *args, **kwargs): - self.object = self.get_object() - success_url = self.get_success_url() - - # Log the deletion - IntegrationLog.objects.create( - source=self.object, - action=IntegrationLog.ActionChoices.SYNC, # Using SYNC for deletion - endpoint=f'/api/sources/{self.object.pk}/', - method='DELETE', - request_data={'name': self.object.name}, - ip_address=self.request.META.get('REMOTE_ADDR'), - user_agent=self.request.META.get('HTTP_USER_AGENT', '') - ) - - messages.success(request, f'Source "{self.object.name}" deleted successfully!') - return super().delete(request, *args, **kwargs) - -def generate_api_keys_view(request): - """API endpoint to generate API keys""" - if not request.user.is_staff: - return JsonResponse({'error': 'Permission denied'}, status=403) - - if request.method == 'POST': - api_key = generate_api_key() - api_secret = generate_api_secret() - - return JsonResponse({ - 'success': True, - 'api_key': api_key, - 'api_secret': api_secret, - 'message': 'API keys generated successfully' - }) - - return JsonResponse({'error': 'Invalid request method'}, status=405) - -def copy_to_clipboard_view(request): - """HTMX endpoint to copy text to clipboard""" - if request.method == 'POST': - text_to_copy = request.POST.get('text', '') - - return render(request, 'includes/copy_to_clipboard.html', { - 'text': text_to_copy - }) - - return JsonResponse({'error': 'Invalid request method'}, status=405) diff --git a/templates/base.html b/templates/base.html index b399ec2..b4a6c35 100644 --- a/templates/base.html +++ b/templates/base.html @@ -16,9 +16,9 @@ {% endif %} - + - + @@ -44,7 +44,7 @@
King Abdullah bin Abdulaziz University Hospital
- KAAUH Logo + KAAUH Logo @@ -55,11 +55,11 @@ {# --- MOBILE BRAND LOGIC: Show small logo on mobile, large on desktop (lg) --- #} - {% trans 'kaauh logo green bg' %} + {% trans 'kaauh logo green bg' %} - {% trans 'kaauh logo green bg' %} + {% trans 'kaauh logo green bg' %} {# Toggler: order-lg-0 ensures it's before navigation links on desktop, but it stays where it is on mobile #} @@ -120,7 +120,7 @@ {% endif %}
@@ -323,21 +332,20 @@
- + - -
+ +
{% trans "Financial & Timeline" %}
@@ -392,19 +400,74 @@
{{ job.benefits|safe}}
{% endif %} - - - {# TAB 3 CONTENT: APPLICATION INSTRUCTIONS #} - {% if job.application_instructions %} -
-
+ {% if job.application_instructions %} +
{% trans "Application Instructions" %}
{{ job.application_instructions|safe }}
-
- {% endif %} + {% endif %} +
+ + {# TAB 3 CONTENT: APPLICATION KPIS #} +
+
+ + {# 1. Job Avg. Score #} +
+
+
+ +
{{ avg_match_score|floatformat:1 }}
+ {% trans "Avg. AI Score" %} +
+
+
+ + {# 2. High Potential Count #} +
+
+
+ +
{{ high_potential_count }}
+ {% trans "High Potential" %} +
+
+
+ + {# 3. Avg. Time to Interview #} +
+
+
+ +
{{ avg_t2i_days|floatformat:1 }}d
+ {% trans "Time to Interview" %} +
+
+
+ + {# 4. Avg. Exam Review Time #} +
+
+
+ +
{{ avg_t_in_exam_days|floatformat:1 }}d
+ {% trans "Avg. Exam Review" %} +
+
+
+
+ +

+ {% trans "KPIs based on completed applicant data." %} +

+ +
+ + + + {# FOOTER ACTIONS #} @@ -423,7 +486,7 @@ {# RIGHT COLUMN: TABBED CARDS #} -
+
{# New Card for Candidate Category Chart #}
@@ -440,44 +503,9 @@
-
-
-
{% trans "Applicant Tracking" %}
- {% include 'jobs/partials/applicant_tracking.html' %} -
-
- -
-
-
-

{% trans "Job Avg. Score" %}

-
-
{{ avg_match_score|floatformat:1 }}
-
{% trans "Average AI Match Score (0-100)" %}
-
-
-
-

{% trans "High Potential Count" %}

-
-
{{ high_potential_count }}
-
{% trans "Candidates with Score ≥ 75%" %}
-
-
-
-

{% trans "Avg. Time to Interview" %}

-
-
{{ avg_t2i_days|floatformat:1 }}d
-
{% trans "Applied to Interview (Total Funnel Speed)" %}
-
-
-
-

{% trans "Avg. Exam Review Time" %}

-
-
{{ avg_t_in_exam_days|floatformat:1 }}d
-
{% trans "Days spent between Exam and Interview" %}
-
-
-
+ {# REMOVED: Standalone Applicant Tracking Card (It is now in a tab) #} + +
{# RIGHT TABS NAVIGATION #} @@ -503,35 +536,7 @@ {# TAB 1: APPLICANTS CONTENT #} - {# TAB 2: MANAGEMENT (LinkedIn & Forms) CONTENT #} + {# NEW TAB 2: APPLICANT TRACKING CONTENT #} +
+
{% trans "Pipeline Stages" %}
+ {% include 'jobs/partials/applicant_tracking.html' %} +

{% trans "View the number of candidates currently in each stage of the hiring pipeline." %}

+
+ + {# TAB 3: MANAGEMENT (Form Template) CONTENT #}
- {# LinkedIn Integration (Content from old card) #} - - - {# Applicant Form Management (Content from old card) #}
{% trans "Form Management" %}

@@ -569,8 +577,8 @@

- {# TAB 3: INTERNAL INFO CONTENT #} -
- - {# TAB 3: INTERNAL INFO CONTENT #} -
-
{% trans "Internal Information" %}
-
-

{% trans "Internal Job ID:" %} {{ job.internal_job_id }}

-

{% trans "Created:" %} {{ job.created_at|date:"M d, Y" }}

-

{% trans "Last Updated:" %} {{ job.updated_at|date:"M d, Y" }}

- {% if job.reporting_to %} -

{% trans "Reports To:" %} {{ job.reporting_to }}

- {% endif %} -
- -
@@ -660,10 +626,8 @@
- {% include "jobs/partials/image_upload.html" %} -