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 4d4c47a..a59ff1e 100644 Binary files a/recruitment/__pycache__/forms.cpython-313.pyc and b/recruitment/__pycache__/forms.cpython-313.pyc differ diff --git a/recruitment/__pycache__/urls.cpython-313.pyc b/recruitment/__pycache__/urls.cpython-313.pyc index 0f0a705..097b279 100644 Binary files a/recruitment/__pycache__/urls.cpython-313.pyc and b/recruitment/__pycache__/urls.cpython-313.pyc differ 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 %}