...
This commit is contained in:
parent
a1558a6b22
commit
1f3c38d687
@ -1426,178 +1426,3 @@ msgstr ""
|
|||||||
#: templates/unfold/components/table.html:43
|
#: templates/unfold/components/table.html:43
|
||||||
msgid "No data"
|
msgid "No data"
|
||||||
msgstr ""
|
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 "تم حذف المصدر بنجاح."
|
|
||||||
|
|||||||
@ -1425,178 +1425,3 @@ msgstr ""
|
|||||||
#: templates/unfold/components/table.html:43
|
#: templates/unfold/components/table.html:43
|
||||||
msgid "No data"
|
msgid "No data"
|
||||||
msgstr ""
|
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 ""
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@ -1,171 +1,20 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
|
from .validators import validate_hash_tags
|
||||||
from django.core.validators import URLValidator
|
from django.core.validators import URLValidator
|
||||||
from django.forms.formsets import formset_factory
|
from django.forms.formsets import formset_factory
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from crispy_forms.helper import FormHelper
|
from crispy_forms.helper import FormHelper
|
||||||
from crispy_forms.layout import Layout, Submit, Row, Column, Field, Div
|
from crispy_forms.layout import Layout, Submit, Row, Column, Field, Div
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.contrib.auth.forms import UserCreationForm
|
|
||||||
import re
|
|
||||||
from .models import (
|
from .models import (
|
||||||
ZoomMeeting, Candidate,TrainingMaterial,JobPosting,
|
ZoomMeeting, Candidate,TrainingMaterial,JobPosting,
|
||||||
FormTemplate,InterviewSchedule,BreakTime,JobPostingImage,
|
FormTemplate,InterviewSchedule,BreakTime,JobPostingImage,
|
||||||
Profile,MeetingComment,ScheduledInterview,Source
|
Profile,MeetingComment,ScheduledInterview
|
||||||
)
|
)
|
||||||
# from django_summernote.widgets import SummernoteWidget
|
# from django_summernote.widgets import SummernoteWidget
|
||||||
from django_ckeditor_5.widgets import CKEditor5Widget
|
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 CandidateForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -223,6 +72,51 @@ class CandidateStageForm(forms.ModelForm):
|
|||||||
'stage': forms.Select(attrs={'class': 'form-select'}),
|
'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 ZoomMeetingForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ZoomMeeting
|
model = ZoomMeeting
|
||||||
@ -252,6 +146,8 @@ class ZoomMeetingForm(forms.ModelForm):
|
|||||||
Submit('submit', _('Create Meeting'), css_class='btn btn-primary')
|
Submit('submit', _('Create Meeting'), css_class='btn btn-primary')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Old JobForm removed - replaced by JobPostingForm
|
||||||
|
|
||||||
class TrainingMaterialForm(forms.ModelForm):
|
class TrainingMaterialForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = TrainingMaterial
|
model = TrainingMaterial
|
||||||
@ -264,11 +160,13 @@ class TrainingMaterialForm(forms.ModelForm):
|
|||||||
}
|
}
|
||||||
widgets = {
|
widgets = {
|
||||||
'title': forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Enter material title')}),
|
'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=...')}),
|
'video_link': forms.URLInput(attrs={'class': 'form-control', 'placeholder': _('https://www.youtube.com/watch?v=...')}),
|
||||||
'file': forms.FileInput(attrs={'class': 'form-control'}),
|
'file': forms.FileInput(attrs={'class': 'form-control'}),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# The __init__ and FormHelper layout remains the same
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.helper = FormHelper()
|
self.helper = FormHelper()
|
||||||
@ -277,7 +175,7 @@ class TrainingMaterialForm(forms.ModelForm):
|
|||||||
|
|
||||||
self.helper.layout = Layout(
|
self.helper.layout = Layout(
|
||||||
'title',
|
'title',
|
||||||
'content',
|
'content', # Summernote is applied via the widgets dictionary
|
||||||
Row(
|
Row(
|
||||||
Column('video_link', css_class='col-md-6'),
|
Column('video_link', css_class='col-md-6'),
|
||||||
Column('file', css_class='col-md-6'),
|
Column('file', css_class='col-md-6'),
|
||||||
@ -290,6 +188,7 @@ class TrainingMaterialForm(forms.ModelForm):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class JobPostingForm(forms.ModelForm):
|
class JobPostingForm(forms.ModelForm):
|
||||||
"""Form for creating and editing job postings"""
|
"""Form for creating and editing job postings"""
|
||||||
|
|
||||||
@ -304,6 +203,7 @@ class JobPostingForm(forms.ModelForm):
|
|||||||
'open_positions','hash_tags','max_applications'
|
'open_positions','hash_tags','max_applications'
|
||||||
]
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
|
# Basic Information
|
||||||
'title': forms.TextInput(attrs={
|
'title': forms.TextInput(attrs={
|
||||||
'class': 'form-control',
|
'class': 'form-control',
|
||||||
'placeholder': 'Assistant Professor of Computer Science',
|
'placeholder': 'Assistant Professor of Computer Science',
|
||||||
@ -321,6 +221,8 @@ class JobPostingForm(forms.ModelForm):
|
|||||||
'class': 'form-select',
|
'class': 'form-select',
|
||||||
'required': True
|
'required': True
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
# Location
|
||||||
'location_city': forms.TextInput(attrs={
|
'location_city': forms.TextInput(attrs={
|
||||||
'class': 'form-control',
|
'class': 'form-control',
|
||||||
'placeholder': 'Boston'
|
'placeholder': 'Boston'
|
||||||
@ -333,6 +235,8 @@ class JobPostingForm(forms.ModelForm):
|
|||||||
'class': 'form-control',
|
'class': 'form-control',
|
||||||
'value': 'United States'
|
'value': 'United States'
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|
||||||
'salary_range': forms.TextInput(attrs={
|
'salary_range': forms.TextInput(attrs={
|
||||||
'class': 'form-control',
|
'class': 'form-control',
|
||||||
'placeholder': '$60,000 - $80,000'
|
'placeholder': '$60,000 - $80,000'
|
||||||
@ -346,11 +250,24 @@ class JobPostingForm(forms.ModelForm):
|
|||||||
# 'required': True
|
# '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={
|
'application_deadline': forms.DateInput(attrs={
|
||||||
'class': 'form-control',
|
'class': 'form-control',
|
||||||
'type': 'date',
|
'type': 'date',
|
||||||
'required':True
|
'required':True
|
||||||
}),
|
}),
|
||||||
|
|
||||||
'open_positions': forms.NumberInput(attrs={
|
'open_positions': forms.NumberInput(attrs={
|
||||||
'class': 'form-control',
|
'class': 'form-control',
|
||||||
'min': 1,
|
'min': 1,
|
||||||
@ -359,7 +276,10 @@ class JobPostingForm(forms.ModelForm):
|
|||||||
'hash_tags': forms.TextInput(attrs={
|
'hash_tags': forms.TextInput(attrs={
|
||||||
'class': 'form-control',
|
'class': 'form-control',
|
||||||
'placeholder': '#hiring,#jobopening',
|
'placeholder': '#hiring,#jobopening',
|
||||||
|
# 'validators':validate_hash_tags, # Assuming this is available
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
# Internal Information
|
||||||
'position_number': forms.TextInput(attrs={
|
'position_number': forms.TextInput(attrs={
|
||||||
'class': 'form-control',
|
'class': 'form-control',
|
||||||
'placeholder': 'UNIV-2025-001'
|
'placeholder': 'UNIV-2025-001'
|
||||||
@ -397,7 +317,7 @@ class JobPostingForm(forms.ModelForm):
|
|||||||
if not tag.startswith('#'):
|
if not tag.startswith('#'):
|
||||||
raise forms.ValidationError("Each hashtag must start with '#' symbol and must be comma(,) sepearted.")
|
raise forms.ValidationError("Each hashtag must start with '#' symbol and must be comma(,) sepearted.")
|
||||||
return ','.join(tags)
|
return ','.join(tags)
|
||||||
return hash_tags
|
return hash_tags # Allow blank
|
||||||
|
|
||||||
def clean_title(self):
|
def clean_title(self):
|
||||||
title=self.cleaned_data.get('title')
|
title=self.cleaned_data.get('title')
|
||||||
@ -411,7 +331,43 @@ class JobPostingForm(forms.ModelForm):
|
|||||||
description=self.cleaned_data.get('description')
|
description=self.cleaned_data.get('description')
|
||||||
if not description or len(description.strip())<20:
|
if not description or len(description.strip())<20:
|
||||||
raise forms.ValidationError("Job description must be at least 20 characters long.")
|
raise forms.ValidationError("Job description must be at least 20 characters long.")
|
||||||
return description.strip()
|
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 JobPostingImageForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -459,6 +415,90 @@ class FormTemplateForm(forms.ModelForm):
|
|||||||
Field('is_active', css_class='form-check-input'),
|
Field('is_active', css_class='form-check-input'),
|
||||||
Submit('submit', _('Create Template'), css_class='btn btn-primary mt-3')
|
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):
|
class BreakTimeForm(forms.Form):
|
||||||
"""
|
"""
|
||||||
@ -475,8 +515,10 @@ class BreakTimeForm(forms.Form):
|
|||||||
label="End Time"
|
label="End Time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Use the non-model form for the formset factory
|
||||||
BreakTimeFormSet = formset_factory(BreakTimeForm, extra=1, can_delete=True)
|
BreakTimeFormSet = formset_factory(BreakTimeForm, extra=1, can_delete=True)
|
||||||
|
|
||||||
|
# --- InterviewScheduleForm remains unchanged ---
|
||||||
class InterviewScheduleForm(forms.ModelForm):
|
class InterviewScheduleForm(forms.ModelForm):
|
||||||
candidates = forms.ModelMultipleChoiceField(
|
candidates = forms.ModelMultipleChoiceField(
|
||||||
queryset=Candidate.objects.none(),
|
queryset=Candidate.objects.none(),
|
||||||
@ -512,6 +554,7 @@ class InterviewScheduleForm(forms.ModelForm):
|
|||||||
|
|
||||||
def __init__(self, slug, *args, **kwargs):
|
def __init__(self, slug, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
# Filter candidates based on the selected job
|
||||||
self.fields['candidates'].queryset = Candidate.objects.filter(
|
self.fields['candidates'].queryset = Candidate.objects.filter(
|
||||||
job__slug=slug,
|
job__slug=slug,
|
||||||
stage='Interview'
|
stage='Interview'
|
||||||
@ -519,6 +562,7 @@ class InterviewScheduleForm(forms.ModelForm):
|
|||||||
|
|
||||||
def clean_working_days(self):
|
def clean_working_days(self):
|
||||||
working_days = self.cleaned_data.get('working_days')
|
working_days = self.cleaned_data.get('working_days')
|
||||||
|
# Convert string values to integers
|
||||||
return [int(day) for day in working_days]
|
return [int(day) for day in working_days]
|
||||||
|
|
||||||
class MeetingCommentForm(forms.ModelForm):
|
class MeetingCommentForm(forms.ModelForm):
|
||||||
@ -548,7 +592,26 @@ class MeetingCommentForm(forms.ModelForm):
|
|||||||
Submit('submit', _('Add Comment'), css_class='btn btn-primary mt-3')
|
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 InterviewForm(forms.ModelForm):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ScheduledInterview
|
model = ScheduledInterview
|
||||||
fields = ['job','candidate']
|
fields = ['job','candidate']
|
||||||
@ -558,6 +621,36 @@ class ProfileImageUploadForm(forms.ModelForm):
|
|||||||
model=Profile
|
model=Profile
|
||||||
fields=['profile_image']
|
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):
|
class StaffUserCreationForm(UserCreationForm):
|
||||||
email = forms.EmailField(required=True)
|
email = forms.EmailField(required=True)
|
||||||
first_name = forms.CharField(max_length=30, required=True)
|
first_name = forms.CharField(max_length=30, required=True)
|
||||||
@ -591,37 +684,15 @@ class StaffUserCreationForm(UserCreationForm):
|
|||||||
user.email = self.cleaned_data["email"]
|
user.email = self.cleaned_data["email"]
|
||||||
user.first_name = self.cleaned_data["first_name"]
|
user.first_name = self.cleaned_data["first_name"]
|
||||||
user.last_name = self.cleaned_data["last_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
|
user.is_staff = True
|
||||||
if commit:
|
if commit:
|
||||||
user.save()
|
user.save()
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class ToggleAccountForm(forms.Form):
|
class ToggleAccountForm(forms.Form):
|
||||||
pass
|
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'}),
|
|
||||||
}
|
|
||||||
|
|||||||
@ -2,7 +2,6 @@ from django.urls import path
|
|||||||
from . import views_frontend
|
from . import views_frontend
|
||||||
from . import views
|
from . import views
|
||||||
from . import views_integration
|
from . import views_integration
|
||||||
from . import views_source
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('', views_frontend.dashboard_view, name='dashboard'),
|
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/<int:pk>/', views_source.SourceDetailView.as_view(), name='source_detail'),
|
|
||||||
path('sources/<int:pk>/update/', views_source.SourceUpdateView.as_view(), name='source_update'),
|
|
||||||
path('sources/<int:pk>/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
|
# Meeting Comments URLs
|
||||||
path('meetings/<slug:slug>/comments/add/', views.add_meeting_comment, name='add_meeting_comment'),
|
path('meetings/<slug:slug>/comments/add/', views.add_meeting_comment, name='add_meeting_comment'),
|
||||||
|
|||||||
@ -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)
|
|
||||||
@ -44,7 +44,7 @@
|
|||||||
<div class="en text-xs">King Abdullah bin Abdulaziz University Hospital</div>
|
<div class="en text-xs">King Abdullah bin Abdulaziz University Hospital</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<img src="{% static 'image/hospital_logo.png' %}" alt="KAAUH Logo" style="max-height: 40px; max-width: 40px;">
|
<img src="{% static 'image/kaauh.png' %}" alt="KAAUH Logo" style="max-height: 40px; max-width: 40px;">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -55,11 +55,11 @@
|
|||||||
|
|
||||||
{# --- MOBILE BRAND LOGIC: Show small logo on mobile, large on desktop (lg) --- #}
|
{# --- MOBILE BRAND LOGIC: Show small logo on mobile, large on desktop (lg) --- #}
|
||||||
<a class="navbar-brand text-white d-block d-lg-none" href="{% url 'dashboard' %}" aria-label="Home">
|
<a class="navbar-brand text-white d-block d-lg-none" href="{% url 'dashboard' %}" aria-label="Home">
|
||||||
<img src="{% static 'image/hospital_logo_1.png' %}" alt="{% trans 'kaauh logo green bg' %}" class="navbar-brand-mobile">
|
<img src="{% static 'image/kaauh_green1.png' %}" alt="{% trans 'kaauh logo green bg' %}" class="navbar-brand-mobile">
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a class="navbar-brand text-white d-none d-lg-block me-4 pe-4" href="{% url 'dashboard' %}" aria-label="Home">
|
<a class="navbar-brand text-white d-none d-lg-block me-4 pe-4" href="{% url 'dashboard' %}" aria-label="Home">
|
||||||
<img src="{% static 'image/hospital_logo_1.png' %}" alt="{% trans 'kaauh logo green bg' %}" style="width: 60px; height: 60px;">
|
<img src="{% static 'image/kaauh_green1.png' %}" alt="{% trans 'kaauh logo green bg' %}" style="width: 60px; height: 60px;">
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{# Toggler: order-lg-0 ensures it's before navigation links on desktop, but it stays where it is on mobile #}
|
{# Toggler: order-lg-0 ensures it's before navigation links on desktop, but it stays where it is on mobile #}
|
||||||
@ -216,21 +216,13 @@
|
|||||||
<li class="nav-item me-lg-4">
|
<li class="nav-item me-lg-4">
|
||||||
<a class="nav-link {% if request.resolver_match.url_name == 'list_meetings' %}active{% endif %}" href="{% url 'list_meetings' %}">
|
<a class="nav-link {% if request.resolver_match.url_name == 'list_meetings' %}active{% endif %}" href="{% url 'list_meetings' %}">
|
||||||
<span class="d-flex align-items-center gap-2">
|
<span class="d-flex align-items-center gap-2">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z" />
|
||||||
</svg>
|
</svg>
|
||||||
{% trans "Meetings" %}
|
{% trans "Meetings" %}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item me-lg-4">
|
|
||||||
<a class="nav-link {% if request.resolver_match.url_name == 'source_list' %}active{% endif %}" href="{% url 'source_list' %}">
|
|
||||||
<span class="d-flex align-items-center gap-2">
|
|
||||||
{% include "icons/sources.html" %}
|
|
||||||
{% trans "Sources" %}
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{% comment %} <li class="nav-item me-lg-4">
|
{% comment %} <li class="nav-item me-lg-4">
|
||||||
<a class="nav-link {% if request.resolver_match.url_name == 'training_list' %}active{% endif %}" href="{% url 'training_list' %}">
|
<a class="nav-link {% if request.resolver_match.url_name == 'training_list' %}active{% endif %}" href="{% url 'training_list' %}">
|
||||||
<span class="d-flex align-items-center gap-2">
|
<span class="d-flex align-items-center gap-2">
|
||||||
@ -336,4 +328,4 @@
|
|||||||
{% block customJS %}{% endblock %}
|
{% block customJS %}{% endblock %}
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@ -1,4 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 14.25h13.5a.75.75 0 0 1 0 1.5H5.25a.75.75 0 0 1 0-1.5zM5.25 17.25h13.5a.75.75 0 0 1 0 1.5H5.25a.75.75 0 0 1 0-1.5zM5.25 11.25h13.5a.75.75 0 0 1 0 1.5H5.25a.75.75 0 0 1 0-1.5zM3.75 6.75h16.5a.75.75 0 0 1 0 1.5H3.75a.75.75 0 0 1 0-1.5z" />
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 2.25h15a2.25 2.25 0 0 1 2.25 2.25v15a2.25 2.25 0 0 1-2.25 2.25h-15A2.25 2.25 0 0 0 2.25 19.5v-15A2.25 2.25 0 0 1 4.5 2.25z" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 628 B |
@ -1,20 +0,0 @@
|
|||||||
<div class="toast-container position-fixed bottom-0 end-0 p-3">
|
|
||||||
<div id="copyToast" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
|
|
||||||
<div class="toast-header">
|
|
||||||
<i class="fas fa-check-circle text-success me-2"></i>
|
|
||||||
<strong class="me-auto">{% trans "Success" %}</strong>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
<div class="toast-body">
|
|
||||||
{% blocktrans with text=text %}Copied "{{ text }}" to clipboard!{% endblocktrans %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Show toast notification
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
const toast = new bootstrap.Toast(document.getElementById('copyToast'));
|
|
||||||
toast.show();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@ -65,6 +65,11 @@
|
|||||||
</th>
|
</th>
|
||||||
<th style="width: 15%"><i class="fas fa-user me-1"></i> {% trans "Name" %}</th>
|
<th style="width: 15%"><i class="fas fa-user me-1"></i> {% trans "Name" %}</th>
|
||||||
<th style="width: 15%"><i class="fas fa-phone me-1"></i> {% trans "Contact" %}</th>
|
<th style="width: 15%"><i class="fas fa-phone me-1"></i> {% trans "Contact" %}</th>
|
||||||
|
<th style="width: 15%"><i class="fas fa-tag me-1"></i> {% trans "Topic" %}</th>
|
||||||
|
<th style="width: 10%"><i class="fas fa-clock me-1"></i> {% trans "Duration" %}</th>
|
||||||
|
<th style="width: 15%"><i class="fas fa-calendar me-1"></i> {% trans "Meeting Date" %}</th>
|
||||||
|
<th style="width: 10%"><i class="fas fa-video me-1"></i> {% trans "Meeting Link" %}</th>
|
||||||
|
<th style="width: 10%"><i class="fas fa-check-circle me-1"></i> {% trans "Meeting Status" %}</th>
|
||||||
<th style="width: 10%"><i class="fas fa-check-circle me-1"></i> {% trans "Offer" %}</th>
|
<th style="width: 10%"><i class="fas fa-check-circle me-1"></i> {% trans "Offer" %}</th>
|
||||||
<th style="width: 15%"><i class="fas fa-cog me-1"></i> {% trans "Actions" %}</th>
|
<th style="width: 15%"><i class="fas fa-cog me-1"></i> {% trans "Actions" %}</th>
|
||||||
</tr>
|
</tr>
|
||||||
@ -88,6 +93,57 @@
|
|||||||
<i class="fas fa-phone me-1"></i> {{ candidate.phone }}
|
<i class="fas fa-phone me-1"></i> {{ candidate.phone }}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td class="candidate-details text-muted">
|
||||||
|
{% if candidate.get_latest_meeting.topic %}
|
||||||
|
{{ candidate.get_latest_meeting.topic }}
|
||||||
|
{% else %}
|
||||||
|
--
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="candidate-details text-muted"><div class="d-block">
|
||||||
|
{% if candidate.get_latest_meeting.duration %}
|
||||||
|
{{ candidate.get_latest_meeting.duration }} {% trans _("Minutes") %}
|
||||||
|
{% else %}
|
||||||
|
--
|
||||||
|
{% endif %}
|
||||||
|
</div></td>
|
||||||
|
<td class="candidate-details text-muted">
|
||||||
|
{% with latest_meeting=candidate.get_latest_meeting %}
|
||||||
|
{% if latest_meeting %}
|
||||||
|
{{ latest_meeting.start_time|date:"d-m-Y h:i A" }}
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">--</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% with latest_meeting=candidate.get_latest_meeting %}
|
||||||
|
{% if latest_meeting and latest_meeting.join_url %}
|
||||||
|
<a href="{{ latest_meeting.join_url }}" target="_blank" class="btn btn-sm bg-primary-theme text-white" title="Join Interview"
|
||||||
|
{% if latest_meeting.status == 'ended' %}disabled{% endif %}>
|
||||||
|
click to join
|
||||||
|
<i class="fas fa-video me-1"></i>
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">--</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ latest_meeting.status }}
|
||||||
|
{% with latest_meeting=candidate.get_latest_meeting %}
|
||||||
|
{% if latest_meeting %}
|
||||||
|
<span class="badge {% if latest_meeting.status == 'waiting' %}bg-warning{% elif latest_meeting.status == 'started' %}bg-success{% elif latest_meeting.status == 'ended' %}bg-danger{% endif %}">
|
||||||
|
{% if latest_meeting.status == 'started' %}
|
||||||
|
<i class="fas fa-circle me-1 text-success"></i>
|
||||||
|
{% endif %}
|
||||||
|
{{ latest_meeting.status|title }}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">--</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
</td>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
{% if not candidate.offer_status %}
|
{% if not candidate.offer_status %}
|
||||||
<button type="button" class="btn btn-warning btn-sm"
|
<button type="button" class="btn btn-warning btn-sm"
|
||||||
|
|||||||
@ -1,128 +0,0 @@
|
|||||||
{% extends 'base.html' %}
|
|
||||||
{% load i18n %}
|
|
||||||
|
|
||||||
{% block title %}
|
|
||||||
{% trans "Delete Source" %} | {% trans "Recruitment System" %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="container-fluid py-4">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="card shadow">
|
|
||||||
<div class="card-header py-3">
|
|
||||||
<h6 class="m-0 font-weight-bold text-danger">
|
|
||||||
<i class="fas fa-exclamation-triangle me-2"></i>
|
|
||||||
{% trans "Delete Source" %}
|
|
||||||
</h6>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="alert alert-warning">
|
|
||||||
<h5 class="alert-heading">{% trans "Confirm Deletion" %}</h5>
|
|
||||||
<p>{% trans "Are you sure you want to delete the following source? This action cannot be undone." %}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Source Information -->
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-12">
|
|
||||||
<h5>{% trans "Source Information" %}</h5>
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-borderless">
|
|
||||||
<tr>
|
|
||||||
<th width="30%">{% trans "Name" %}</th>
|
|
||||||
<td>{{ source.name }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th>{% trans "Type" %}</th>
|
|
||||||
<td>
|
|
||||||
<span class="badge bg-info">{{ source.source_type }}</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th>{% trans "Status" %}</th>
|
|
||||||
<td>
|
|
||||||
{% if source.is_active %}
|
|
||||||
<span class="badge bg-success">
|
|
||||||
<i class="fas fa-check-circle me-1"></i>
|
|
||||||
{% trans "Active" %}
|
|
||||||
</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="badge bg-danger">
|
|
||||||
<i class="fas fa-times-circle me-1"></i>
|
|
||||||
{% trans "Inactive" %}
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th>{% trans "Created By" %}</th>
|
|
||||||
<td>{{ source.created_by }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th>{% trans "Created At" %}</th>
|
|
||||||
<td>{{ source.created_at|date:"M d, Y H:i" }}</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Warning Messages -->
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="alert alert-danger">
|
|
||||||
<h5 class="alert-heading">
|
|
||||||
<i class="fas fa-exclamation-circle me-2"></i>
|
|
||||||
{% trans "Important Note" %}
|
|
||||||
</h5>
|
|
||||||
<ul>
|
|
||||||
<li>{% trans "All associated API keys will be permanently deleted." %}</li>
|
|
||||||
<li>{% trans "Integration logs related to this source will remain but will show 'Source deleted'." %}</li>
|
|
||||||
<li>{% trans "Any active integrations using this source will be disconnected." %}</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Action Buttons -->
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="d-flex justify-content-between">
|
|
||||||
<div>
|
|
||||||
<a href="{% url 'source_detail' source.pk %}" class="btn btn-secondary">
|
|
||||||
<i class="fas fa-arrow-left me-2"></i>
|
|
||||||
{% trans "Cancel" %}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<form method="post" action="">
|
|
||||||
{% csrf_token %}
|
|
||||||
<button type="submit" class="btn btn-danger">
|
|
||||||
<i class="fas fa-trash me-2"></i>
|
|
||||||
{% trans "Delete Source" %}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_js %}
|
|
||||||
<script>
|
|
||||||
// Add confirmation dialog
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
const deleteForm = document.querySelector('form[action*="delete"]');
|
|
||||||
if (deleteForm) {
|
|
||||||
deleteForm.addEventListener('submit', function(e) {
|
|
||||||
if (!confirm('{% trans "Are you sure you want to delete this source? This action cannot be undone." %}')) {
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@ -1,285 +0,0 @@
|
|||||||
{% extends 'base.html' %}
|
|
||||||
{% load i18n %}
|
|
||||||
|
|
||||||
{% block title %}
|
|
||||||
{{ source.name }} | {% trans "Source Details" %} | {% trans "Recruitment System" %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="container-fluid py-4">
|
|
||||||
<!-- Page Header -->
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
|
||||||
<div>
|
|
||||||
<h1 class="h3 mb-1">{{ source.name }}</h1>
|
|
||||||
<nav aria-label="breadcrumb">
|
|
||||||
<ol class="breadcrumb">
|
|
||||||
<li class="breadcrumb-item">
|
|
||||||
<a href="{% url 'source_list' %}">{% trans "Sources" %}</a>
|
|
||||||
</li>
|
|
||||||
<li class="breadcrumb-item active" aria-current="page">{{ source.name }}</li>
|
|
||||||
</ol>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
<div class="btn-group">
|
|
||||||
<a href="{% url 'source_update' source.pk %}" class="btn btn-primary">
|
|
||||||
<i class="fas fa-edit me-2"></i>
|
|
||||||
{% trans "Edit" %}
|
|
||||||
</a>
|
|
||||||
<a href="{% url 'source_delete' source.pk %}" class="btn btn-danger">
|
|
||||||
<i class="fas fa-trash me-2"></i>
|
|
||||||
{% trans "Delete" %}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Source Information Card -->
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="card shadow">
|
|
||||||
<div class="card-header py-3">
|
|
||||||
<h6 class="m-0 font-weight-bold text-primary">
|
|
||||||
<i class="fas fa-info-circle me-2"></i>
|
|
||||||
{% trans "Source Information" %}
|
|
||||||
</h6>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<table class="table table-borderless">
|
|
||||||
<tr>
|
|
||||||
<th width="30%">{% trans "Name" %}</th>
|
|
||||||
<td>{{ source.name }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th>{% trans "Type" %}</th>
|
|
||||||
<td>
|
|
||||||
<span class="badge bg-info">{{ source.source_type }}</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th>{% trans "Status" %}</th>
|
|
||||||
<td>
|
|
||||||
{% if source.is_active %}
|
|
||||||
<span class="badge bg-success">
|
|
||||||
<i class="fas fa-check-circle me-1"></i>
|
|
||||||
{% trans "Active" %}
|
|
||||||
</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="badge bg-danger">
|
|
||||||
<i class="fas fa-times-circle me-1"></i>
|
|
||||||
{% trans "Inactive" %}
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<table class="table table-borderless">
|
|
||||||
<tr>
|
|
||||||
<th width="30%">{% trans "Created By" %}</th>
|
|
||||||
<td>{{ source.created_by }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th>{% trans "Created At" %}</th>
|
|
||||||
<td>{{ source.created_at|date:"M d, Y H:i" }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th>{% trans "Updated At" %}</th>
|
|
||||||
<td>{{ source.updated_at|date:"M d, Y H:i" }}</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row mt-3">
|
|
||||||
<div class="col-12">
|
|
||||||
<h6>{% trans "Description" %}</h6>
|
|
||||||
<p class="text-muted">{{ source.description|default:"-" }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<!-- Network Configuration -->
|
|
||||||
<div class="col-lg-6 mb-4">
|
|
||||||
<div class="card shadow">
|
|
||||||
<div class="card-header py-3">
|
|
||||||
<h6 class="m-0 font-weight-bold text-primary">
|
|
||||||
<i class="fas fa-network-wired me-2"></i>
|
|
||||||
{% trans "Network Configuration" %}
|
|
||||||
</h6>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="mb-3">
|
|
||||||
<strong>{% trans "IP Address" %}:</strong>
|
|
||||||
<code>{{ source.ip_address|default:"Not specified" }}</code>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<strong>{% trans "Trusted IPs" %}:</strong>
|
|
||||||
<div class="mt-2">
|
|
||||||
{% if source.trusted_ips %}
|
|
||||||
<div class="d-flex flex-wrap gap-2">
|
|
||||||
{% for ip in source.trusted_ips|split:"," %}
|
|
||||||
<span class="badge bg-secondary">{{ ip|strip }}</span>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<span class="text-muted">Not specified</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- API Configuration -->
|
|
||||||
<div class="col-lg-6 mb-4">
|
|
||||||
<div class="card shadow">
|
|
||||||
<div class="card-header py-3">
|
|
||||||
<h6 class="m-0 font-weight-bold text-primary">
|
|
||||||
<i class="fas fa-key me-2"></i>
|
|
||||||
{% trans "API Configuration" %}
|
|
||||||
</h6>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="mb-3">
|
|
||||||
<strong>{% trans "Integration Version" %}:</strong>
|
|
||||||
<span class="badge bg-primary">{{ source.integration_version|default:"Not specified" }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<strong>{% trans "API Key" %}:</strong>
|
|
||||||
<div class="input-group mt-2">
|
|
||||||
<input type="text" class="form-control" id="apiKey" value="{{ masked_api_key }}" readonly>
|
|
||||||
<button class="btn btn-outline-secondary" type="button"
|
|
||||||
onclick="copyToClipboard('apiKey')">
|
|
||||||
<i class="fas fa-copy"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<strong>{% trans "API Secret" %}:</strong>
|
|
||||||
<div class="input-group mt-2">
|
|
||||||
<input type="text" class="form-control" id="apiSecret" value="{{ masked_api_secret }}" readonly>
|
|
||||||
<button class="btn btn-outline-secondary" type="button"
|
|
||||||
onclick="copyToClipboard('apiSecret')">
|
|
||||||
<i class="fas fa-copy"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Integration Logs -->
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="card shadow">
|
|
||||||
<div class="card-header py-3 d-flex justify-content-between align-items-center">
|
|
||||||
<h6 class="m-0 font-weight-bold text-primary">
|
|
||||||
<i class="fas fa-history me-2"></i>
|
|
||||||
{% trans "Recent Integration Logs" %}
|
|
||||||
</h6>
|
|
||||||
<a href="{% url 'source_list' %}" class="btn btn-sm btn-secondary">
|
|
||||||
<i class="fas fa-arrow-left me-1"></i>
|
|
||||||
{% trans "Back to List" %}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
{% if recent_logs %}
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-bordered">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>{% trans "Time" %}</th>
|
|
||||||
<th>{% trans "Action" %}</th>
|
|
||||||
<th>{% trans "Endpoint" %}</th>
|
|
||||||
<th>{% trans "Method" %}</th>
|
|
||||||
<th>{% trans "Status" %}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for log in recent_logs %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ log.created_at|date:"M d, Y H:i:s" }}</td>
|
|
||||||
<td>
|
|
||||||
<span class="badge bg-info">{{ log.get_action_display }}</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<code class="text-muted">{{ log.endpoint }}</code>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span class="badge bg-secondary">{{ log.method }}</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{% if log.success %}
|
|
||||||
<span class="badge bg-success">Success</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="badge bg-danger">Failed</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{% if recent_logs.has_previous %}
|
|
||||||
<div class="text-center mt-3">
|
|
||||||
<a href="?page={{ recent_logs.previous_page_number }}" class="btn btn-sm btn-secondary">
|
|
||||||
<i class="fas fa-chevron-left"></i> {% trans "Previous" %}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% else %}
|
|
||||||
<div class="text-center py-5">
|
|
||||||
<i class="fas fa-inbox fa-3x text-muted mb-3"></i>
|
|
||||||
<h5 class="text-muted">{% trans "No integration logs found" %}</h5>
|
|
||||||
<p class="text-muted">
|
|
||||||
{% trans "Integration logs will appear here when this source is used for external integrations." %}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_js %}
|
|
||||||
<script>
|
|
||||||
// Make function available globally
|
|
||||||
window.copyToClipboard = function(elementId) {
|
|
||||||
const element = document.getElementById(elementId);
|
|
||||||
if (element) {
|
|
||||||
const text = element.value;
|
|
||||||
navigator.clipboard.writeText(text).then(function() {
|
|
||||||
// Show success message
|
|
||||||
const button = event.target.closest('button');
|
|
||||||
const originalContent = button.innerHTML;
|
|
||||||
button.innerHTML = '<i class="fas fa-check"></i>';
|
|
||||||
button.classList.add('btn-success');
|
|
||||||
button.classList.remove('btn-outline-secondary');
|
|
||||||
|
|
||||||
setTimeout(function() {
|
|
||||||
button.innerHTML = originalContent;
|
|
||||||
button.classList.remove('btn-success');
|
|
||||||
button.classList.add('btn-outline-secondary');
|
|
||||||
}, 2000);
|
|
||||||
}).catch(function(err) {
|
|
||||||
console.error('Failed to copy text: ', err);
|
|
||||||
alert('{% trans "Failed to copy to clipboard" %}');
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
console.error('Element not found:', elementId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@ -1,376 +0,0 @@
|
|||||||
{% extends 'base.html' %}
|
|
||||||
{% load i18n %}
|
|
||||||
|
|
||||||
{% block title %}
|
|
||||||
{% if title %}{{ title }} | {% endif %}{% trans "Source" %} | {% trans "Recruitment System" %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
|
|
||||||
<!-- Script to define functions globally before buttons are rendered -->
|
|
||||||
<script>
|
|
||||||
function copyToClipboard(elementId) {
|
|
||||||
const element = document.getElementById(elementId);
|
|
||||||
if (element) {
|
|
||||||
const text = element.value;
|
|
||||||
navigator.clipboard.writeText(text).then(function() {
|
|
||||||
const button = event.target.closest('button');
|
|
||||||
if (button) {
|
|
||||||
const orig = button.innerHTML;
|
|
||||||
button.innerHTML = '<i class="fas fa-check"></i>';
|
|
||||||
button.classList.add('btn-success');
|
|
||||||
button.classList.remove('btn-outline-secondary');
|
|
||||||
setTimeout(function() {
|
|
||||||
button.innerHTML = orig;
|
|
||||||
button.classList.remove('btn-success');
|
|
||||||
button.classList.add('btn-outline-secondary');
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
}).catch(function(err) {
|
|
||||||
console.error('Failed to copy text: ', err);
|
|
||||||
alert('{% trans "Failed to copy to clipboard" %}');
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
console.error('Element not found:', elementId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateRandomKey(elementId, length) {
|
|
||||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
|
|
||||||
let result = '';
|
|
||||||
for (let i = 0; i < length; i++) {
|
|
||||||
result += alphabet.charAt(Math.floor(Math.random() * alphabet.length));
|
|
||||||
}
|
|
||||||
|
|
||||||
const element = document.getElementById(elementId);
|
|
||||||
if (element) {
|
|
||||||
element.value = result;
|
|
||||||
|
|
||||||
const button = event.target.closest('button');
|
|
||||||
if (button) {
|
|
||||||
const orig = button.innerHTML;
|
|
||||||
button.innerHTML = '<i class="fas fa-check"></i>';
|
|
||||||
button.classList.add('btn-success');
|
|
||||||
button.classList.remove('btn-outline-secondary');
|
|
||||||
setTimeout(function() {
|
|
||||||
button.innerHTML = orig;
|
|
||||||
button.classList.remove('btn-success');
|
|
||||||
button.classList.add('btn-outline-secondary');
|
|
||||||
}, 1500);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.error('Element not found:', elementId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make functions globally available
|
|
||||||
window.copyToClipboard = copyToClipboard;
|
|
||||||
window.generateRandomKey = generateRandomKey;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="container-fluid py-4">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="card shadow">
|
|
||||||
<div class="card-header py-3">
|
|
||||||
<h6 class="m-0 font-weight-bold text-primary">
|
|
||||||
{% if title %}{{ title }}{% else %}{% trans "Create New Source" %}{% endif %}
|
|
||||||
</h6>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<form method="post" id="sourceForm">
|
|
||||||
{% csrf_token %}
|
|
||||||
|
|
||||||
<!-- Form Messages -->
|
|
||||||
{% if form.errors %}
|
|
||||||
<div class="alert alert-danger">
|
|
||||||
<h5 class="alert-heading">{% trans "Please correct the errors below:" %}</h5>
|
|
||||||
{% for field in form %}
|
|
||||||
{% if field.errors %}
|
|
||||||
<p class="mb-0">{{ field.label }}: {{ field.errors|join:", " }}</p>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if messages %}
|
|
||||||
{% for message in messages %}
|
|
||||||
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
|
|
||||||
{{ message }}
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Basic Information -->
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-12">
|
|
||||||
<h5 class="mb-3">{% trans "Basic Information" %}</h5>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
{{ form.name.label_tag }}
|
|
||||||
{{ form.name }}
|
|
||||||
{% if form.name.help_text %}
|
|
||||||
<small class="form-text text-muted">{{ form.name.help_text }}</small>
|
|
||||||
{% endif %}
|
|
||||||
{% if form.name.errors %}
|
|
||||||
<div class="invalid-feedback d-block">{{ form.name.errors }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
{{ form.source_type.label_tag }}
|
|
||||||
{{ form.source_type }}
|
|
||||||
{% if form.source_type.help_text %}
|
|
||||||
<small class="form-text text-muted">{{ form.source_type.help_text }}</small>
|
|
||||||
{% endif %}
|
|
||||||
{% if form.source_type.errors %}
|
|
||||||
<div class="invalid-feedback d-block">{{ form.source_type.errors }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-12 mb-3">
|
|
||||||
{{ form.description.label_tag }}
|
|
||||||
{{ form.description }}
|
|
||||||
{% if form.description.help_text %}
|
|
||||||
<small class="form-text text-muted">{{ form.description.help_text }}</small>
|
|
||||||
{% endif %}
|
|
||||||
{% if form.description.errors %}
|
|
||||||
<div class="invalid-feedback d-block">{{ form.description.errors }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Network Configuration -->
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-12">
|
|
||||||
<h5 class="mb-3">{% trans "Network Configuration" %}</h5>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
{{ form.ip_address.label_tag }}
|
|
||||||
{{ form.ip_address }}
|
|
||||||
{% if form.ip_address.help_text %}
|
|
||||||
<small class="form-text text-muted">{{ form.ip_address.help_text }}</small>
|
|
||||||
{% endif %}
|
|
||||||
{% if form.ip_address.errors %}
|
|
||||||
<div class="invalid-feedback d-block">{{ form.ip_address.errors }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
{{ form.trusted_ips.label_tag }}
|
|
||||||
{{ form.trusted_ips }}
|
|
||||||
{% if form.trusted_ips.help_text %}
|
|
||||||
<small class="form-text text-muted">{{ form.trusted_ips.help_text }}</small>
|
|
||||||
{% endif %}
|
|
||||||
{% if form.trusted_ips.errors %}
|
|
||||||
<div class="invalid-feedback d-block">{{ form.trusted_ips.errors }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Settings -->
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-12">
|
|
||||||
<h5 class="mb-3">{% trans "Settings" %}</h5>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
{{ form.integration_version.label_tag }}
|
|
||||||
{{ form.integration_version }}
|
|
||||||
{% if form.integration_version.help_text %}
|
|
||||||
<small class="form-text text-muted">{{ form.integration_version.help_text }}</small>
|
|
||||||
{% endif %}
|
|
||||||
{% if form.integration_version.errors %}
|
|
||||||
<div class="invalid-feedback d-block">{{ form.integration_version.errors }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
<div class="form-check form-switch">
|
|
||||||
{{ form.is_active }}
|
|
||||||
{{ form.is_active.label_tag }}
|
|
||||||
</div>
|
|
||||||
{% if form.is_active.help_text %}
|
|
||||||
<small class="form-text text-muted">{{ form.is_active.help_text }}</small>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- API Configuration -->
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-12">
|
|
||||||
<h5 class="mb-3">{% trans "API Configuration" %}</h5>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-12 mb-3">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="row align-items-center">
|
|
||||||
<div class="col-md-8">
|
|
||||||
<h6 class="mb-0">{% trans "API Keys" %}</h6>
|
|
||||||
<small class="text-muted">{% trans "Generate secure API keys for external integrations" %}</small>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4 text-end">
|
|
||||||
<div class="form-check form-switch">
|
|
||||||
{{ form.generate_keys }}
|
|
||||||
{{ form.generate_keys.label_tag }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Generated API Key -->
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
<label class="form-label">{% trans "API Key" %}</label>
|
|
||||||
<div class="input-group">
|
|
||||||
{{ form.api_key_generated }}
|
|
||||||
<button class="btn btn-outline-secondary" type="button"
|
|
||||||
id="generateApiKey"
|
|
||||||
onclick="generateRandomKey('id_api_key_generated', 32)"
|
|
||||||
title="{% trans 'Generate random API key' %}">
|
|
||||||
<i class="fas fa-sync-alt"></i>
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-outline-secondary" type="button"
|
|
||||||
id="copyApiKey"
|
|
||||||
onclick="copyToClipboard('id_api_key_generated')"
|
|
||||||
title="{% trans 'Copy to clipboard' %}">
|
|
||||||
<i class="fas fa-copy"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{% if form.api_key_generated.errors %}
|
|
||||||
<div class="invalid-feedback d-block">{{ form.api_key_generated.errors }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Generated API Secret -->
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
<label class="form-label">{% trans "API Secret" %}</label>
|
|
||||||
<div class="input-group">
|
|
||||||
{{ form.api_secret_generated }}
|
|
||||||
<button class="btn btn-outline-secondary" type="button"
|
|
||||||
id="generateApiSecret"
|
|
||||||
onclick="generateRandomKey('id_api_secret_generated', 64)"
|
|
||||||
title="{% trans 'Generate random API secret' %}">
|
|
||||||
<i class="fas fa-sync-alt"></i>
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-outline-secondary" type="button"
|
|
||||||
id="copyApiSecret"
|
|
||||||
onclick="copyToClipboard('id_api_secret_generated')"
|
|
||||||
title="{% trans 'Copy to clipboard' %}">
|
|
||||||
<i class="fas fa-copy"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{% if form.api_secret_generated.errors %}
|
|
||||||
<div class="invalid-feedback d-block">{{ form.api_secret_generated.errors }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Form Actions -->
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12">
|
|
||||||
<hr>
|
|
||||||
<div class="d-flex justify-content-between">
|
|
||||||
<a href="{% url 'source_list' %}" class="btn btn-secondary">
|
|
||||||
<i class="fas fa-times me-2"></i>
|
|
||||||
{% trans "Cancel" %}
|
|
||||||
</a>
|
|
||||||
<div>
|
|
||||||
<button type="submit" class="btn btn-primary">
|
|
||||||
<i class="fas fa-save me-2"></i>
|
|
||||||
{% trans "Save Source" %}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_js %}
|
|
||||||
<script>
|
|
||||||
// Function to copy text to clipboard
|
|
||||||
function copyToClipboard(elementId) {
|
|
||||||
const element = document.getElementById(elementId);
|
|
||||||
if (element) {
|
|
||||||
const text = element.value;
|
|
||||||
navigator.clipboard.writeText(text).then(function() {
|
|
||||||
// Show success message
|
|
||||||
const button = event.target.closest('button');
|
|
||||||
if (button) {
|
|
||||||
const originalContent = button.innerHTML;
|
|
||||||
button.innerHTML = '<i class="fas fa-check"></i>';
|
|
||||||
button.classList.add('btn-success');
|
|
||||||
button.classList.remove('btn-outline-secondary');
|
|
||||||
|
|
||||||
setTimeout(function() {
|
|
||||||
button.innerHTML = originalContent;
|
|
||||||
button.classList.remove('btn-success');
|
|
||||||
button.classList.add('btn-outline-secondary');
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
}).catch(function(err) {
|
|
||||||
console.error('Failed to copy text: ', err);
|
|
||||||
alert('{% trans "Failed to copy to clipboard" %}');
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
console.error('Element not found:', elementId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to generate random key
|
|
||||||
function generateRandomKey(elementId, length) {
|
|
||||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
|
|
||||||
let result = '';
|
|
||||||
for (let i = 0; i < length; i++) {
|
|
||||||
result += alphabet.charAt(Math.floor(Math.random() * alphabet.length));
|
|
||||||
}
|
|
||||||
console.log(elementId);
|
|
||||||
const element = document.getElementById(elementId);
|
|
||||||
if (element) {
|
|
||||||
element.value = result;
|
|
||||||
|
|
||||||
// Show success animation on the generate button
|
|
||||||
const button = event.target.closest('button');
|
|
||||||
if (button) {
|
|
||||||
const originalContent = button.innerHTML;
|
|
||||||
button.innerHTML = '<i class="fas fa-check"></i>';
|
|
||||||
button.classList.add('btn-success');
|
|
||||||
button.classList.remove('btn-outline-secondary');
|
|
||||||
|
|
||||||
setTimeout(function() {
|
|
||||||
button.innerHTML = originalContent;
|
|
||||||
button.classList.remove('btn-success');
|
|
||||||
button.classList.add('btn-outline-secondary');
|
|
||||||
}, 1500);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.error('Element not found:', elementId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize after DOM is fully loaded
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
const generateKeysCheckbox = document.getElementById('id_generate_keys');
|
|
||||||
if (generateKeysCheckbox) {
|
|
||||||
// If API keys are already generated, show them
|
|
||||||
const apiKeyField = document.getElementById('id_api_key_generated');
|
|
||||||
const apiSecretField = document.getElementById('id_api_secret_generated');
|
|
||||||
|
|
||||||
if (apiKeyField && apiSecretField && (apiKeyField.value || apiSecretField.value)) {
|
|
||||||
generateKeysCheckbox.checked = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@ -1,209 +0,0 @@
|
|||||||
{% extends 'base.html' %}
|
|
||||||
{% load i18n %}
|
|
||||||
|
|
||||||
{% block title %}{% trans "Sources" %} | {% trans "Recruitment System" %}{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="container-fluid py-4">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
||||||
<h1 class="h3 mb-0 text-gray-800">
|
|
||||||
<i class="fas fa-database me-2"></i>
|
|
||||||
{% trans "Data Sources" %}
|
|
||||||
</h1>
|
|
||||||
<a href="{% url 'source_create' %}" class="btn btn-primary">
|
|
||||||
<i class="fas fa-plus me-2"></i>
|
|
||||||
{% trans "Create New Source" %}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Search Bar -->
|
|
||||||
<div class="card shadow mb-4">
|
|
||||||
<div class="card-body">
|
|
||||||
<form method="get" class="row g-3">
|
|
||||||
<div class="col-md-10">
|
|
||||||
<div class="input-group">
|
|
||||||
<span class="input-group-text">
|
|
||||||
<i class="fas fa-search"></i>
|
|
||||||
</span>
|
|
||||||
<input type="text" name="search" class="form-control"
|
|
||||||
placeholder="{% trans 'Search by name, type, or description...' %}"
|
|
||||||
value="{{ search_query }}">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-2">
|
|
||||||
<button type="submit" class="btn btn-secondary w-100">
|
|
||||||
<i class="fas fa-filter me-2"></i>
|
|
||||||
{% trans "Filter" %}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Sources List -->
|
|
||||||
<div class="card shadow">
|
|
||||||
<div class="card-header py-3 d-flex justify-content-between align-items-center">
|
|
||||||
<h6 class="m-0 font-weight-bold text-primary">
|
|
||||||
{% trans "Available Sources" %}
|
|
||||||
</h6>
|
|
||||||
<span class="badge bg-secondary">
|
|
||||||
{{ page_obj.paginator.count }} {% trans "sources" %}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
{% if sources %}
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-bordered" id="dataTable" width="100%" cellspacing="0">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>{% trans "Name" %}</th>
|
|
||||||
<th>{% trans "Type" %}</th>
|
|
||||||
<th>{% trans "Status" %}</th>
|
|
||||||
<th>{% trans "API Key" %}</th>
|
|
||||||
<th>{% trans "Created By" %}</th>
|
|
||||||
<th>{% trans "Created At" %}</th>
|
|
||||||
<th>{% trans "Actions" %}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for source in sources %}
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="me-3">
|
|
||||||
<div class="icon-circle bg-primary text-white">
|
|
||||||
<i class="fas fa-server"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="fw-bold">{{ source.name }}</div>
|
|
||||||
<small class="text-muted">{{ source.description|truncatewords:10 }}</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span class="badge bg-info">{{ source.source_type }}</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{% if source.is_active %}
|
|
||||||
<span class="badge bg-success">
|
|
||||||
<i class="fas fa-check-circle me-1"></i>
|
|
||||||
{% trans "Active" %}
|
|
||||||
</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="badge bg-danger">
|
|
||||||
<i class="fas fa-times-circle me-1"></i>
|
|
||||||
{% trans "Inactive" %}
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{% if source.api_key %}
|
|
||||||
<code class="text-muted">{{ source.api_key|slice:":8" }}...</code>
|
|
||||||
{% else %}
|
|
||||||
<span class="text-muted">
|
|
||||||
<i class="fas fa-key me-1"></i>
|
|
||||||
{% trans "Not generated" %}
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>{{ source.created_by }}</td>
|
|
||||||
<td>{{ source.created_at|date:"M d, Y" }}</td>
|
|
||||||
<td>
|
|
||||||
<div class="btn-group" role="group">
|
|
||||||
<a href="{% url 'source_detail' source.pk %}"
|
|
||||||
class="btn btn-sm btn-outline-primary"
|
|
||||||
title="{% trans 'View Details' %}">
|
|
||||||
<i class="fas fa-eye"></i>
|
|
||||||
</a>
|
|
||||||
<a href="{% url 'source_update' source.pk %}"
|
|
||||||
class="btn btn-sm btn-outline-secondary"
|
|
||||||
title="{% trans 'Edit' %}">
|
|
||||||
<i class="fas fa-edit"></i>
|
|
||||||
</a>
|
|
||||||
<a href="{% url 'source_delete' source.pk %}"
|
|
||||||
class="btn btn-sm btn-outline-danger"
|
|
||||||
title="{% trans 'Delete' %}">
|
|
||||||
<i class="fas fa-trash"></i>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Pagination -->
|
|
||||||
{% if is_paginated %}
|
|
||||||
<nav aria-label="Page navigation">
|
|
||||||
<ul class="pagination justify-content-center">
|
|
||||||
{% if page_obj.has_previous %}
|
|
||||||
<li class="page-item">
|
|
||||||
<a class="page-link" href="?page=1{% if search_query %}&search={{ search_query }}{% endif %}"
|
|
||||||
aria-label="{% trans 'First' %}">
|
|
||||||
<span aria-hidden="true">««</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="page-item">
|
|
||||||
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if search_query %}&search={{ search_query }}{% endif %}"
|
|
||||||
aria-label="{% trans 'Previous' %}">
|
|
||||||
<span aria-hidden="true">«</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% for num in page_obj.paginator.page_range %}
|
|
||||||
{% if page_obj.number == num %}
|
|
||||||
<li class="page-item active">
|
|
||||||
<span class="page-link">{{ num }}</span>
|
|
||||||
</li>
|
|
||||||
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
|
|
||||||
<li class="page-item">
|
|
||||||
<a class="page-link" href="?page={{ num }}{% if search_query %}&search={{ search_query }}{% endif %}">{{ num }}</a>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
{% if page_obj.has_next %}
|
|
||||||
<li class="page-item">
|
|
||||||
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% if search_query %}&search={{ search_query }}{% endif %}"
|
|
||||||
aria-label="{% trans 'Next' %}">
|
|
||||||
<span aria-hidden="true">»</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="page-item">
|
|
||||||
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}{% if search_query %}&search={{ search_query }}{% endif %}"
|
|
||||||
aria-label="{% trans 'Last' %}">
|
|
||||||
<span aria-hidden="true">»»</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
{% endif %}
|
|
||||||
{% else %}
|
|
||||||
<div class="text-center py-5">
|
|
||||||
<i class="fas fa-inbox fa-3x text-muted mb-3"></i>
|
|
||||||
<h5 class="text-muted">{% trans "No sources found" %}</h5>
|
|
||||||
<p class="text-muted mb-4">
|
|
||||||
{% trans "Get started by creating your first data source." %}
|
|
||||||
</p>
|
|
||||||
<a href="{% url 'source_create' %}" class="btn btn-primary">
|
|
||||||
<i class="fas fa-plus me-2"></i>
|
|
||||||
{% trans "Create Source" %}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_js %}
|
|
||||||
<script>
|
|
||||||
$(document).ready(function() {
|
|
||||||
// Add any DataTables initialization or other JavaScript here
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user