Compare commits
7 Commits
f0d3218caa
...
1f3c38d687
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f3c38d687 | |||
| a1558a6b22 | |||
| f33cf97975 | |||
| fec156fab2 | |||
| e6daa39f1e | |||
| d97ad030f1 | |||
| 65c25a1a57 |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -22,7 +22,7 @@ urlpatterns = [
|
||||
# path('', include('recruitment.urls')),
|
||||
path("ckeditor5/", include('django_ckeditor_5.urls')),
|
||||
|
||||
path('form/<slug:template_slug>/', views.form_wizard_view, name='form_wizard'),
|
||||
path('form_wizard/<slug:template_slug>/', views.form_wizard_view, name='form_wizard'),
|
||||
path('form/<slug:template_slug>/submit/', views.submit_form, name='submit_form'),
|
||||
|
||||
path('api/templates/', views.list_form_templates, name='list_form_templates'),
|
||||
|
||||
@ -1426,178 +1426,3 @@ msgstr ""
|
||||
#: templates/unfold/components/table.html:43
|
||||
msgid "No data"
|
||||
msgstr ""
|
||||
|
||||
# Source Management
|
||||
msgid "Data Sources"
|
||||
msgstr "مصادر البيانات"
|
||||
|
||||
msgid "Create New Source"
|
||||
msgstr "إنشاء مصدر جديد"
|
||||
|
||||
msgid "Search by name, type, or description..."
|
||||
msgstr "البحث بالاسم، النوع، أو الوصف..."
|
||||
|
||||
msgid "Filter"
|
||||
msgstr "تصفية"
|
||||
|
||||
msgid "Available Sources"
|
||||
msgstr "المصادر المتاحة"
|
||||
|
||||
msgid "sources"
|
||||
msgstr "مصادر"
|
||||
|
||||
msgid "View Details"
|
||||
msgstr "عرض التفاصيل"
|
||||
|
||||
msgid "Edit"
|
||||
msgstr "تعديل"
|
||||
|
||||
msgid "Delete"
|
||||
msgstr "حذف"
|
||||
|
||||
msgid "First"
|
||||
msgstr "الأول"
|
||||
|
||||
msgid "Previous"
|
||||
msgstr "السابق"
|
||||
|
||||
msgid "Next"
|
||||
msgstr "التالي"
|
||||
|
||||
msgid "Last"
|
||||
msgstr "الأخير"
|
||||
|
||||
msgid "No sources found"
|
||||
msgstr "لم يتم العثور على مصادر"
|
||||
|
||||
msgid "Get started by creating your first data source."
|
||||
msgstr "ابدأ بإنشاء أول مصدر بيانات لك."
|
||||
|
||||
msgid "Network Configuration"
|
||||
msgstr "تكوين الشبكة"
|
||||
|
||||
msgid "Settings"
|
||||
msgstr "الإعدادات"
|
||||
|
||||
msgid "API Configuration"
|
||||
msgstr "تكوين واجهة برمجة التطبيقات"
|
||||
|
||||
msgid "API Keys"
|
||||
msgstr "مفاتيح واجهة برمجة التطبيقات"
|
||||
|
||||
msgid "Generate secure API keys for external integrations"
|
||||
msgstr "إنشاء مفاتيح واجهة برمجة التطبيقات الآمنة للتكاملات الخارجية"
|
||||
|
||||
msgid "Active"
|
||||
msgstr "نشط"
|
||||
|
||||
msgid "Inactive"
|
||||
msgstr "غير نشط"
|
||||
|
||||
msgid "Not generated"
|
||||
msgstr "لم يتم إنشاؤه"
|
||||
|
||||
msgid "Created By"
|
||||
msgstr "أنشأ بواسطة"
|
||||
|
||||
msgid "Created At"
|
||||
msgstr "أنشأ في"
|
||||
|
||||
msgid "Updated At"
|
||||
msgstr "حدث في"
|
||||
|
||||
msgid "IP Address"
|
||||
msgstr "عنوان IP"
|
||||
|
||||
msgid "Trusted IPs"
|
||||
msgstr "عناوين IP الموثوقة"
|
||||
|
||||
msgid "Integration Version"
|
||||
msgstr "إصدار التكامل"
|
||||
|
||||
msgid "API Key"
|
||||
msgstr "مفتاح واجهة برمجة التطبيقات"
|
||||
|
||||
msgid "API Secret"
|
||||
msgstr "سر واجهة برمجة التطبيقات"
|
||||
|
||||
msgid "Recent Integration Logs"
|
||||
msgstr "سجلات التكامل الأخيرة"
|
||||
|
||||
msgid "Time"
|
||||
msgstr "الوقت"
|
||||
|
||||
msgid "Action"
|
||||
msgstr "الإجراء"
|
||||
|
||||
msgid "Endpoint"
|
||||
msgstr "نقطة النهاية"
|
||||
|
||||
msgid "Method"
|
||||
msgstr "الطريقة"
|
||||
|
||||
msgid "Status"
|
||||
msgstr "الحالة"
|
||||
|
||||
msgid "Success"
|
||||
msgstr "نجح"
|
||||
|
||||
msgid "Failed"
|
||||
msgstr "فشل"
|
||||
|
||||
msgid "No integration logs found"
|
||||
msgstr "لم يتم العثور على سجلات تكامل"
|
||||
|
||||
msgid "Integration logs will appear here when this source is used for external integrations."
|
||||
msgstr "ستظهر سجلات التكامل هنا عند استخدام هذا المصدر للتكاملات الخارجية."
|
||||
|
||||
msgid "Delete Source"
|
||||
msgstr "حذف المصدر"
|
||||
|
||||
msgid "Confirm Deletion"
|
||||
msgstr "تأكيد الحذف"
|
||||
|
||||
msgid "Are you sure you want to delete the following source? This action cannot be undone."
|
||||
msgstr "هل أنت متأكد من رغبتك في حذف المصدر التالي؟ لا يمكن التراجع عن هذا الإجراء."
|
||||
|
||||
msgid "Important Note"
|
||||
msgstr "ملاحظة هامة"
|
||||
|
||||
msgid "All associated API keys will be permanently deleted."
|
||||
msgstr "ستتم حذف جميع مفاتيح واجهة برمجة التطبيقات المرتبطة بشكل دائم."
|
||||
|
||||
msgid "Integration logs related to this source will remain but will show 'Source deleted'."
|
||||
msgstr "ستبقى سجلات التكامل المتعلقة بهذا المصدر ولكنها ستظهر 'تم حذف المصدر'."
|
||||
|
||||
msgid "Any active integrations using this source will be disconnected."
|
||||
msgstr "سيتم فصل أي تكاملات نشطة تستخدم هذا المصدر."
|
||||
|
||||
msgid "Back to List"
|
||||
msgstr "العودة إلى القائمة"
|
||||
|
||||
msgid "Delete Source"
|
||||
msgstr "حذف المصدر"
|
||||
|
||||
msgid "Generate API Keys"
|
||||
msgstr "إنشاء مفاتيح واجهة برمجة التطبيقات"
|
||||
|
||||
msgid "Copy to Clipboard"
|
||||
msgstr "نسخ إلى الحافظة"
|
||||
|
||||
msgid "Failed to copy to clipboard"
|
||||
msgstr "فشل نسخ إلى الحافظة"
|
||||
|
||||
msgid "Generate random API key"
|
||||
msgstr "إنشاء مفتاح واجهة برمجة تطبيقات عشوائي"
|
||||
|
||||
msgid "Generate random API secret"
|
||||
msgstr "إنشاء سر واجهة برمجة تطبيقات عشوائي"
|
||||
|
||||
msgid "Source updated successfully."
|
||||
msgstr "تم تحديث المصدر بنجاح."
|
||||
|
||||
msgid "Source created successfully."
|
||||
msgstr "تم إنشاء المصدر بنجاح."
|
||||
|
||||
msgid "Source deleted successfully."
|
||||
msgstr "تم حذف المصدر بنجاح."
|
||||
|
||||
@ -1425,178 +1425,3 @@ msgstr ""
|
||||
#: templates/unfold/components/table.html:43
|
||||
msgid "No data"
|
||||
msgstr ""
|
||||
|
||||
# Source Management
|
||||
msgid "Data Sources"
|
||||
msgstr ""
|
||||
|
||||
msgid "Create New Source"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search by name, type, or description..."
|
||||
msgstr ""
|
||||
|
||||
msgid "Filter"
|
||||
msgstr ""
|
||||
|
||||
msgid "Available Sources"
|
||||
msgstr ""
|
||||
|
||||
msgid "sources"
|
||||
msgstr ""
|
||||
|
||||
msgid "View Details"
|
||||
msgstr ""
|
||||
|
||||
msgid "Edit"
|
||||
msgstr ""
|
||||
|
||||
msgid "Delete"
|
||||
msgstr ""
|
||||
|
||||
msgid "First"
|
||||
msgstr ""
|
||||
|
||||
msgid "Previous"
|
||||
msgstr ""
|
||||
|
||||
msgid "Next"
|
||||
msgstr ""
|
||||
|
||||
msgid "Last"
|
||||
msgstr ""
|
||||
|
||||
msgid "No sources found"
|
||||
msgstr ""
|
||||
|
||||
msgid "Get started by creating your first data source."
|
||||
msgstr ""
|
||||
|
||||
msgid "Network Configuration"
|
||||
msgstr ""
|
||||
|
||||
msgid "Settings"
|
||||
msgstr ""
|
||||
|
||||
msgid "API Configuration"
|
||||
msgstr ""
|
||||
|
||||
msgid "API Keys"
|
||||
msgstr ""
|
||||
|
||||
msgid "Generate secure API keys for external integrations"
|
||||
msgstr ""
|
||||
|
||||
msgid "Active"
|
||||
msgstr ""
|
||||
|
||||
msgid "Inactive"
|
||||
msgstr ""
|
||||
|
||||
msgid "Not generated"
|
||||
msgstr ""
|
||||
|
||||
msgid "Created By"
|
||||
msgstr ""
|
||||
|
||||
msgid "Created At"
|
||||
msgstr ""
|
||||
|
||||
msgid "Updated At"
|
||||
msgstr ""
|
||||
|
||||
msgid "IP Address"
|
||||
msgstr ""
|
||||
|
||||
msgid "Trusted IPs"
|
||||
msgstr ""
|
||||
|
||||
msgid "Integration Version"
|
||||
msgstr ""
|
||||
|
||||
msgid "API Key"
|
||||
msgstr ""
|
||||
|
||||
msgid "API Secret"
|
||||
msgstr ""
|
||||
|
||||
msgid "Recent Integration Logs"
|
||||
msgstr ""
|
||||
|
||||
msgid "Time"
|
||||
msgstr ""
|
||||
|
||||
msgid "Action"
|
||||
msgstr ""
|
||||
|
||||
msgid "Endpoint"
|
||||
msgstr ""
|
||||
|
||||
msgid "Method"
|
||||
msgstr ""
|
||||
|
||||
msgid "Status"
|
||||
msgstr ""
|
||||
|
||||
msgid "Success"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed"
|
||||
msgstr ""
|
||||
|
||||
msgid "No integration logs found"
|
||||
msgstr ""
|
||||
|
||||
msgid "Integration logs will appear here when this source is used for external integrations."
|
||||
msgstr ""
|
||||
|
||||
msgid "Delete Source"
|
||||
msgstr ""
|
||||
|
||||
msgid "Confirm Deletion"
|
||||
msgstr ""
|
||||
|
||||
msgid "Are you sure you want to delete the following source? This action cannot be undone."
|
||||
msgstr ""
|
||||
|
||||
msgid "Important Note"
|
||||
msgstr ""
|
||||
|
||||
msgid "All associated API keys will be permanently deleted."
|
||||
msgstr ""
|
||||
|
||||
msgid "Integration logs related to this source will remain but will show 'Source deleted'."
|
||||
msgstr ""
|
||||
|
||||
msgid "Any active integrations using this source will be disconnected."
|
||||
msgstr ""
|
||||
|
||||
msgid "Back to List"
|
||||
msgstr ""
|
||||
|
||||
msgid "Delete Source"
|
||||
msgstr ""
|
||||
|
||||
msgid "Generate API Keys"
|
||||
msgstr ""
|
||||
|
||||
msgid "Copy to Clipboard"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to copy to clipboard"
|
||||
msgstr ""
|
||||
|
||||
msgid "Generate random API key"
|
||||
msgstr ""
|
||||
|
||||
msgid "Generate random API secret"
|
||||
msgstr ""
|
||||
|
||||
msgid "Source updated successfully."
|
||||
msgstr ""
|
||||
|
||||
msgid "Source created successfully."
|
||||
msgstr ""
|
||||
|
||||
msgid "Source deleted successfully."
|
||||
msgstr ""
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,171 +1,20 @@
|
||||
from django import forms
|
||||
from .validators import validate_hash_tags
|
||||
from django.core.validators import URLValidator
|
||||
from django.forms.formsets import formset_factory
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Layout, Submit, Row, Column, Field, Div
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.auth.forms import UserCreationForm
|
||||
import re
|
||||
from .models import (
|
||||
ZoomMeeting, Candidate,TrainingMaterial,JobPosting,
|
||||
FormTemplate,InterviewSchedule,BreakTime,JobPostingImage,
|
||||
Profile,MeetingComment,ScheduledInterview,Source
|
||||
Profile,MeetingComment,ScheduledInterview
|
||||
)
|
||||
# from django_summernote.widgets import SummernoteWidget
|
||||
from django_ckeditor_5.widgets import CKEditor5Widget
|
||||
import secrets
|
||||
import string
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
def generate_api_key(length=32):
|
||||
"""Generate a secure API key"""
|
||||
alphabet = string.ascii_letters + string.digits
|
||||
return ''.join(secrets.choice(alphabet) for _ in range(length))
|
||||
|
||||
def generate_api_secret(length=64):
|
||||
"""Generate a secure API secret"""
|
||||
alphabet = string.ascii_letters + string.digits + '-._~'
|
||||
return ''.join(secrets.choice(alphabet) for _ in range(length))
|
||||
|
||||
class SourceForm(forms.ModelForm):
|
||||
"""Form for creating and editing sources with API key generation"""
|
||||
|
||||
# Hidden field to trigger API key generation
|
||||
generate_keys = forms.CharField(
|
||||
widget=forms.HiddenInput(),
|
||||
required=False,
|
||||
help_text="Set to 'true' to generate new API keys"
|
||||
)
|
||||
|
||||
# Display fields for generated keys (read-only)
|
||||
api_key_generated = forms.CharField(
|
||||
label="Generated API Key",
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'readonly': True, 'class': 'form-control'})
|
||||
)
|
||||
|
||||
api_secret_generated = forms.CharField(
|
||||
label="Generated API Secret",
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'readonly': True, 'class': 'form-control'})
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Source
|
||||
fields = [
|
||||
'name', 'source_type', 'description', 'ip_address',
|
||||
'trusted_ips', 'is_active', 'integration_version'
|
||||
]
|
||||
widgets = {
|
||||
'name': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'e.g., ATS System, ERP Integration',
|
||||
'required': True
|
||||
}),
|
||||
'source_type': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'e.g., ATS, ERP, API',
|
||||
'required': True
|
||||
}),
|
||||
'description': forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 3,
|
||||
'placeholder': 'Brief description of the source system'
|
||||
}),
|
||||
'ip_address': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': '192.168.1.100'
|
||||
}),
|
||||
'trusted_ips': forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 2,
|
||||
'placeholder': 'Comma-separated IP addresses (e.g., 192.168.1.100, 10.0.0.1)'
|
||||
}),
|
||||
'integration_version': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'v1.0, v2.1'
|
||||
}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_method = 'post'
|
||||
self.helper.form_class = 'form-horizontal'
|
||||
self.helper.label_class = 'col-md-3'
|
||||
self.helper.field_class = 'col-md-9'
|
||||
|
||||
# Add generate keys button
|
||||
self.helper.layout = Layout(
|
||||
Field('name', css_class='form-control'),
|
||||
Field('source_type', css_class='form-control'),
|
||||
Field('description', css_class='form-control'),
|
||||
Field('ip_address', css_class='form-control'),
|
||||
Field('trusted_ips', css_class='form-control'),
|
||||
Field('integration_version', css_class='form-control'),
|
||||
Field('is_active', css_class='form-check-input'),
|
||||
|
||||
# Hidden field for key generation trigger
|
||||
Field('generate_keys', type='hidden'),
|
||||
|
||||
# Display fields for generated keys
|
||||
Field('api_key_generated', css_class='form-control'),
|
||||
Field('api_secret_generated', css_class='form-control'),
|
||||
|
||||
Submit('submit', 'Save Source', css_class='btn btn-primary mt-3')
|
||||
)
|
||||
|
||||
def clean_name(self):
|
||||
"""Ensure source name is unique"""
|
||||
name = self.cleaned_data.get('name')
|
||||
if name:
|
||||
# Check for duplicates excluding current instance if editing
|
||||
instance = self.instance
|
||||
if not instance.pk: # Creating new instance
|
||||
if Source.objects.filter(name=name).exists():
|
||||
raise ValidationError('A source with this name already exists.')
|
||||
else: # Editing existing instance
|
||||
if Source.objects.filter(name=name).exclude(pk=instance.pk).exists():
|
||||
raise ValidationError('A source with this name already exists.')
|
||||
return name
|
||||
|
||||
def clean_trusted_ips(self):
|
||||
"""Validate and format trusted IP addresses"""
|
||||
trusted_ips = self.cleaned_data.get('trusted_ips')
|
||||
if trusted_ips:
|
||||
# Split by comma and strip whitespace
|
||||
ips = [ip.strip() for ip in trusted_ips.split(',') if ip.strip()]
|
||||
|
||||
# Validate each IP address
|
||||
for ip in ips:
|
||||
try:
|
||||
# Basic IP validation (can be enhanced)
|
||||
if not (ip.replace('.', '').isdigit() and len(ip.split('.')) == 4):
|
||||
raise ValidationError(f'Invalid IP address: {ip}')
|
||||
except Exception:
|
||||
raise ValidationError(f'Invalid IP address: {ip}')
|
||||
|
||||
return ', '.join(ips)
|
||||
return trusted_ips
|
||||
|
||||
def clean(self):
|
||||
"""Custom validation for the form"""
|
||||
cleaned_data = super().clean()
|
||||
|
||||
# Check if we need to generate API keys
|
||||
generate_keys = cleaned_data.get('generate_keys')
|
||||
|
||||
if generate_keys == 'true':
|
||||
# Generate new API key and secret
|
||||
cleaned_data['api_key'] = generate_api_key()
|
||||
cleaned_data['api_secret'] = generate_api_secret()
|
||||
|
||||
# Set display fields for the frontend
|
||||
cleaned_data['api_key_generated'] = cleaned_data['api_key']
|
||||
cleaned_data['api_secret_generated'] = cleaned_data['api_secret']
|
||||
|
||||
return cleaned_data
|
||||
|
||||
class CandidateForm(forms.ModelForm):
|
||||
class Meta:
|
||||
@ -223,6 +72,51 @@ class CandidateStageForm(forms.ModelForm):
|
||||
'stage': forms.Select(attrs={'class': 'form-select'}),
|
||||
}
|
||||
|
||||
# def __init__(self, *args, **kwargs):
|
||||
# # Get the current candidate instance for validation
|
||||
# self.candidate = kwargs.pop('candidate', None)
|
||||
# super().__init__(*args, **kwargs)
|
||||
|
||||
# # Dynamically filter stage choices based on current stage
|
||||
# if self.candidate and self.candidate.pk:
|
||||
# current_stage = self.candidate.stage
|
||||
# available_stages = self.candidate.get_available_stages()
|
||||
|
||||
# # Filter choices to only include available stages
|
||||
# choices = [(stage, self.candidate.Stage(stage).label)
|
||||
# for stage in available_stages]
|
||||
# self.fields['stage'].choices = choices
|
||||
|
||||
# # Set initial value to current stage
|
||||
# self.fields['stage'].initial = current_stage
|
||||
# else:
|
||||
# # For new candidates, only show 'Applied' stage
|
||||
# self.fields['stage'].choices = [('Applied', _('Applied'))]
|
||||
# self.fields['stage'].initial = 'Applied'
|
||||
|
||||
# def clean_stage(self):
|
||||
# """Validate stage transition"""
|
||||
# new_stage = self.cleaned_data.get('stage')
|
||||
# if not new_stage:
|
||||
# raise forms.ValidationError(_('Please select a stage.'))
|
||||
|
||||
# # Use model validation for stage transitions
|
||||
# if self.candidate and self.candidate.pk:
|
||||
# current_stage = self.candidate.stage
|
||||
# if new_stage != current_stage:
|
||||
# if not self.candidate.can_transition_to(new_stage):
|
||||
# allowed_stages = self.candidate.get_available_stages()
|
||||
# raise forms.ValidationError(
|
||||
# _('Cannot transition from "%(current)s" to "%(new)s". '
|
||||
# 'Allowed transitions: %(allowed)s') % {
|
||||
# 'current': current_stage,
|
||||
# 'new': new_stage,
|
||||
# 'allowed': ', '.join(allowed_stages) or 'None (final stage)'
|
||||
# }
|
||||
# )
|
||||
|
||||
# return new_stage
|
||||
|
||||
class ZoomMeetingForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = ZoomMeeting
|
||||
@ -252,6 +146,8 @@ class ZoomMeetingForm(forms.ModelForm):
|
||||
Submit('submit', _('Create Meeting'), css_class='btn btn-primary')
|
||||
)
|
||||
|
||||
# Old JobForm removed - replaced by JobPostingForm
|
||||
|
||||
class TrainingMaterialForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = TrainingMaterial
|
||||
@ -264,11 +160,13 @@ class TrainingMaterialForm(forms.ModelForm):
|
||||
}
|
||||
widgets = {
|
||||
'title': forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Enter material title')}),
|
||||
'content': CKEditor5Widget(attrs={'placeholder': _('Enter material content')}),
|
||||
# 💡 Use SummernoteWidget here
|
||||
# 'content': SummernoteWidget(attrs={'placeholder': _('Enter material content')}),
|
||||
'video_link': forms.URLInput(attrs={'class': 'form-control', 'placeholder': _('https://www.youtube.com/watch?v=...')}),
|
||||
'file': forms.FileInput(attrs={'class': 'form-control'}),
|
||||
}
|
||||
|
||||
# The __init__ and FormHelper layout remains the same
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.helper = FormHelper()
|
||||
@ -277,7 +175,7 @@ class TrainingMaterialForm(forms.ModelForm):
|
||||
|
||||
self.helper.layout = Layout(
|
||||
'title',
|
||||
'content',
|
||||
'content', # Summernote is applied via the widgets dictionary
|
||||
Row(
|
||||
Column('video_link', css_class='col-md-6'),
|
||||
Column('file', css_class='col-md-6'),
|
||||
@ -302,7 +200,7 @@ class JobPostingForm(forms.ModelForm):
|
||||
'description', 'qualifications', 'salary_range', 'benefits',
|
||||
'application_deadline', 'application_instructions',
|
||||
'position_number', 'reporting_to',
|
||||
'open_positions', 'hash_tags', 'max_applications'
|
||||
'open_positions','hash_tags','max_applications'
|
||||
]
|
||||
widgets = {
|
||||
# Basic Information
|
||||
@ -338,22 +236,36 @@ class JobPostingForm(forms.ModelForm):
|
||||
'value': 'United States'
|
||||
}),
|
||||
|
||||
|
||||
'salary_range': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': '$60,000 - $80,000'
|
||||
}),
|
||||
|
||||
|
||||
# Application Information
|
||||
# 'application_url': forms.URLInput(attrs={
|
||||
# 'class': 'form-control',
|
||||
# 'placeholder': 'https://university.edu/careers/job123',
|
||||
# 'required': True
|
||||
# }),
|
||||
|
||||
|
||||
|
||||
# Application Information
|
||||
# 'application_url': forms.URLInput(attrs={
|
||||
# 'class': 'form-control',
|
||||
# 'placeholder': 'https://university.edu/careers/job123',
|
||||
# 'required': True
|
||||
# }),
|
||||
'application_start_date': forms.DateInput(attrs={
|
||||
'class': 'form-control',
|
||||
'type': 'date'
|
||||
}),
|
||||
'application_deadline': forms.DateInput(attrs={
|
||||
'class': 'form-control',
|
||||
'type': 'date',
|
||||
'required': True
|
||||
'required':True
|
||||
}),
|
||||
|
||||
'open_positions': forms.NumberInput(attrs={
|
||||
@ -376,7 +288,8 @@ class JobPostingForm(forms.ModelForm):
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Department Chair, Director, etc.'
|
||||
}),
|
||||
|
||||
|
||||
|
||||
'max_applications': forms.NumberInput(attrs={
|
||||
'class': 'form-control',
|
||||
'min': 1,
|
||||
@ -384,52 +297,78 @@ class JobPostingForm(forms.ModelForm):
|
||||
}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
def __init__(self,*args,**kwargs):
|
||||
|
||||
# Now call the parent __init__ with remaining args
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if not self.instance.pk: # Creating new job posting
|
||||
if not self.instance.pk:# Creating new job posting
|
||||
# self.fields['status'].initial = 'Draft'
|
||||
self.fields['location_city'].initial = 'Riyadh'
|
||||
self.fields['location_state'].initial = 'Riyadh Province'
|
||||
self.fields['location_country'].initial = 'Saudi Arabia'
|
||||
self.fields['location_city'].initial='Riyadh'
|
||||
self.fields['location_state'].initial='Riyadh Province'
|
||||
self.fields['location_country'].initial='Saudi Arabia'
|
||||
|
||||
|
||||
def clean_hash_tags(self):
|
||||
hash_tags = self.cleaned_data.get('hash_tags')
|
||||
hash_tags=self.cleaned_data.get('hash_tags')
|
||||
if hash_tags:
|
||||
tags = [tag.strip() for tag in hash_tags.split(',') if tag.strip()]
|
||||
tags=[tag.strip() for tag in hash_tags.split(',') if tag.strip()]
|
||||
for tag in tags:
|
||||
if not tag.startswith('#'):
|
||||
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 hash_tags # Allow blank
|
||||
|
||||
def clean_title(self):
|
||||
title = self.cleaned_data.get('title')
|
||||
if not title or len(title.strip()) < 3:
|
||||
title=self.cleaned_data.get('title')
|
||||
if not title or len(title.strip())<3:
|
||||
raise forms.ValidationError("Job title must be at least 3 characters long.")
|
||||
if len(title) > 200:
|
||||
if len(title)>200:
|
||||
raise forms.ValidationError("Job title cannot exceed 200 characters.")
|
||||
return title.strip()
|
||||
|
||||
def clean_description(self):
|
||||
description = self.cleaned_data.get('description')
|
||||
if not description or len(description.strip()) < 20:
|
||||
description=self.cleaned_data.get('description')
|
||||
if not description or len(description.strip())<20:
|
||||
raise forms.ValidationError("Job description must be at least 20 characters long.")
|
||||
return description.strip() # to remove leading/trailing whitespace
|
||||
|
||||
def clean_application_url(self):
|
||||
url = self.cleaned_data.get('application_url')
|
||||
url=self.cleaned_data.get('application_url')
|
||||
if url:
|
||||
validator = URLValidator()
|
||||
validator=URLValidator()
|
||||
try:
|
||||
validator(url)
|
||||
except forms.ValidationError:
|
||||
raise forms.ValidationError('Please enter a valid URL (e.g., https://example.com)')
|
||||
return url
|
||||
|
||||
# def clean(self):
|
||||
# """Cross-field validation"""
|
||||
# cleaned_data = super().clean()
|
||||
|
||||
# # Validate dates
|
||||
# start_date = cleaned_data.get('start_date')
|
||||
# application_deadline = cleaned_data.get('application_deadline')
|
||||
|
||||
# # Perform cross-field validation only if both fields have values
|
||||
# if start_date and application_deadline:
|
||||
# if application_deadline > start_date:
|
||||
# self.add_error('application_deadline',
|
||||
# 'The application deadline must be set BEFORE the job start date.')
|
||||
|
||||
# # # Validate that if status is ACTIVE, we have required fields
|
||||
# # status = cleaned_data.get('status')
|
||||
# # if status == 'ACTIVE':
|
||||
# # if not cleaned_data.get('application_url'):
|
||||
# # self.add_error('application_url',
|
||||
# # 'Application URL is required for active jobs.')
|
||||
# # if not cleaned_data.get('description'):
|
||||
# # self.add_error('description',
|
||||
# # 'Job description is required for active jobs.')
|
||||
|
||||
# return cleaned_data
|
||||
|
||||
class JobPostingImageForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model=JobPostingImage
|
||||
@ -476,6 +415,90 @@ class FormTemplateForm(forms.ModelForm):
|
||||
Field('is_active', css_class='form-check-input'),
|
||||
Submit('submit', _('Create Template'), css_class='btn btn-primary mt-3')
|
||||
)
|
||||
# class BreakTimeForm(forms.ModelForm):
|
||||
# class Meta:
|
||||
# model = BreakTime
|
||||
# fields = ['start_time', 'end_time']
|
||||
# widgets = {
|
||||
# 'start_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}),
|
||||
# 'end_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}),
|
||||
# }
|
||||
|
||||
# BreakTimeFormSet = formset_factory(BreakTimeForm, extra=1, can_delete=True)
|
||||
|
||||
# class InterviewScheduleForm(forms.ModelForm):
|
||||
# candidates = forms.ModelMultipleChoiceField(
|
||||
# queryset=Candidate.objects.none(),
|
||||
# widget=forms.CheckboxSelectMultiple,
|
||||
# required=True
|
||||
# )
|
||||
# working_days = forms.MultipleChoiceField(
|
||||
# choices=[
|
||||
# (0, 'Monday'),
|
||||
# (1, 'Tuesday'),
|
||||
# (2, 'Wednesday'),
|
||||
# (3, 'Thursday'),
|
||||
# (4, 'Friday'),
|
||||
# (5, 'Saturday'),
|
||||
# (6, 'Sunday'),
|
||||
# ],
|
||||
# widget=forms.CheckboxSelectMultiple,
|
||||
# required=True
|
||||
# )
|
||||
|
||||
# class Meta:
|
||||
# model = InterviewSchedule
|
||||
# fields = [
|
||||
# 'candidates', 'start_date', 'end_date', 'working_days',
|
||||
# 'start_time', 'end_time', 'interview_duration', 'buffer_time'
|
||||
# ]
|
||||
# widgets = {
|
||||
# 'start_date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
|
||||
# 'end_date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
|
||||
# 'start_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}),
|
||||
# 'end_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}),
|
||||
# 'interview_duration': forms.NumberInput(attrs={'class': 'form-control'}),
|
||||
# 'buffer_time': forms.NumberInput(attrs={'class': 'form-control'}),
|
||||
# }
|
||||
|
||||
# def __init__(self, slug, *args, **kwargs):
|
||||
# super().__init__(*args, **kwargs)
|
||||
# # Filter candidates based on the selected job
|
||||
# self.fields['candidates'].queryset = Candidate.objects.filter(
|
||||
# job__slug=slug,
|
||||
# stage='Interview'
|
||||
# )
|
||||
|
||||
# def clean_working_days(self):
|
||||
# working_days = self.cleaned_data.get('working_days')
|
||||
# # Convert string values to integers
|
||||
# return [int(day) for day in working_days]
|
||||
|
||||
|
||||
class JobPostingCancelReasonForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = JobPosting
|
||||
fields = ['cancel_reason']
|
||||
class JobPostingStatusForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = JobPosting
|
||||
fields = ['status']
|
||||
widgets = {
|
||||
'status': forms.Select(attrs={'class': 'form-select'}),
|
||||
}
|
||||
class FormTemplateIsActiveForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = FormTemplate
|
||||
fields = ['is_active']
|
||||
|
||||
class CandidateExamDateForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Candidate
|
||||
fields = ['exam_date']
|
||||
widgets = {
|
||||
'exam_date': forms.DateTimeInput(attrs={'type': 'datetime-local', 'class': 'form-control'}),
|
||||
}
|
||||
|
||||
|
||||
class BreakTimeForm(forms.Form):
|
||||
"""
|
||||
@ -492,8 +515,10 @@ class BreakTimeForm(forms.Form):
|
||||
label="End Time"
|
||||
)
|
||||
|
||||
# Use the non-model form for the formset factory
|
||||
BreakTimeFormSet = formset_factory(BreakTimeForm, extra=1, can_delete=True)
|
||||
|
||||
# --- InterviewScheduleForm remains unchanged ---
|
||||
class InterviewScheduleForm(forms.ModelForm):
|
||||
candidates = forms.ModelMultipleChoiceField(
|
||||
queryset=Candidate.objects.none(),
|
||||
@ -529,6 +554,7 @@ class InterviewScheduleForm(forms.ModelForm):
|
||||
|
||||
def __init__(self, slug, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Filter candidates based on the selected job
|
||||
self.fields['candidates'].queryset = Candidate.objects.filter(
|
||||
job__slug=slug,
|
||||
stage='Interview'
|
||||
@ -536,6 +562,7 @@ class InterviewScheduleForm(forms.ModelForm):
|
||||
|
||||
def clean_working_days(self):
|
||||
working_days = self.cleaned_data.get('working_days')
|
||||
# Convert string values to integers
|
||||
return [int(day) for day in working_days]
|
||||
|
||||
class MeetingCommentForm(forms.ModelForm):
|
||||
@ -565,7 +592,26 @@ class MeetingCommentForm(forms.ModelForm):
|
||||
Submit('submit', _('Add Comment'), css_class='btn btn-primary mt-3')
|
||||
)
|
||||
|
||||
# --- ScheduleInterviewForCandiateForm remains unchanged ---
|
||||
class ScheduleInterviewForCandiateForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = InterviewSchedule
|
||||
fields = ['start_date', 'end_date', 'start_time', 'end_time', 'interview_duration', 'buffer_time']
|
||||
widgets = {
|
||||
'start_date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
|
||||
'end_date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
|
||||
'start_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}),
|
||||
'end_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}),
|
||||
'interview_duration': forms.NumberInput(attrs={'class': 'form-control'}),
|
||||
'buffer_time': forms.NumberInput(attrs={'class': 'form-control'}),
|
||||
'break_start_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}),
|
||||
'break_end_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}),
|
||||
}
|
||||
|
||||
|
||||
class InterviewForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = ScheduledInterview
|
||||
fields = ['job','candidate']
|
||||
@ -575,6 +621,36 @@ class ProfileImageUploadForm(forms.ModelForm):
|
||||
model=Profile
|
||||
fields=['profile_image']
|
||||
|
||||
|
||||
|
||||
# class UserEditForms(forms.ModelForm):
|
||||
# class Meta:
|
||||
# model = User
|
||||
# fields = ['first_name', 'last_name']
|
||||
|
||||
|
||||
from django.contrib.auth.forms import UserCreationForm
|
||||
# class StaffUserCreationForm(UserCreationForm):
|
||||
# email = forms.EmailField(required=True)
|
||||
# first_name = forms.CharField(max_length=30)
|
||||
# last_name = forms.CharField(max_length=150)
|
||||
|
||||
# class Meta:
|
||||
# model = User
|
||||
# fields = ("email", "first_name", "last_name", "password1", "password2")
|
||||
|
||||
# def save(self, commit=True):
|
||||
# user = super().save(commit=False)
|
||||
# user.email = self.cleaned_data["email"]
|
||||
# user.first_name = self.cleaned_data["first_name"]
|
||||
# user.last_name = self.cleaned_data["last_name"]
|
||||
# user.username = self.cleaned_data["email"] # or generate
|
||||
# user.is_staff = True
|
||||
# if commit:
|
||||
# user.save()
|
||||
# return user
|
||||
|
||||
import re
|
||||
class StaffUserCreationForm(UserCreationForm):
|
||||
email = forms.EmailField(required=True)
|
||||
first_name = forms.CharField(max_length=30, required=True)
|
||||
@ -608,37 +684,15 @@ class StaffUserCreationForm(UserCreationForm):
|
||||
user.email = self.cleaned_data["email"]
|
||||
user.first_name = self.cleaned_data["first_name"]
|
||||
user.last_name = self.cleaned_data["last_name"]
|
||||
user.username = self.generate_username(user.email)
|
||||
user.username = self.generate_username(user.email) # never use raw email if it has dots, etc.
|
||||
user.is_staff = True
|
||||
if commit:
|
||||
user.save()
|
||||
return user
|
||||
|
||||
|
||||
|
||||
class ToggleAccountForm(forms.Form):
|
||||
pass
|
||||
|
||||
class JobPostingCancelReasonForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = JobPosting
|
||||
fields = ['cancel_reason']
|
||||
|
||||
class JobPostingStatusForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = JobPosting
|
||||
fields = ['status']
|
||||
widgets = {
|
||||
'status': forms.Select(attrs={'class': 'form-select'}),
|
||||
}
|
||||
|
||||
class FormTemplateIsActiveForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = FormTemplate
|
||||
fields = ['is_active']
|
||||
|
||||
class CandidateExamDateForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Candidate
|
||||
fields = ['exam_date']
|
||||
widgets = {
|
||||
'exam_date': forms.DateTimeInput(attrs={'type': 'datetime-local', 'class': 'form-control'}),
|
||||
}
|
||||
|
||||
477
recruitment/migrations/0001_initial.py
Normal file
477
recruitment/migrations/0001_initial.py
Normal file
@ -0,0 +1,477 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-21 22:26
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import django_ckeditor_5.fields
|
||||
import django_countries.fields
|
||||
import django_extensions.db.fields
|
||||
import recruitment.validators
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='BreakTime',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('start_time', models.TimeField(verbose_name='Start Time')),
|
||||
('end_time', models.TimeField(verbose_name='End Time')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='FormStage',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
|
||||
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
|
||||
('name', models.CharField(help_text='Name of the stage', max_length=200)),
|
||||
('order', models.PositiveIntegerField(default=0, help_text='Order of the stage in the form')),
|
||||
('is_predefined', models.BooleanField(default=False, help_text='Whether this is a default resume stage')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Form Stage',
|
||||
'verbose_name_plural': 'Form Stages',
|
||||
'ordering': ['order'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='HiringAgency',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
|
||||
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
|
||||
('name', models.CharField(max_length=200, unique=True, verbose_name='Agency Name')),
|
||||
('contact_person', models.CharField(blank=True, max_length=150, verbose_name='Contact Person')),
|
||||
('email', models.EmailField(blank=True, max_length=254)),
|
||||
('phone', models.CharField(blank=True, max_length=20)),
|
||||
('website', models.URLField(blank=True)),
|
||||
('notes', models.TextField(blank=True, help_text='Internal notes about the agency')),
|
||||
('country', django_countries.fields.CountryField(blank=True, max_length=2, null=True)),
|
||||
('address', models.TextField(blank=True, null=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Hiring Agency',
|
||||
'verbose_name_plural': 'Hiring Agencies',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Source',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
|
||||
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
|
||||
('name', models.CharField(help_text='e.g., ATS, ERP ', max_length=100, unique=True, verbose_name='Source Name')),
|
||||
('source_type', models.CharField(help_text='e.g., ATS, ERP ', max_length=100, verbose_name='Source Type')),
|
||||
('description', models.TextField(blank=True, help_text='A description of the source', verbose_name='Description')),
|
||||
('ip_address', models.GenericIPAddressField(blank=True, help_text='The IP address of the source', null=True, verbose_name='IP Address')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('api_key', models.CharField(blank=True, help_text='API key for authentication (will be encrypted)', max_length=255, null=True, verbose_name='API Key')),
|
||||
('api_secret', models.CharField(blank=True, help_text='API secret for authentication (will be encrypted)', max_length=255, null=True, verbose_name='API Secret')),
|
||||
('trusted_ips', models.TextField(blank=True, help_text='Comma-separated list of trusted IP addresses', null=True, verbose_name='Trusted IP Addresses')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Whether this source is active for integration', verbose_name='Active')),
|
||||
('integration_version', models.CharField(blank=True, help_text='Version of the integration protocol', max_length=50, verbose_name='Integration Version')),
|
||||
('last_sync_at', models.DateTimeField(blank=True, help_text='Timestamp of the last successful synchronization', null=True, verbose_name='Last Sync At')),
|
||||
('sync_status', models.CharField(blank=True, choices=[('IDLE', 'Idle'), ('SYNCING', 'Syncing'), ('ERROR', 'Error'), ('DISABLED', 'Disabled')], default='IDLE', max_length=20, verbose_name='Sync Status')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Source',
|
||||
'verbose_name_plural': 'Sources',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ZoomMeeting',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
|
||||
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
|
||||
('topic', models.CharField(max_length=255, verbose_name='Topic')),
|
||||
('meeting_id', models.CharField(db_index=True, max_length=20, unique=True, verbose_name='Meeting ID')),
|
||||
('start_time', models.DateTimeField(db_index=True, verbose_name='Start Time')),
|
||||
('duration', models.PositiveIntegerField(verbose_name='Duration')),
|
||||
('timezone', models.CharField(max_length=50, verbose_name='Timezone')),
|
||||
('join_url', models.URLField(verbose_name='Join URL')),
|
||||
('participant_video', models.BooleanField(default=True, verbose_name='Participant Video')),
|
||||
('password', models.CharField(blank=True, max_length=20, null=True, verbose_name='Password')),
|
||||
('join_before_host', models.BooleanField(default=False, verbose_name='Join Before Host')),
|
||||
('mute_upon_entry', models.BooleanField(default=False, verbose_name='Mute Upon Entry')),
|
||||
('waiting_room', models.BooleanField(default=False, verbose_name='Waiting Room')),
|
||||
('zoom_gateway_response', models.JSONField(blank=True, null=True, verbose_name='Zoom Gateway Response')),
|
||||
('status', models.CharField(blank=True, db_index=True, default='waiting', max_length=20, null=True, verbose_name='Status')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='FormField',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
|
||||
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
|
||||
('label', models.CharField(help_text='Label for the field', max_length=200)),
|
||||
('field_type', models.CharField(choices=[('text', 'Text Input'), ('email', 'Email'), ('phone', 'Phone'), ('textarea', 'Text Area'), ('file', 'File Upload'), ('date', 'Date Picker'), ('select', 'Dropdown'), ('radio', 'Radio Buttons'), ('checkbox', 'Checkboxes')], help_text='Type of the field', max_length=20)),
|
||||
('placeholder', models.CharField(blank=True, help_text='Placeholder text', max_length=200)),
|
||||
('required', models.BooleanField(default=False, help_text='Whether the field is required')),
|
||||
('order', models.PositiveIntegerField(default=0, help_text='Order of the field in the stage')),
|
||||
('is_predefined', models.BooleanField(default=False, help_text='Whether this is a default field')),
|
||||
('options', models.JSONField(blank=True, default=list, help_text='Options for selection fields (stored as JSON array)')),
|
||||
('file_types', models.CharField(blank=True, help_text="Allowed file types (comma-separated, e.g., '.pdf,.doc,.docx')", max_length=200)),
|
||||
('max_file_size', models.PositiveIntegerField(default=5, help_text='Maximum file size in MB (default: 5MB)')),
|
||||
('multiple_files', models.BooleanField(default=False, help_text='Allow multiple files to be uploaded')),
|
||||
('max_files', models.PositiveIntegerField(default=1, help_text='Maximum number of files allowed (when multiple_files is True)')),
|
||||
('stage', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fields', to='recruitment.formstage')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Form Field',
|
||||
'verbose_name_plural': 'Form Fields',
|
||||
'ordering': ['order'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='FormTemplate',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
|
||||
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
|
||||
('name', models.CharField(help_text='Name of the form template', max_length=200)),
|
||||
('description', models.TextField(blank=True, help_text='Description of the form template')),
|
||||
('is_active', models.BooleanField(default=False, help_text='Whether this template is active')),
|
||||
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='form_templates', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Form Template',
|
||||
'verbose_name_plural': 'Form Templates',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='FormSubmission',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
|
||||
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
|
||||
('submitted_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('applicant_name', models.CharField(blank=True, help_text='Name of the applicant', max_length=200)),
|
||||
('applicant_email', models.EmailField(blank=True, db_index=True, help_text='Email of the applicant', max_length=254)),
|
||||
('submitted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='form_submissions', to=settings.AUTH_USER_MODEL)),
|
||||
('template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='submissions', to='recruitment.formtemplate')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Form Submission',
|
||||
'verbose_name_plural': 'Form Submissions',
|
||||
'ordering': ['-submitted_at'],
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='formstage',
|
||||
name='template',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stages', to='recruitment.formtemplate'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Candidate',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
|
||||
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
|
||||
('first_name', models.CharField(max_length=255, verbose_name='First Name')),
|
||||
('last_name', models.CharField(max_length=255, verbose_name='Last Name')),
|
||||
('email', models.EmailField(db_index=True, max_length=254, verbose_name='Email')),
|
||||
('phone', models.CharField(max_length=20, verbose_name='Phone')),
|
||||
('address', models.TextField(max_length=200, verbose_name='Address')),
|
||||
('resume', models.FileField(upload_to='resumes/', verbose_name='Resume')),
|
||||
('is_resume_parsed', models.BooleanField(default=False, verbose_name='Resume Parsed')),
|
||||
('is_potential_candidate', models.BooleanField(default=False, verbose_name='Potential Candidate')),
|
||||
('parsed_summary', models.TextField(blank=True, verbose_name='Parsed Summary')),
|
||||
('applied', models.BooleanField(default=False, verbose_name='Applied')),
|
||||
('stage', models.CharField(choices=[('Applied', 'Applied'), ('Exam', 'Exam'), ('Interview', 'Interview'), ('Offer', 'Offer')], db_index=True, default='Applied', max_length=100, verbose_name='Stage')),
|
||||
('applicant_status', models.CharField(blank=True, choices=[('Applicant', 'Applicant'), ('Candidate', 'Candidate')], default='Applicant', max_length=100, null=True, verbose_name='Applicant Status')),
|
||||
('exam_date', models.DateTimeField(blank=True, null=True, verbose_name='Exam Date')),
|
||||
('exam_status', models.CharField(blank=True, choices=[('Passed', 'Passed'), ('Failed', 'Failed')], max_length=100, null=True, verbose_name='Exam Status')),
|
||||
('interview_date', models.DateTimeField(blank=True, null=True, verbose_name='Interview Date')),
|
||||
('interview_status', models.CharField(blank=True, choices=[('Passed', 'Passed'), ('Failed', 'Failed')], max_length=100, null=True, verbose_name='Interview Status')),
|
||||
('offer_date', models.DateField(blank=True, null=True, verbose_name='Offer Date')),
|
||||
('offer_status', models.CharField(blank=True, choices=[('Accepted', 'Accepted'), ('Rejected', 'Rejected')], max_length=100, null=True, verbose_name='Offer Status')),
|
||||
('join_date', models.DateField(blank=True, null=True, verbose_name='Join Date')),
|
||||
('ai_analysis_data', models.JSONField(default=dict, help_text='Full JSON output from the resume scoring model.', verbose_name='AI Analysis Data')),
|
||||
('submitted_by_agency', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='submitted_candidates', to='recruitment.hiringagency', verbose_name='Submitted by Agency')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Candidate',
|
||||
'verbose_name_plural': 'Candidates',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='JobPosting',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
|
||||
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
|
||||
('title', models.CharField(max_length=200)),
|
||||
('department', models.CharField(blank=True, max_length=100)),
|
||||
('job_type', models.CharField(choices=[('FULL_TIME', 'Full-time'), ('PART_TIME', 'Part-time'), ('CONTRACT', 'Contract'), ('INTERNSHIP', 'Internship'), ('FACULTY', 'Faculty'), ('TEMPORARY', 'Temporary')], default='FULL_TIME', max_length=20)),
|
||||
('workplace_type', models.CharField(choices=[('ON_SITE', 'On-site'), ('REMOTE', 'Remote'), ('HYBRID', 'Hybrid')], default='ON_SITE', max_length=20)),
|
||||
('location_city', models.CharField(blank=True, max_length=100)),
|
||||
('location_state', models.CharField(blank=True, max_length=100)),
|
||||
('location_country', models.CharField(default='Saudia Arabia', max_length=100)),
|
||||
('description', django_ckeditor_5.fields.CKEditor5Field(verbose_name='Description')),
|
||||
('qualifications', django_ckeditor_5.fields.CKEditor5Field(blank=True, null=True)),
|
||||
('salary_range', models.CharField(blank=True, help_text='e.g., $60,000 - $80,000', max_length=200)),
|
||||
('benefits', django_ckeditor_5.fields.CKEditor5Field(blank=True, null=True)),
|
||||
('application_url', models.URLField(blank=True, help_text='URL where candidates apply', null=True, validators=[django.core.validators.URLValidator()])),
|
||||
('application_start_date', models.DateField()),
|
||||
('application_deadline', models.DateField(db_index=True)),
|
||||
('application_instructions', django_ckeditor_5.fields.CKEditor5Field(blank=True, null=True)),
|
||||
('internal_job_id', models.CharField(editable=False, max_length=50, primary_key=True, serialize=False)),
|
||||
('created_by', models.CharField(blank=True, help_text='Name of person who created this job', max_length=100)),
|
||||
('status', models.CharField(choices=[('DRAFT', 'Draft'), ('ACTIVE', 'Active'), ('CLOSED', 'Closed'), ('CANCELLED', 'Cancelled'), ('ARCHIVED', 'Archived')], db_index=True, default='DRAFT', max_length=20)),
|
||||
('hash_tags', models.CharField(blank=True, help_text='Comma-separated hashtags for linkedin post like #hiring,#jobopening', max_length=200, validators=[recruitment.validators.validate_hash_tags])),
|
||||
('linkedin_post_id', models.CharField(blank=True, help_text='LinkedIn post ID after posting', max_length=200)),
|
||||
('linkedin_post_url', models.URLField(blank=True, help_text='Direct URL to LinkedIn post')),
|
||||
('posted_to_linkedin', models.BooleanField(default=False)),
|
||||
('linkedin_post_status', models.CharField(blank=True, help_text='Status of LinkedIn posting', max_length=50)),
|
||||
('linkedin_posted_at', models.DateTimeField(blank=True, null=True)),
|
||||
('published_at', models.DateTimeField(blank=True, db_index=True, null=True)),
|
||||
('position_number', models.CharField(blank=True, help_text='University position number', max_length=50)),
|
||||
('reporting_to', models.CharField(blank=True, help_text='Who this position reports to', max_length=100)),
|
||||
('open_positions', models.PositiveIntegerField(default=1, help_text='Number of open positions for this job')),
|
||||
('max_applications', models.PositiveIntegerField(blank=True, default=1000, help_text='Maximum number of applications allowed', null=True)),
|
||||
('cancel_reason', models.TextField(blank=True, help_text='Reason for canceling the job posting', verbose_name='Cancel Reason')),
|
||||
('cancelled_by', models.CharField(blank=True, help_text='Name of person who cancelled this job', max_length=100, verbose_name='Cancelled By')),
|
||||
('cancelled_at', models.DateTimeField(blank=True, null=True)),
|
||||
('hiring_agency', models.ManyToManyField(blank=True, help_text='External agency responsible for sourcing candidates for this role', related_name='jobs', to='recruitment.hiringagency', verbose_name='Hiring Agency')),
|
||||
('source', models.ForeignKey(blank=True, help_text='The system or channel from which this job posting originated or was first published.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='job_postings', to='recruitment.source')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Job Posting',
|
||||
'verbose_name_plural': 'Job Postings',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='InterviewSchedule',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
|
||||
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
|
||||
('start_date', models.DateField(db_index=True, verbose_name='Start Date')),
|
||||
('end_date', models.DateField(db_index=True, verbose_name='End Date')),
|
||||
('working_days', models.JSONField(verbose_name='Working Days')),
|
||||
('start_time', models.TimeField(verbose_name='Start Time')),
|
||||
('end_time', models.TimeField(verbose_name='End Time')),
|
||||
('break_start_time', models.TimeField(blank=True, null=True, verbose_name='Break Start Time')),
|
||||
('break_end_time', models.TimeField(blank=True, null=True, verbose_name='Break End Time')),
|
||||
('interview_duration', models.PositiveIntegerField(verbose_name='Interview Duration (minutes)')),
|
||||
('buffer_time', models.PositiveIntegerField(default=0, verbose_name='Buffer Time (minutes)')),
|
||||
('candidates', models.ManyToManyField(blank=True, null=True, related_name='interview_schedules', to='recruitment.candidate')),
|
||||
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interview_schedules', to='recruitment.jobposting')),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='formtemplate',
|
||||
name='job',
|
||||
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='form_template', to='recruitment.jobposting'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='candidate',
|
||||
name='job',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='candidates', to='recruitment.jobposting', verbose_name='Job'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='JobPostingImage',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('post_image', models.ImageField(upload_to='post/', validators=[recruitment.validators.validate_image_size])),
|
||||
('job', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='post_images', to='recruitment.jobposting')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Profile',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('profile_image', models.ImageField(blank=True, null=True, upload_to='profile_pic/', validators=[recruitment.validators.validate_image_size])),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SharedFormTemplate',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
|
||||
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
|
||||
('is_public', models.BooleanField(default=False, help_text='Whether this template is publicly available')),
|
||||
('shared_with', models.ManyToManyField(blank=True, related_name='shared_templates', to=settings.AUTH_USER_MODEL)),
|
||||
('template', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='recruitment.formtemplate')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Shared Form Template',
|
||||
'verbose_name_plural': 'Shared Form Templates',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='IntegrationLog',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
|
||||
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
|
||||
('action', models.CharField(choices=[('REQUEST', 'Request'), ('RESPONSE', 'Response'), ('ERROR', 'Error'), ('SYNC', 'Sync'), ('CREATE_JOB', 'Create Job'), ('UPDATE_JOB', 'Update Job')], max_length=20, verbose_name='Action')),
|
||||
('endpoint', models.CharField(blank=True, max_length=255, verbose_name='Endpoint')),
|
||||
('method', models.CharField(blank=True, max_length=10, verbose_name='HTTP Method')),
|
||||
('request_data', models.JSONField(blank=True, null=True, verbose_name='Request Data')),
|
||||
('response_data', models.JSONField(blank=True, null=True, verbose_name='Response Data')),
|
||||
('status_code', models.CharField(blank=True, max_length=10, verbose_name='Status Code')),
|
||||
('error_message', models.TextField(blank=True, verbose_name='Error Message')),
|
||||
('ip_address', models.GenericIPAddressField(verbose_name='IP Address')),
|
||||
('user_agent', models.CharField(blank=True, max_length=255, verbose_name='User Agent')),
|
||||
('processing_time', models.FloatField(blank=True, null=True, verbose_name='Processing Time (seconds)')),
|
||||
('source', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='integration_logs', to='recruitment.source', verbose_name='Source')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Integration Log',
|
||||
'verbose_name_plural': 'Integration Logs',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TrainingMaterial',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
|
||||
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
|
||||
('title', models.CharField(max_length=255, verbose_name='Title')),
|
||||
('content', django_ckeditor_5.fields.CKEditor5Field(blank=True, verbose_name='Content')),
|
||||
('video_link', models.URLField(blank=True, verbose_name='Video Link')),
|
||||
('file', models.FileField(blank=True, upload_to='training_materials/', verbose_name='File')),
|
||||
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Created by')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Training Material',
|
||||
'verbose_name_plural': 'Training Materials',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ScheduledInterview',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
|
||||
('interview_date', models.DateField(db_index=True, verbose_name='Interview Date')),
|
||||
('interview_time', models.TimeField(verbose_name='Interview Time')),
|
||||
('status', models.CharField(choices=[('scheduled', 'Scheduled'), ('confirmed', 'Confirmed'), ('cancelled', 'Cancelled'), ('completed', 'Completed')], db_index=True, default='scheduled', max_length=20)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('candidate', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.candidate')),
|
||||
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.jobposting')),
|
||||
('schedule', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interviews', to='recruitment.interviewschedule')),
|
||||
('zoom_meeting', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='interview', to='recruitment.zoommeeting')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='MeetingComment',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
|
||||
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
|
||||
('content', django_ckeditor_5.fields.CKEditor5Field(verbose_name='Content')),
|
||||
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='meeting_comments', to=settings.AUTH_USER_MODEL, verbose_name='Author')),
|
||||
('meeting', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='recruitment.zoommeeting', verbose_name='Meeting')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Meeting Comment',
|
||||
'verbose_name_plural': 'Meeting Comments',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='FieldResponse',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
|
||||
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
|
||||
('value', models.JSONField(blank=True, help_text='Response value (stored as JSON)', null=True)),
|
||||
('uploaded_file', models.FileField(blank=True, null=True, upload_to='form_uploads/')),
|
||||
('field', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='responses', to='recruitment.formfield')),
|
||||
('submission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='responses', to='recruitment.formsubmission')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Field Response',
|
||||
'verbose_name_plural': 'Field Responses',
|
||||
'indexes': [models.Index(fields=['submission'], name='recruitment_submiss_474130_idx'), models.Index(fields=['field'], name='recruitment_field_i_097e5b_idx')],
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='formsubmission',
|
||||
index=models.Index(fields=['submitted_at'], name='recruitment_submitt_7946c8_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='interviewschedule',
|
||||
index=models.Index(fields=['start_date'], name='recruitment_start_d_15d55e_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='interviewschedule',
|
||||
index=models.Index(fields=['end_date'], name='recruitment_end_dat_aeb00e_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='interviewschedule',
|
||||
index=models.Index(fields=['created_by'], name='recruitment_created_d0bdcc_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='formtemplate',
|
||||
index=models.Index(fields=['created_at'], name='recruitment_created_c21775_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='formtemplate',
|
||||
index=models.Index(fields=['is_active'], name='recruitment_is_acti_ae5efb_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='candidate',
|
||||
index=models.Index(fields=['stage'], name='recruitment_stage_f1c6eb_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='candidate',
|
||||
index=models.Index(fields=['created_at'], name='recruitment_created_73590f_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='jobposting',
|
||||
index=models.Index(fields=['status', 'created_at', 'title'], name='recruitment_status_8b77aa_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='jobposting',
|
||||
index=models.Index(fields=['slug'], name='recruitment_slug_004045_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='scheduledinterview',
|
||||
index=models.Index(fields=['job', 'status'], name='recruitment_job_id_f09e22_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='scheduledinterview',
|
||||
index=models.Index(fields=['interview_date', 'interview_time'], name='recruitment_intervi_7f5877_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='scheduledinterview',
|
||||
index=models.Index(fields=['candidate', 'job'], name='recruitment_candida_43d5b0_idx'),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-21 23:25
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='jobposting',
|
||||
name='application_start_date',
|
||||
),
|
||||
]
|
||||
@ -33,7 +33,6 @@ class Profile(models.Model):
|
||||
def __str__(self):
|
||||
return f"image for user {self.user}"
|
||||
|
||||
|
||||
class JobPosting(Base):
|
||||
# Basic Job Information
|
||||
JOB_TYPES = [
|
||||
@ -67,16 +66,16 @@ class JobPosting(Base):
|
||||
# Job Details
|
||||
description = CKEditor5Field(
|
||||
'Description',
|
||||
config_name='extends' # Matches the config name you defined in settings.py
|
||||
config_name='extends' # Matches the config name you defined in settings.py
|
||||
)
|
||||
|
||||
qualifications = CKEditor5Field(blank=True, null=True,
|
||||
config_name='extends'
|
||||
)
|
||||
qualifications = CKEditor5Field(blank=True,null=True,
|
||||
config_name='extends'
|
||||
)
|
||||
salary_range = models.CharField(
|
||||
max_length=200, blank=True, help_text="e.g., $60,000 - $80,000"
|
||||
)
|
||||
benefits = CKEditor5Field(blank=True, null=True, config_name='extends')
|
||||
benefits = CKEditor5Field(blank=True,null=True,config_name='extends')
|
||||
|
||||
# Application Information ---job detail apply link for the candidates
|
||||
application_url = models.URLField(
|
||||
@ -85,10 +84,10 @@ class JobPosting(Base):
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
|
||||
application_deadline = models.DateField(db_index=True) # Added index
|
||||
application_instructions = CKEditor5Field(
|
||||
blank=True, null=True, config_name='extends'
|
||||
|
||||
application_deadline = models.DateField(db_index=True) # Added index
|
||||
application_instructions =CKEditor5Field(
|
||||
blank=True, null=True,config_name='extends'
|
||||
)
|
||||
|
||||
# Internal Tracking
|
||||
@ -106,7 +105,7 @@ class JobPosting(Base):
|
||||
("ARCHIVED", "Archived"),
|
||||
]
|
||||
status = models.CharField(
|
||||
db_index=True, max_length=20, choices=STATUS_CHOICES, default="DRAFT" # Added index
|
||||
db_index=True, max_length=20, choices=STATUS_CHOICES, default="DRAFT" # Added index
|
||||
)
|
||||
|
||||
# hashtags for social media
|
||||
@ -130,7 +129,7 @@ class JobPosting(Base):
|
||||
)
|
||||
linkedin_posted_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
published_at = models.DateTimeField(db_index=True, null=True, blank=True) # Added index
|
||||
published_at = models.DateTimeField(db_index=True, null=True, blank=True) # Added index
|
||||
# University Specific Fields
|
||||
position_number = models.CharField(
|
||||
max_length=50, blank=True, help_text="University position number"
|
||||
@ -138,7 +137,7 @@ class JobPosting(Base):
|
||||
reporting_to = models.CharField(
|
||||
max_length=100, blank=True, help_text="Who this position reports to"
|
||||
)
|
||||
|
||||
|
||||
open_positions = models.PositiveIntegerField(
|
||||
default=1, help_text="Number of open positions for this job"
|
||||
)
|
||||
@ -150,11 +149,11 @@ class JobPosting(Base):
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="The system or channel from which this job posting originated or was first published.",
|
||||
db_index=True # Explicitly index ForeignKey
|
||||
db_index=True # Explicitly index ForeignKey
|
||||
)
|
||||
max_applications = models.PositiveIntegerField(
|
||||
default=1000, help_text="Maximum number of applications allowed", null=True, blank=True
|
||||
)
|
||||
default=1000, help_text="Maximum number of applications allowed",null=True,blank=True
|
||||
)
|
||||
hiring_agency = models.ManyToManyField(
|
||||
"HiringAgency",
|
||||
blank=True,
|
||||
@ -182,7 +181,7 @@ class JobPosting(Base):
|
||||
verbose_name = "Job Posting"
|
||||
verbose_name_plural = "Job Postings"
|
||||
indexes = [
|
||||
models.Index(fields=['status', 'created_at', 'title']),
|
||||
models.Index(fields=['status', 'created_at','title']),
|
||||
models.Index(fields=['slug']),
|
||||
]
|
||||
|
||||
@ -259,6 +258,7 @@ class JobPosting(Base):
|
||||
# Return True if any content remains after stripping tags and spaces.
|
||||
return bool(final_content)
|
||||
|
||||
|
||||
@property
|
||||
def has_description_content(self):
|
||||
"""Returns True if the description field has meaningful content."""
|
||||
@ -277,7 +277,6 @@ class JobPosting(Base):
|
||||
@property
|
||||
def has_application_instructions_content(self):
|
||||
return self._check_content(self.application_instructions)
|
||||
|
||||
@property
|
||||
def current_applications_count(self):
|
||||
"""Returns the current number of candidates associated with this job."""
|
||||
@ -290,13 +289,9 @@ class JobPosting(Base):
|
||||
return True
|
||||
|
||||
return self.current_applications_count >= self.max_applications
|
||||
|
||||
@property
|
||||
def all_candidates(self):
|
||||
return self.candidates.annotate(
|
||||
sortable_score=Coalesce(Cast('ai_analysis_data__analysis_data__match_score', output_field=IntegerField()),
|
||||
0)).order_by('-sortable_score')
|
||||
|
||||
return self.candidates.annotate(sortable_score=Coalesce(Cast('ai_analysis_data__analysis_data__match_score',output_field=IntegerField()),0)).order_by('-sortable_score')
|
||||
@property
|
||||
def screening_candidates(self):
|
||||
return self.all_candidates.filter(stage="Applied")
|
||||
@ -304,7 +299,6 @@ class JobPosting(Base):
|
||||
@property
|
||||
def exam_candidates(self):
|
||||
return self.all_candidates.filter(stage="Exam")
|
||||
|
||||
@property
|
||||
def interview_candidates(self):
|
||||
return self.all_candidates.filter(stage="Interview")
|
||||
@ -313,13 +307,11 @@ class JobPosting(Base):
|
||||
def offer_candidates(self):
|
||||
return self.all_candidates.filter(stage="Offer")
|
||||
|
||||
# counts
|
||||
|
||||
#counts
|
||||
@property
|
||||
def all_candidates_count(self):
|
||||
return self.candidates.annotate(
|
||||
sortable_score=Cast('ai_analysis_data__match_score', output_field=CharField())).order_by(
|
||||
'-sortable_score').count()
|
||||
|
||||
return self.candidates.annotate(sortable_score=Cast('ai_analysis_data__match_score',output_field=CharField())).order_by('-sortable_score').count()
|
||||
@property
|
||||
def screening_candidates_count(self):
|
||||
return self.all_candidates.filter(stage="Applied").count()
|
||||
@ -327,7 +319,6 @@ class JobPosting(Base):
|
||||
@property
|
||||
def exam_candidates_count(self):
|
||||
return self.all_candidates.filter(stage="Exam").count()
|
||||
|
||||
@property
|
||||
def interview_candidates_count(self):
|
||||
return self.all_candidates.filter(stage="Interview").count()
|
||||
@ -336,7 +327,6 @@ class JobPosting(Base):
|
||||
def offer_candidates_count(self):
|
||||
return self.all_candidates.filter(stage="Offer").count()
|
||||
|
||||
|
||||
class JobPostingImage(models.Model):
|
||||
job=models.OneToOneField('JobPosting',on_delete=models.CASCADE,related_name='post_images')
|
||||
post_image = models.ImageField(upload_to='post/',validators=[validate_image_size])
|
||||
@ -742,8 +732,10 @@ class FormTemplate(Base):
|
||||
blank=True, help_text="Description of the form template"
|
||||
)
|
||||
created_by = models.ForeignKey(
|
||||
User, on_delete=models.CASCADE, related_name="form_templates",null=True,blank=True, db_index=True
|
||||
)
|
||||
User, on_delete=models.CASCADE, related_name="form_templates",null=True,blank=True, db_index=True
|
||||
)
|
||||
# FIXME: on Delete model SETNULl
|
||||
|
||||
is_active = models.BooleanField(
|
||||
default=False, help_text="Whether this template is active"
|
||||
)
|
||||
|
||||
@ -10,7 +10,7 @@ logger = logging.getLogger(__name__)
|
||||
@receiver(post_save, sender=JobPosting)
|
||||
def format_job(sender, instance, created, **kwargs):
|
||||
if created:
|
||||
FormTemplate.objects.create(job=instance, is_active=True, name=instance.title)
|
||||
# FormTemplate.objects.create(job=instance, is_active=True, name=instance.title)
|
||||
async_task(
|
||||
'recruitment.tasks.format_job_description',
|
||||
instance.pk,
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@ -2,7 +2,6 @@ from django.urls import path
|
||||
from . import views_frontend
|
||||
from . import views
|
||||
from . import views_integration
|
||||
from . import views_source
|
||||
|
||||
urlpatterns = [
|
||||
path('', views_frontend.dashboard_view, name='dashboard'),
|
||||
@ -127,15 +126,6 @@ urlpatterns = [
|
||||
|
||||
|
||||
|
||||
# Source URLs
|
||||
path('sources/', views_source.SourceListView.as_view(), name='source_list'),
|
||||
path('sources/create/', views_source.SourceCreateView.as_view(), name='source_create'),
|
||||
path('sources/<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
|
||||
path('meetings/<slug:slug>/comments/add/', views.add_meeting_comment, name='add_meeting_comment'),
|
||||
|
||||
@ -283,7 +283,7 @@ def create_job(request):
|
||||
job_apply_url_relative=reverse('job_detail_candidate',kwargs={'slug':job.slug})
|
||||
job_apply_url_absolute=request.build_absolute_uri(job_apply_url_relative)
|
||||
job.application_url=job_apply_url_absolute
|
||||
# FormTemplate.objects.create(job=job, is_active=False, name=job.title,created_by=request.user)
|
||||
FormTemplate.objects.create(job=job, is_active=True, name=job.title,created_by=request.user)
|
||||
job.save()
|
||||
messages.success(request, f'Job "{job.title}" created successfully!')
|
||||
return redirect("job_list")
|
||||
@ -328,6 +328,7 @@ def edit_job(request, slug):
|
||||
def job_detail(request, slug):
|
||||
"""View details of a specific job"""
|
||||
job = get_object_or_404(JobPosting, slug=slug)
|
||||
|
||||
# Get all candidates for this job, ordered by most recent
|
||||
applicants = job.candidates.all().order_by("-created_at")
|
||||
|
||||
@ -613,15 +614,15 @@ def application_success(request,slug):
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@login_required
|
||||
def form_builder(request, template_slug=None):
|
||||
def form_builder(request, template_id=None):
|
||||
"""Render the form builder interface"""
|
||||
context = {}
|
||||
if template_slug:
|
||||
if template_id:
|
||||
template = get_object_or_404(
|
||||
FormTemplate, slug=template_slug
|
||||
FormTemplate, id=template_id, created_by=request.user
|
||||
)
|
||||
context['template']=template
|
||||
context["template_slug"] = template.slug
|
||||
context["template_id"] = template.id
|
||||
context["template_name"] = template.name
|
||||
return render(request, "forms/form_builder.html", context)
|
||||
|
||||
@ -634,12 +635,12 @@ def save_form_template(request):
|
||||
data = json.loads(request.body)
|
||||
template_name = data.get("name", "Untitled Form")
|
||||
stages_data = data.get("stages", [])
|
||||
template_slug = data.get("template_slug")
|
||||
template_id = data.get("template_id")
|
||||
|
||||
if template_slug:
|
||||
if template_id:
|
||||
# Update existing template
|
||||
template = get_object_or_404(
|
||||
FormTemplate, slug=template_slug
|
||||
FormTemplate, id=template_id, created_by=request.user
|
||||
)
|
||||
template.name = template_name
|
||||
template.save()
|
||||
@ -648,7 +649,7 @@ def save_form_template(request):
|
||||
else:
|
||||
# Create new template
|
||||
template = FormTemplate.objects.create(
|
||||
name=template_name
|
||||
name=template_name, created_by=request.user
|
||||
)
|
||||
|
||||
# Create stages and fields
|
||||
@ -684,7 +685,7 @@ def save_form_template(request):
|
||||
return JsonResponse(
|
||||
{
|
||||
"success": True,
|
||||
"template_slug": template.slug,
|
||||
"template_id": template.id,
|
||||
"message": "Form template saved successfully!",
|
||||
}
|
||||
)
|
||||
@ -693,9 +694,9 @@ def save_form_template(request):
|
||||
|
||||
|
||||
@require_http_methods(["GET"])
|
||||
def load_form_template(request, template_slug):
|
||||
def load_form_template(request, template_id):
|
||||
"""Load an existing form template"""
|
||||
template = get_object_or_404(FormTemplate, slug=template_slug)
|
||||
template = get_object_or_404(FormTemplate, id=template_id, created_by=request.user)
|
||||
|
||||
stages = []
|
||||
for stage in template.stages.all():
|
||||
@ -728,7 +729,6 @@ def load_form_template(request, template_slug):
|
||||
"success": True,
|
||||
"template": {
|
||||
"id": template.id,
|
||||
"template_slug": template.slug,
|
||||
"name": template.name,
|
||||
"description": template.description,
|
||||
"is_active": template.is_active,
|
||||
@ -743,7 +743,7 @@ def load_form_template(request, template_slug):
|
||||
def form_templates_list(request):
|
||||
"""List all form templates for the current user"""
|
||||
query = request.GET.get("q", "")
|
||||
templates = FormTemplate.objects.filter()
|
||||
templates = FormTemplate.objects.filter(created_by=request.user)
|
||||
|
||||
if query:
|
||||
templates = templates.filter(
|
||||
@ -783,7 +783,7 @@ def create_form_template(request):
|
||||
@require_http_methods(["GET"])
|
||||
def list_form_templates(request):
|
||||
"""List all form templates for the current user"""
|
||||
templates = FormTemplate.objects.filter().values(
|
||||
templates = FormTemplate.objects.filter(created_by=request.user).values(
|
||||
"id", "name", "description", "created_at", "updated_at"
|
||||
)
|
||||
return JsonResponse({"success": True, "templates": list(templates)})
|
||||
@ -793,25 +793,26 @@ def list_form_templates(request):
|
||||
@require_http_methods(["DELETE"])
|
||||
def delete_form_template(request, template_id):
|
||||
"""Delete a form template"""
|
||||
template = get_object_or_404(FormTemplate, id=template_id)
|
||||
template = get_object_or_404(FormTemplate, id=template_id, created_by=request.user)
|
||||
template.delete()
|
||||
return JsonResponse(
|
||||
{"success": True, "message": "Form template deleted successfully!"}
|
||||
)
|
||||
|
||||
|
||||
def form_wizard_view(request, template_slug):
|
||||
def form_wizard_view(request, template_id):
|
||||
"""Display the form as a step-by-step wizard"""
|
||||
template = get_object_or_404(FormTemplate, slug=template_slug, is_active=True)
|
||||
template = get_object_or_404(FormTemplate, pk=template_id, is_active=True)
|
||||
job_id = template.job.internal_job_id
|
||||
job=template.job
|
||||
is_limit_exceeded = job.is_application_limit_reached
|
||||
is_limit_exceeded=job.is_application_limit_reached
|
||||
if is_limit_exceeded:
|
||||
messages.error(
|
||||
request,
|
||||
'Application limit reached: This job is no longer accepting new applications. Please explore other available positions.'
|
||||
)
|
||||
return redirect('job_detail_candidate',slug=job.slug)
|
||||
|
||||
if job.is_expired:
|
||||
messages.error(
|
||||
request,
|
||||
@ -822,16 +823,14 @@ def form_wizard_view(request, template_slug):
|
||||
return render(
|
||||
request,
|
||||
"forms/form_wizard.html",
|
||||
{"template_slug": template_slug, "job_id": job_id},
|
||||
{"template_id": template_id, "job_id": job_id},
|
||||
)
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_POST
|
||||
def submit_form(request, template_slug):
|
||||
def submit_form(request, template_id):
|
||||
"""Handle form submission"""
|
||||
template = get_object_or_404(FormTemplate, slug=template_slug)
|
||||
job = template.job
|
||||
template = get_object_or_404(FormTemplate, id=template_id)
|
||||
if request.method == "POST":
|
||||
try:
|
||||
with transaction.atomic():
|
||||
@ -845,7 +844,6 @@ def submit_form(request, template_slug):
|
||||
{"success": False, "message": "Application limit reached for this job."}
|
||||
)
|
||||
submission = FormSubmission.objects.create(template=template)
|
||||
|
||||
# Process field responses
|
||||
for field_id, value in request.POST.items():
|
||||
if field_id.startswith("field_"):
|
||||
@ -890,7 +888,7 @@ def submit_form(request, template_slug):
|
||||
)
|
||||
submission.applicant_email = email.display_value
|
||||
submission.save()
|
||||
# time=timezone.now()
|
||||
time=timezone.now()
|
||||
Candidate.objects.create(
|
||||
first_name=first_name.display_value,
|
||||
last_name=last_name.display_value,
|
||||
@ -898,16 +896,10 @@ def submit_form(request, template_slug):
|
||||
phone=phone.display_value,
|
||||
address=address.display_value,
|
||||
resume=resume.get_file if resume.is_file else None,
|
||||
job=job
|
||||
job=submission.template.job,
|
||||
|
||||
)
|
||||
return JsonResponse(
|
||||
{
|
||||
"success": True,
|
||||
"message": "Form submitted successfully!",
|
||||
"redirect_url": reverse('application_success',kwargs={'slug':job.slug}),
|
||||
}
|
||||
)
|
||||
# return redirect('application_success',slug=job.slug)
|
||||
return redirect('application_success',slug=job.slug)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Candidate creation failed,{e}")
|
||||
@ -2052,6 +2044,7 @@ def user_detail(request, pk):
|
||||
user = get_object_or_404(User, pk=pk)
|
||||
|
||||
try:
|
||||
|
||||
profile_instance = user.profile
|
||||
profile_form = ProfileImageUploadForm(instance=profile_instance)
|
||||
except:
|
||||
@ -2205,10 +2198,6 @@ def account_toggle_status(request,pk):
|
||||
messages.error(f'Please correct the error below')
|
||||
|
||||
|
||||
# @login_required
|
||||
# def user_detail(requests,pk):
|
||||
# user=get_object_or_404(User,pk=pk)
|
||||
# return render(requests,'user/profile.html')
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
|
||||
@ -394,6 +394,27 @@ def dashboard_view(request):
|
||||
).count()
|
||||
high_potential_ratio = round((high_potential_count / total_candidates) * 100, 1) if total_candidates > 0 else 0
|
||||
|
||||
#donut chart data
|
||||
jobs=models.JobPosting.objects.all()
|
||||
selected_job_id=request.GET.get('selected_job_id')
|
||||
print(jobs)
|
||||
print(selected_job_id)
|
||||
apply_counts,exam_counts,interview_counts,offer_counts=[0]*4
|
||||
if selected_job_id:
|
||||
job=jobs.filter(internal_job_id=selected_job_id)
|
||||
apply_counts=job.screening_candidates_count or 0
|
||||
exam_counts=job.exam_candidates_count or 0
|
||||
interview_counts=job.interview_candidates_count or 0
|
||||
offer_counts=job.offer_candidates_count or 0
|
||||
|
||||
|
||||
|
||||
|
||||
applicant_stages=['APPLIED','EXAM','INTERVIEW','OFFER']
|
||||
stage_counts=[apply_counts,exam_counts,interview_counts,offer_counts]
|
||||
|
||||
|
||||
|
||||
|
||||
context = {
|
||||
'total_jobs': total_jobs,
|
||||
@ -409,8 +430,13 @@ def dashboard_view(request):
|
||||
'high_potential_count': high_potential_count,
|
||||
'high_potential_ratio': high_potential_ratio,
|
||||
'scored_ratio': scored_ratio,
|
||||
'applicant_stages':json.dumps(applicant_stages),
|
||||
'stage_counts':json.dumps(stage_counts),
|
||||
'jobs':'jobs',
|
||||
'selected_job_id':selected_job_id
|
||||
}
|
||||
return render(request, 'recruitment/dashboard.html', context)
|
||||
|
||||
@login_required
|
||||
def candidate_offer_view(request, slug):
|
||||
"""View for candidates in the Offer stage"""
|
||||
|
||||
@ -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)
|
||||
@ -16,9 +16,9 @@
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
{% endif %}
|
||||
|
||||
|
||||
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.6.0/css/all.min.css" integrity="sha512-Kc323vGBEqzTmouAECnVceyQqyqdsSiqLQISBL29aUW4U/M7pSPA/gEUZQqv1cwx4OnYxTxve5UMg5GT6L4JJg==" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
|
||||
|
||||
<link rel="stylesheet" href="{% static 'css/main.css' %}">
|
||||
|
||||
|
||||
@ -120,7 +120,7 @@
|
||||
{% endif %}
|
||||
</button>
|
||||
<ul
|
||||
|
||||
|
||||
class="dropdown-menu dropdown-menu-end py-0 shadow border-0 rounded-3"
|
||||
style="min-width: 240px;"
|
||||
>
|
||||
@ -324,34 +324,6 @@
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
function form_loader(){
|
||||
const forms = document.querySelectorAll('form');
|
||||
forms.forEach(form => {
|
||||
form.addEventListener('submit', function(e) {
|
||||
const submitButton = form.querySelector('button[type="submit"], input[type="submit"]');
|
||||
|
||||
if (submitButton) {
|
||||
submitButton.disabled = true;
|
||||
submitButton.classList.add('loading');
|
||||
window.addEventListener('unload', function() {
|
||||
submitButton.disabled = false;
|
||||
submitButton.classList.remove('loading');
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
form_loader();
|
||||
|
||||
try{
|
||||
document.addEventListener('htmx:afterSwap', form_loader);
|
||||
}catch(e){
|
||||
console.error(e)
|
||||
}
|
||||
|
||||
</script>
|
||||
{% block customJS %}{% endblock %}
|
||||
|
||||
|
||||
@ -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>
|
||||
@ -275,7 +275,7 @@
|
||||
{% elif active_tab == 'request' %}
|
||||
<tr>
|
||||
<td>{{ log.datetime|date:"Y-m-d H:i:s" }}</td>
|
||||
<td>{{ log.user.get_full_name|default:log.user.email|default:"Anonymous" }}</td>
|
||||
<td>{{ log.user.email|default:"Anonymous" }}</td>
|
||||
<td>
|
||||
<span class="badge rounded-pill badge-request-method">{{ log.method }}</span>
|
||||
</td>
|
||||
|
||||
@ -71,7 +71,7 @@
|
||||
border: 1px solid #ced4da;
|
||||
width: 100%;
|
||||
padding: 0.375rem 0.75rem;
|
||||
box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ================================================= */
|
||||
@ -100,7 +100,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4">
|
||||
<div class="container-fluid py-4">
|
||||
<h1 class="h3 mb-4 text-primary fw-bold">
|
||||
<i class="fas fa-bullhorn me-2"></i> {% if form.instance.pk %} {% trans "Edit Job Posting" %} {% else %} {% trans "Create New Job Posting" %} {% endif %}
|
||||
</h1>
|
||||
@ -150,11 +150,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{# ================================================= #}
|
||||
{# SECTION 2: INTERNAL AND PROMOTION #}
|
||||
{# ================================================= #}
|
||||
|
||||
|
||||
<div class="card mb-4 shadow-sm">
|
||||
<div class="card-header-themed">
|
||||
<h5><i class="fas fa-tags"></i> {% trans "Internal & Promotion" %}</h5>
|
||||
@ -189,8 +189,8 @@
|
||||
{% if form.max_applications.errors %}<div class="text-danger small mt-1">{{ form.max_applications.errors }}</div>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="col-12">
|
||||
<div>
|
||||
<label for="{{ form.hash_tags.id_for_label }}" class="form-label">{% trans "Hashtags (For Promotion/Search on Linkedin)" %}</label>
|
||||
@ -203,11 +203,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{# ================================================= #}
|
||||
{# SECTION 3: LOCATION AND DATES #}
|
||||
{# ================================================= #}
|
||||
|
||||
|
||||
<div class="card mb-4 shadow-sm">
|
||||
<div class="card-header-themed">
|
||||
<h5><i class="fas fa-map-marker-alt"></i> {% trans "Location, Dates, & Salary" %}</h5>
|
||||
@ -251,7 +251,7 @@
|
||||
{% if form.salary_range.errors %}<div class="text-danger small mt-1">{{ form.salary_range.errors }}</div>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -292,7 +292,7 @@
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-4">
|
||||
|
||||
|
||||
{% comment %} (application_url comment removed for brevity) {% endcomment %}
|
||||
|
||||
<div class="col-12">
|
||||
|
||||
@ -71,7 +71,7 @@
|
||||
border: 1px solid #ced4da;
|
||||
width: 100%;
|
||||
padding: 0.375rem 0.75rem;
|
||||
box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ================================================= */
|
||||
@ -100,7 +100,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4">
|
||||
<div class="container-fluid py-4">
|
||||
<h1 class="h3 mb-4 text-primary fw-bold">
|
||||
<i class="fas fa-bullhorn me-2"></i> {% if form.instance.pk %} {% trans "Edit Job Posting" %} {% else %} {% trans "Create New Job Posting" %} {% endif %}
|
||||
</h1>
|
||||
@ -150,11 +150,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{# ================================================= #}
|
||||
{# SECTION 2: INTERNAL AND PROMOTION #}
|
||||
{# ================================================= #}
|
||||
|
||||
|
||||
<div class="card mb-4 shadow-sm">
|
||||
<div class="card-header-themed">
|
||||
<h5><i class="fas fa-tags"></i> {% trans "Internal & Promotion" %}</h5>
|
||||
@ -189,8 +189,8 @@
|
||||
{% if form.max_applications.errors %}<div class="text-danger small mt-1">{{ form.max_applications.errors }}</div>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="col-12">
|
||||
<div>
|
||||
<label for="{{ form.hash_tags.id_for_label }}" class="form-label">{% trans "Hashtags (For Promotion/Search on Linkedin)" %}</label>
|
||||
@ -203,11 +203,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{# ================================================= #}
|
||||
{# SECTION 3: LOCATION AND DATES #}
|
||||
{# ================================================= #}
|
||||
|
||||
|
||||
<div class="card mb-4 shadow-sm">
|
||||
<div class="card-header-themed">
|
||||
<h5><i class="fas fa-map-marker-alt"></i> {% trans "Location, Dates, & Salary" %}</h5>
|
||||
@ -251,7 +251,7 @@
|
||||
{% if form.salary_range.errors %}<div class="text-danger small mt-1">{{ form.salary_range.errors }}</div>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -292,7 +292,7 @@
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-4">
|
||||
|
||||
|
||||
{% comment %} (application_url comment removed for brevity) {% endcomment %}
|
||||
|
||||
<div class="col-12">
|
||||
|
||||
@ -217,8 +217,8 @@
|
||||
}
|
||||
|
||||
/* Custom CSS for simplified stat card (from previous answer) */
|
||||
.stats-grid .kpi-card {
|
||||
border-left: 4px solid var(--kaauh-teal);
|
||||
.stats-grid .kpi-card {
|
||||
border-left: 4px solid var(--kaauh-teal);
|
||||
background-color: #f0faff;
|
||||
}
|
||||
.stats-grid .card-body {
|
||||
@ -296,7 +296,7 @@
|
||||
<i class="fas fa-chart-line me-1"></i> {% trans "Application KPIs" %}
|
||||
</button>
|
||||
</li>
|
||||
|
||||
|
||||
</ul>
|
||||
|
||||
<div class="card-body">
|
||||
@ -338,7 +338,7 @@
|
||||
id="copyJobLinkButton"
|
||||
data-url="{{ job.application_url }}">
|
||||
{# Replaced bulky SVG with simpler Font Awesome icon #}
|
||||
<i class="fas fa-link"></i>
|
||||
<i class="fas fa-link"></i>
|
||||
{% trans "Share Public Link" %}
|
||||
</button>
|
||||
|
||||
@ -412,7 +412,7 @@
|
||||
{# TAB 3 CONTENT: APPLICATION KPIS #}
|
||||
<div class="tab-pane fade" id="kpis" role="tabpanel" aria-labelledby="kpis-tab">
|
||||
<div class="row g-3 stats-grid">
|
||||
|
||||
|
||||
{# 1. Job Avg. Score #}
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card text-center h-100 kpi-card">
|
||||
@ -461,13 +461,13 @@
|
||||
<p class="text-end text-muted small mt-3 me-2">
|
||||
<i class="fas fa-info-circle me-1"></i> {% trans "KPIs based on completed applicant data." %}
|
||||
</p>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
{# FOOTER ACTIONS #}
|
||||
@ -504,7 +504,7 @@
|
||||
</div>
|
||||
|
||||
{# REMOVED: Standalone Applicant Tracking Card (It is now in a tab) #}
|
||||
|
||||
|
||||
<div class="card shadow-sm no-hover">
|
||||
|
||||
{# RIGHT TABS NAVIGATION #}
|
||||
@ -536,7 +536,7 @@
|
||||
{# TAB 1: APPLICANTS CONTENT #}
|
||||
<div class="tab-pane fade show active" id="applicants-pane" role="tabpanel" aria-labelledby="applicants-tab">
|
||||
<h5 class="mb-3">{% trans "Total Applicants" %} (<span id="total_candidates">{{ total_applicants }}</span>)</h5>
|
||||
|
||||
|
||||
<div class="d-grid gap-4">
|
||||
<a href="{% url 'candidate_create_for_job' job.slug %}" class="btn btn-main-action">
|
||||
<i class="fas fa-user-plus"></i> {% trans "Create Applicant" %}
|
||||
|
||||
@ -224,7 +224,7 @@ body {
|
||||
<div class="detail-row"><div class="detail-label">{% trans "Host Email" %}:</div><div class="detail-value">{{ meeting.host_email|default:"N/A" }}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{# --- ACTION BAR AT THE BOTTOM OF THE MAIN CARD --- #}
|
||||
<div class="card-footer action-bar-footer d-flex justify-content-end">
|
||||
<a href="{% url 'update_meeting' meeting.slug %}" class="btn btn-primary btn-footer-action">
|
||||
@ -254,7 +254,7 @@ body {
|
||||
<div class="card no-hover join-info-card detail-section h-100">
|
||||
<div class="card-body">
|
||||
<h2>{% trans "Join Information" %}</h2>
|
||||
|
||||
|
||||
<a href="{{ meeting.join_url }}" class="btn btn-primary w-100 mb-4" target="_blank">
|
||||
<i class="fas fa-video me-1"></i> {% trans "Join Meeting Now" %}
|
||||
</a>
|
||||
@ -264,7 +264,7 @@ body {
|
||||
|
||||
<div class="join-url-display d-flex justify-content-between align-items-center">
|
||||
<div class="text-truncate">
|
||||
<strong>{% trans "Join URL" %}:</strong>
|
||||
<strong>{% trans "Join URL" %}:</strong>
|
||||
<span id="meeting-join-url">{{ meeting.join_url }}</span>
|
||||
</div>
|
||||
<button class="btn-copy ms-2" onclick="copyLink()" title="{% trans 'Copy URL' %}">
|
||||
@ -285,7 +285,7 @@ body {
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{# --- API RESPONSE CARD (Full width, hidden by default) --- #}
|
||||
{% if meeting.zoom_gateway_response %}
|
||||
<div id="gateway-response-card" class="card mt-4" style="display: none;">
|
||||
|
||||
@ -65,6 +65,11 @@
|
||||
</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-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: 15%"><i class="fas fa-cog me-1"></i> {% trans "Actions" %}</th>
|
||||
</tr>
|
||||
@ -88,6 +93,57 @@
|
||||
<i class="fas fa-phone me-1"></i> {{ candidate.phone }}
|
||||
</div>
|
||||
</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">
|
||||
{% if not candidate.offer_status %}
|
||||
<button type="button" class="btn btn-warning btn-sm"
|
||||
|
||||
@ -547,6 +547,33 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3" style="font-size: 0.9rem;">
|
||||
<span style="display: inline-flex; align-items: center;">
|
||||
<a href="{% url 'job_list' %}"
|
||||
style="color: white; text-decoration: none; padding-right: 8px;">
|
||||
JOBS
|
||||
</a>
|
||||
<span style="color: #6c757d; padding-right: 8px;">/</span>
|
||||
|
||||
<a href="{% url 'candidate_list' %}"
|
||||
style="color: white; text-decoration: none; padding-right: 8px;">
|
||||
CANDIDATES
|
||||
</a>
|
||||
<span style="color: #6c757d; padding-right: 8px;">/</span>
|
||||
|
||||
<a href="{% url 'candidate_detail' candidate.id %}"
|
||||
style="color: white; text-decoration: none; padding-right: 8px;">
|
||||
CANDIDATE
|
||||
</a>
|
||||
<span style="color: #6c757d; padding-right: 8px;">/</span>
|
||||
|
||||
<span style="color:gray; font-weight: 600;">
|
||||
RESUME Overview
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="score-box">
|
||||
<div class="score-value">{{ candidate.analysis_data.match_score|default:0 }}%</div>
|
||||
<div class="score-text">Match Score</div>
|
||||
|
||||
@ -151,6 +151,26 @@
|
||||
<canvas id="applicationsChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="card shadow-lg">
|
||||
<div class="card-header">
|
||||
<h2 class="d-flex align-items-center mb-0">
|
||||
Select a job from the drop
|
||||
</h2>
|
||||
<form method="GET" action="{% url 'dashboard' %}">
|
||||
<select name="selected_job_id" onchange="this.form.submit()">
|
||||
<option value="">Show All</option>
|
||||
{% for job in jobs%}
|
||||
<option value="job" {% if request.GET.selected_job_id == selected_job_id %}selected{% endif %}>job</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</form>
|
||||
</div>
|
||||
<div style="width: 75%; margin: auto;">
|
||||
<canvas id="job-doughnut-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
@ -218,6 +238,54 @@
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const applicant_stages=JSON.parse('{{applicant_stages|safe}}');
|
||||
const stage_counts=JSON.parse('{{stage_counts|safe}}');
|
||||
console.log(applicant_stages)
|
||||
console.log(stage_counts)
|
||||
|
||||
const job_data = {
|
||||
labels: applicant_stages,
|
||||
datasets: [{
|
||||
label: 'My Doughnut Dataset',
|
||||
data: stage_counts,
|
||||
backgroundColor: [ // Define colors for your slices
|
||||
'rgb(255, 99, 132)', // Red
|
||||
'rgb(54, 162, 235)', // Blue
|
||||
'rgb(255, 205, 86)', // Yellow
|
||||
'rgb(200,200,100)',
|
||||
'rgb(30,40,80)',
|
||||
],
|
||||
hoverOffset: 4
|
||||
}]
|
||||
};
|
||||
|
||||
const dnt_config = {
|
||||
// Set type to 'doughnut'
|
||||
type: 'doughnut',
|
||||
data: job_data,
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top',
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Django Data Doughnut Chart'
|
||||
}
|
||||
},
|
||||
// The 'cutout' option is what makes it a doughnut chart (defaults to '50%')
|
||||
// cutout: '50%',
|
||||
}
|
||||
};
|
||||
|
||||
// 4. Initialize and render the chart
|
||||
const ctx_dnt = document.getElementById('job-doughnut-chart').getContext('2d');
|
||||
new Chart(ctx_dnt, dnt_config);
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
@ -49,6 +49,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
jhashkdhkashkdhkash
|
||||
<div class="container mt-4">
|
||||
<div class="detail-header">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
|
||||
@ -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