From ee78018a5a8383a9862e484cca247af7655a6a3c Mon Sep 17 00:00:00 2001 From: ismail Date: Wed, 22 Oct 2025 13:10:03 +0300 Subject: [PATCH] add source crud --- locale/ar/LC_MESSAGES/django.po | 175 +++++++ locale/en/LC_MESSAGES/django.po | 175 +++++++ recruitment/__pycache__/forms.cpython-313.pyc | Bin 27062 -> 32066 bytes recruitment/__pycache__/urls.cpython-313.pyc | Bin 11429 -> 12513 bytes recruitment/forms.py | 439 ++++++++---------- recruitment/urls.py | 10 + recruitment/views_source.py | 212 +++++++++ templates/base.html | 22 +- templates/icons/sources.html | 4 + templates/includes/copy_to_clipboard.html | 20 + .../recruitment/candidate_offer_view.html | 56 --- .../recruitment/source_confirm_delete.html | 128 +++++ templates/recruitment/source_detail.html | 285 ++++++++++++ templates/recruitment/source_form.html | 376 +++++++++++++++ templates/recruitment/source_list.html | 209 +++++++++ 15 files changed, 1792 insertions(+), 319 deletions(-) create mode 100644 recruitment/views_source.py create mode 100644 templates/icons/sources.html create mode 100644 templates/includes/copy_to_clipboard.html create mode 100644 templates/recruitment/source_confirm_delete.html create mode 100644 templates/recruitment/source_detail.html create mode 100644 templates/recruitment/source_form.html create mode 100644 templates/recruitment/source_list.html diff --git a/locale/ar/LC_MESSAGES/django.po b/locale/ar/LC_MESSAGES/django.po index f28f365..e112fa0 100644 --- a/locale/ar/LC_MESSAGES/django.po +++ b/locale/ar/LC_MESSAGES/django.po @@ -1426,3 +1426,178 @@ 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 ecedf37..25de3e8 100644 --- a/locale/en/LC_MESSAGES/django.po +++ b/locale/en/LC_MESSAGES/django.po @@ -1425,3 +1425,178 @@ 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__/forms.cpython-313.pyc b/recruitment/__pycache__/forms.cpython-313.pyc index 4d4c47a37a43934a48ba3589ef82ab55c0ee996f..a59ff1eda8119906446f0825b45958e8cda22128 100644 GIT binary patch literal 32066 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 NsoyM;zXPY^{{r>2&0K!5A^2JQs31 zr`L>39+-TNu^?jwpvvP5klNCXQ7oUJW-x?d(a}tTRdw;8-^UP7h;4DXHIh|Gre_+S(7iOk9-zDjOx zLY*6-bJHxP3vEJXGngR91d$1mv3Z%1EhuS*xm!40Ey%q5Kg?DzTRHn|L;JLXX@&n9 z8HvV78_L@uZ|C%GM`j0@9URkvOedI5j_E?C8*b@toi6es3++Ux2T;$80xozjGP~ep zyEtYyGJC-6;h4Tq%D%mgx*!CZzJ5>ky3g7PbnU*Yt=h0G+FNshUS%oLa@ zj(HoIYhbRy43jX!J1D;n`E^e3yU5%CbAw~vLuMMxG{?+nCcJs~`|b~ZR)L#*n^tw( zJil>)U?ZPd%w*E?w>@}2rKi8^!0rLxaCqOrupV4EQ7!RmNl?o?@`bfxkpT|xC-(OA z4R!Pnbcg4|L-NFGCRSUHkV&d-`vo)k*k%_jw_~;pK~%pdkRO*enC~yxH4xt4aj0*! zE8NpD(${+^T%+zPsbwt**~`Q_)t<4}q=vVsVY6CVNT;LCs=nC-_&b;ZUxgLDAP5h1 zCic7Nfi6!GCGvwKQ;^6bhi#b@H$U$8WL`Xf;lsD-dn?RQGgZ&VvRef*rDXHH49tIn@*Rl<f3K{&c)F*;P+n&z{=~ ziFCVcny6yJ7fqA>&D7rT~|KTUtRgy3D=P|?mYc^h$`LcLbqE* zXW8+mtbWJpV|Om(OFSkM8>3TTXR%B>Zu(|?(GygxQ@Q{IJx-{j9<#Q4KZ$rU^?M=u z++(g}okLn@_Efo?9MKki(RkS}QNMK0OvSCrA?P<2n4tO!)F~QS z?PqnoAXj_MYvb5N#nDxAX$viFiETef#UUjSCy_e^Ug-LRRZF4z`~s}@7pl9B7v@&6 zlecO+ltp{xiV&>`#ah`8ZEE zwB1Ug<#K6|mIh;6*?_%zHsCIGb=-8R_v*mJz^zIuE>m{Hd4BV$)1?%t)m4<+#q0rX zC-*If>Q7AH|cUs8?9-JbswhU i5#_kHdO}ZT{OO4i?U6*j_GcK@it0&Eri9fG-G2ZU)V!?# diff --git a/recruitment/forms.py b/recruitment/forms.py index 1794f3f..93d73a1 100644 --- a/recruitment/forms.py +++ b/recruitment/forms.py @@ -1,20 +1,171 @@ 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 + Profile,MeetingComment,ScheduledInterview,Source ) # 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: @@ -72,51 +223,6 @@ 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 @@ -146,8 +252,6 @@ 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 @@ -160,13 +264,11 @@ class TrainingMaterialForm(forms.ModelForm): } widgets = { 'title': forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Enter material title')}), - # 💡 Use SummernoteWidget here - # 'content': SummernoteWidget(attrs={'placeholder': _('Enter material content')}), + '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'}), } - # The __init__ and FormHelper layout remains the same def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.helper = FormHelper() @@ -175,7 +277,7 @@ class TrainingMaterialForm(forms.ModelForm): self.helper.layout = Layout( 'title', - 'content', # Summernote is applied via the widgets dictionary + 'content', Row( Column('video_link', css_class='col-md-6'), Column('file', css_class='col-md-6'), @@ -188,7 +290,6 @@ class TrainingMaterialForm(forms.ModelForm): ) ) - class JobPostingForm(forms.ModelForm): """Form for creating and editing job postings""" @@ -203,7 +304,6 @@ class JobPostingForm(forms.ModelForm): 'created_by','open_positions','hash_tags','max_applications' ] widgets = { - # Basic Information 'title': forms.TextInput(attrs={ 'class': 'form-control', 'placeholder': 'Assistant Professor of Computer Science', @@ -221,8 +321,6 @@ class JobPostingForm(forms.ModelForm): 'class': 'form-select', 'required': True }), - - # Location 'location_city': forms.TextInput(attrs={ 'class': 'form-control', 'placeholder': 'Boston' @@ -235,20 +333,10 @@ 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_start_date': forms.DateInput(attrs={ 'class': 'form-control', 'type': 'date' @@ -257,7 +345,6 @@ class JobPostingForm(forms.ModelForm): 'class': 'form-control', 'type': 'date' }), - 'open_positions': forms.NumberInput(attrs={ 'class': 'form-control', 'min': 1, @@ -266,10 +353,7 @@ 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' @@ -282,7 +366,6 @@ class JobPostingForm(forms.ModelForm): 'class': 'form-control', 'type': 'date' }), - 'created_by': forms.TextInput(attrs={ 'class': 'form-control', 'placeholder': 'University Administrator' @@ -295,21 +378,16 @@ class JobPostingForm(forms.ModelForm): } def __init__(self,*args,**kwargs): - - # Extract your custom argument BEFORE calling super() 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:# Creating new job posting + if not self.instance.pk: if not self.is_anonymous_user: self.fields['created_by'].initial = 'University Administrator' - # 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') if hash_tags: @@ -318,7 +396,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 # Allow blank + return hash_tags def clean_title(self): title=self.cleaned_data.get('title') @@ -332,43 +410,7 @@ 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() # 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 + return description.strip() class JobPostingImageForm(forms.ModelForm): class Meta: @@ -416,90 +458,6 @@ 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): """ @@ -516,10 +474,8 @@ 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(), @@ -555,7 +511,6 @@ 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' @@ -563,7 +518,6 @@ 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): @@ -593,26 +547,7 @@ 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'] @@ -622,36 +557,6 @@ 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) @@ -685,15 +590,37 @@ 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) # never use raw email if it has dots, etc. + user.username = self.generate_username(user.email) 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/urls.py b/recruitment/urls.py index 4d391ff..604ad08 100644 --- a/recruitment/urls.py +++ b/recruitment/urls.py @@ -2,6 +2,7 @@ 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'), @@ -126,6 +127,15 @@ 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_source.py b/recruitment/views_source.py new file mode 100644 index 0000000..078e485 --- /dev/null +++ b/recruitment/views_source.py @@ -0,0 +1,212 @@ +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 0250f32..b399ec2 100644 --- a/templates/base.html +++ b/templates/base.html @@ -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 #} @@ -216,13 +216,21 @@ + {% comment %}