add source crud

This commit is contained in:
ismail 2025-10-22 13:10:03 +03:00
parent 3086b38a23
commit ee78018a5a
15 changed files with 1792 additions and 319 deletions

View File

@ -1426,3 +1426,178 @@ msgstr ""
#: templates/unfold/components/table.html:43
msgid "No data"
msgstr ""
# Source Management
msgid "Data Sources"
msgstr "مصادر البيانات"
msgid "Create New Source"
msgstr "إنشاء مصدر جديد"
msgid "Search by name, type, or description..."
msgstr "البحث بالاسم، النوع، أو الوصف..."
msgid "Filter"
msgstr "تصفية"
msgid "Available Sources"
msgstr "المصادر المتاحة"
msgid "sources"
msgstr "مصادر"
msgid "View Details"
msgstr "عرض التفاصيل"
msgid "Edit"
msgstr "تعديل"
msgid "Delete"
msgstr "حذف"
msgid "First"
msgstr "الأول"
msgid "Previous"
msgstr "السابق"
msgid "Next"
msgstr "التالي"
msgid "Last"
msgstr "الأخير"
msgid "No sources found"
msgstr "لم يتم العثور على مصادر"
msgid "Get started by creating your first data source."
msgstr "ابدأ بإنشاء أول مصدر بيانات لك."
msgid "Network Configuration"
msgstr "تكوين الشبكة"
msgid "Settings"
msgstr "الإعدادات"
msgid "API Configuration"
msgstr "تكوين واجهة برمجة التطبيقات"
msgid "API Keys"
msgstr "مفاتيح واجهة برمجة التطبيقات"
msgid "Generate secure API keys for external integrations"
msgstr "إنشاء مفاتيح واجهة برمجة التطبيقات الآمنة للتكاملات الخارجية"
msgid "Active"
msgstr "نشط"
msgid "Inactive"
msgstr "غير نشط"
msgid "Not generated"
msgstr "لم يتم إنشاؤه"
msgid "Created By"
msgstr "أنشأ بواسطة"
msgid "Created At"
msgstr "أنشأ في"
msgid "Updated At"
msgstr "حدث في"
msgid "IP Address"
msgstr "عنوان IP"
msgid "Trusted IPs"
msgstr "عناوين IP الموثوقة"
msgid "Integration Version"
msgstr "إصدار التكامل"
msgid "API Key"
msgstr "مفتاح واجهة برمجة التطبيقات"
msgid "API Secret"
msgstr "سر واجهة برمجة التطبيقات"
msgid "Recent Integration Logs"
msgstr "سجلات التكامل الأخيرة"
msgid "Time"
msgstr "الوقت"
msgid "Action"
msgstr "الإجراء"
msgid "Endpoint"
msgstr "نقطة النهاية"
msgid "Method"
msgstr "الطريقة"
msgid "Status"
msgstr "الحالة"
msgid "Success"
msgstr "نجح"
msgid "Failed"
msgstr "فشل"
msgid "No integration logs found"
msgstr "لم يتم العثور على سجلات تكامل"
msgid "Integration logs will appear here when this source is used for external integrations."
msgstr "ستظهر سجلات التكامل هنا عند استخدام هذا المصدر للتكاملات الخارجية."
msgid "Delete Source"
msgstr "حذف المصدر"
msgid "Confirm Deletion"
msgstr "تأكيد الحذف"
msgid "Are you sure you want to delete the following source? This action cannot be undone."
msgstr "هل أنت متأكد من رغبتك في حذف المصدر التالي؟ لا يمكن التراجع عن هذا الإجراء."
msgid "Important Note"
msgstr "ملاحظة هامة"
msgid "All associated API keys will be permanently deleted."
msgstr "ستتم حذف جميع مفاتيح واجهة برمجة التطبيقات المرتبطة بشكل دائم."
msgid "Integration logs related to this source will remain but will show 'Source deleted'."
msgstr "ستبقى سجلات التكامل المتعلقة بهذا المصدر ولكنها ستظهر 'تم حذف المصدر'."
msgid "Any active integrations using this source will be disconnected."
msgstr "سيتم فصل أي تكاملات نشطة تستخدم هذا المصدر."
msgid "Back to List"
msgstr "العودة إلى القائمة"
msgid "Delete Source"
msgstr "حذف المصدر"
msgid "Generate API Keys"
msgstr "إنشاء مفاتيح واجهة برمجة التطبيقات"
msgid "Copy to Clipboard"
msgstr "نسخ إلى الحافظة"
msgid "Failed to copy to clipboard"
msgstr "فشل نسخ إلى الحافظة"
msgid "Generate random API key"
msgstr "إنشاء مفتاح واجهة برمجة تطبيقات عشوائي"
msgid "Generate random API secret"
msgstr "إنشاء سر واجهة برمجة تطبيقات عشوائي"
msgid "Source updated successfully."
msgstr "تم تحديث المصدر بنجاح."
msgid "Source created successfully."
msgstr "تم إنشاء المصدر بنجاح."
msgid "Source deleted successfully."
msgstr "تم حذف المصدر بنجاح."

View File

@ -1425,3 +1425,178 @@ msgstr ""
#: templates/unfold/components/table.html:43
msgid "No data"
msgstr ""
# Source Management
msgid "Data Sources"
msgstr ""
msgid "Create New Source"
msgstr ""
msgid "Search by name, type, or description..."
msgstr ""
msgid "Filter"
msgstr ""
msgid "Available Sources"
msgstr ""
msgid "sources"
msgstr ""
msgid "View Details"
msgstr ""
msgid "Edit"
msgstr ""
msgid "Delete"
msgstr ""
msgid "First"
msgstr ""
msgid "Previous"
msgstr ""
msgid "Next"
msgstr ""
msgid "Last"
msgstr ""
msgid "No sources found"
msgstr ""
msgid "Get started by creating your first data source."
msgstr ""
msgid "Network Configuration"
msgstr ""
msgid "Settings"
msgstr ""
msgid "API Configuration"
msgstr ""
msgid "API Keys"
msgstr ""
msgid "Generate secure API keys for external integrations"
msgstr ""
msgid "Active"
msgstr ""
msgid "Inactive"
msgstr ""
msgid "Not generated"
msgstr ""
msgid "Created By"
msgstr ""
msgid "Created At"
msgstr ""
msgid "Updated At"
msgstr ""
msgid "IP Address"
msgstr ""
msgid "Trusted IPs"
msgstr ""
msgid "Integration Version"
msgstr ""
msgid "API Key"
msgstr ""
msgid "API Secret"
msgstr ""
msgid "Recent Integration Logs"
msgstr ""
msgid "Time"
msgstr ""
msgid "Action"
msgstr ""
msgid "Endpoint"
msgstr ""
msgid "Method"
msgstr ""
msgid "Status"
msgstr ""
msgid "Success"
msgstr ""
msgid "Failed"
msgstr ""
msgid "No integration logs found"
msgstr ""
msgid "Integration logs will appear here when this source is used for external integrations."
msgstr ""
msgid "Delete Source"
msgstr ""
msgid "Confirm Deletion"
msgstr ""
msgid "Are you sure you want to delete the following source? This action cannot be undone."
msgstr ""
msgid "Important Note"
msgstr ""
msgid "All associated API keys will be permanently deleted."
msgstr ""
msgid "Integration logs related to this source will remain but will show 'Source deleted'."
msgstr ""
msgid "Any active integrations using this source will be disconnected."
msgstr ""
msgid "Back to List"
msgstr ""
msgid "Delete Source"
msgstr ""
msgid "Generate API Keys"
msgstr ""
msgid "Copy to Clipboard"
msgstr ""
msgid "Failed to copy to clipboard"
msgstr ""
msgid "Generate random API key"
msgstr ""
msgid "Generate random API secret"
msgstr ""
msgid "Source updated successfully."
msgstr ""
msgid "Source created successfully."
msgstr ""
msgid "Source deleted successfully."
msgstr ""

View File

@ -1,20 +1,171 @@
from django import forms
from .validators import validate_hash_tags
from django.core.validators import URLValidator
from django.forms.formsets import formset_factory
from django.utils.translation import gettext_lazy as _
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Submit, Row, Column, Field, Div
from django.contrib.auth.models import User
from django.contrib.auth.forms import UserCreationForm
import re
from .models import (
ZoomMeeting, Candidate,TrainingMaterial,JobPosting,
FormTemplate,InterviewSchedule,BreakTime,JobPostingImage,
Profile,MeetingComment,ScheduledInterview
Profile,MeetingComment,ScheduledInterview,Source
)
# from django_summernote.widgets import SummernoteWidget
from django_ckeditor_5.widgets import CKEditor5Widget
import secrets
import string
from django.core.exceptions import ValidationError
def generate_api_key(length=32):
"""Generate a secure API key"""
alphabet = string.ascii_letters + string.digits
return ''.join(secrets.choice(alphabet) for _ in range(length))
def generate_api_secret(length=64):
"""Generate a secure API secret"""
alphabet = string.ascii_letters + string.digits + '-._~'
return ''.join(secrets.choice(alphabet) for _ in range(length))
class SourceForm(forms.ModelForm):
"""Form for creating and editing sources with API key generation"""
# Hidden field to trigger API key generation
generate_keys = forms.CharField(
widget=forms.HiddenInput(),
required=False,
help_text="Set to 'true' to generate new API keys"
)
# Display fields for generated keys (read-only)
api_key_generated = forms.CharField(
label="Generated API Key",
required=False,
widget=forms.TextInput(attrs={'readonly': True, 'class': 'form-control'})
)
api_secret_generated = forms.CharField(
label="Generated API Secret",
required=False,
widget=forms.TextInput(attrs={'readonly': True, 'class': 'form-control'})
)
class Meta:
model = Source
fields = [
'name', 'source_type', 'description', 'ip_address',
'trusted_ips', 'is_active', 'integration_version'
]
widgets = {
'name': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'e.g., ATS System, ERP Integration',
'required': True
}),
'source_type': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'e.g., ATS, ERP, API',
'required': True
}),
'description': forms.Textarea(attrs={
'class': 'form-control',
'rows': 3,
'placeholder': 'Brief description of the source system'
}),
'ip_address': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': '192.168.1.100'
}),
'trusted_ips': forms.Textarea(attrs={
'class': 'form-control',
'rows': 2,
'placeholder': 'Comma-separated IP addresses (e.g., 192.168.1.100, 10.0.0.1)'
}),
'integration_version': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'v1.0, v2.1'
}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_method = 'post'
self.helper.form_class = 'form-horizontal'
self.helper.label_class = 'col-md-3'
self.helper.field_class = 'col-md-9'
# Add generate keys button
self.helper.layout = Layout(
Field('name', css_class='form-control'),
Field('source_type', css_class='form-control'),
Field('description', css_class='form-control'),
Field('ip_address', css_class='form-control'),
Field('trusted_ips', css_class='form-control'),
Field('integration_version', css_class='form-control'),
Field('is_active', css_class='form-check-input'),
# Hidden field for key generation trigger
Field('generate_keys', type='hidden'),
# Display fields for generated keys
Field('api_key_generated', css_class='form-control'),
Field('api_secret_generated', css_class='form-control'),
Submit('submit', 'Save Source', css_class='btn btn-primary mt-3')
)
def clean_name(self):
"""Ensure source name is unique"""
name = self.cleaned_data.get('name')
if name:
# Check for duplicates excluding current instance if editing
instance = self.instance
if not instance.pk: # Creating new instance
if Source.objects.filter(name=name).exists():
raise ValidationError('A source with this name already exists.')
else: # Editing existing instance
if Source.objects.filter(name=name).exclude(pk=instance.pk).exists():
raise ValidationError('A source with this name already exists.')
return name
def clean_trusted_ips(self):
"""Validate and format trusted IP addresses"""
trusted_ips = self.cleaned_data.get('trusted_ips')
if trusted_ips:
# Split by comma and strip whitespace
ips = [ip.strip() for ip in trusted_ips.split(',') if ip.strip()]
# Validate each IP address
for ip in ips:
try:
# Basic IP validation (can be enhanced)
if not (ip.replace('.', '').isdigit() and len(ip.split('.')) == 4):
raise ValidationError(f'Invalid IP address: {ip}')
except Exception:
raise ValidationError(f'Invalid IP address: {ip}')
return ', '.join(ips)
return trusted_ips
def clean(self):
"""Custom validation for the form"""
cleaned_data = super().clean()
# Check if we need to generate API keys
generate_keys = cleaned_data.get('generate_keys')
if generate_keys == 'true':
# Generate new API key and secret
cleaned_data['api_key'] = generate_api_key()
cleaned_data['api_secret'] = generate_api_secret()
# Set display fields for the frontend
cleaned_data['api_key_generated'] = cleaned_data['api_key']
cleaned_data['api_secret_generated'] = cleaned_data['api_secret']
return cleaned_data
class CandidateForm(forms.ModelForm):
class Meta:
@ -72,51 +223,6 @@ class CandidateStageForm(forms.ModelForm):
'stage': forms.Select(attrs={'class': 'form-select'}),
}
# def __init__(self, *args, **kwargs):
# # Get the current candidate instance for validation
# self.candidate = kwargs.pop('candidate', None)
# super().__init__(*args, **kwargs)
# # Dynamically filter stage choices based on current stage
# if self.candidate and self.candidate.pk:
# current_stage = self.candidate.stage
# available_stages = self.candidate.get_available_stages()
# # Filter choices to only include available stages
# choices = [(stage, self.candidate.Stage(stage).label)
# for stage in available_stages]
# self.fields['stage'].choices = choices
# # Set initial value to current stage
# self.fields['stage'].initial = current_stage
# else:
# # For new candidates, only show 'Applied' stage
# self.fields['stage'].choices = [('Applied', _('Applied'))]
# self.fields['stage'].initial = 'Applied'
# def clean_stage(self):
# """Validate stage transition"""
# new_stage = self.cleaned_data.get('stage')
# if not new_stage:
# raise forms.ValidationError(_('Please select a stage.'))
# # Use model validation for stage transitions
# if self.candidate and self.candidate.pk:
# current_stage = self.candidate.stage
# if new_stage != current_stage:
# if not self.candidate.can_transition_to(new_stage):
# allowed_stages = self.candidate.get_available_stages()
# raise forms.ValidationError(
# _('Cannot transition from "%(current)s" to "%(new)s". '
# 'Allowed transitions: %(allowed)s') % {
# 'current': current_stage,
# 'new': new_stage,
# 'allowed': ', '.join(allowed_stages) or 'None (final stage)'
# }
# )
# return new_stage
class ZoomMeetingForm(forms.ModelForm):
class Meta:
model = ZoomMeeting
@ -146,8 +252,6 @@ class ZoomMeetingForm(forms.ModelForm):
Submit('submit', _('Create Meeting'), css_class='btn btn-primary')
)
# Old JobForm removed - replaced by JobPostingForm
class TrainingMaterialForm(forms.ModelForm):
class Meta:
model = TrainingMaterial
@ -160,13 +264,11 @@ class TrainingMaterialForm(forms.ModelForm):
}
widgets = {
'title': forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Enter material title')}),
# 💡 Use SummernoteWidget here
# 'content': SummernoteWidget(attrs={'placeholder': _('Enter material content')}),
'content': CKEditor5Widget(attrs={'placeholder': _('Enter material content')}),
'video_link': forms.URLInput(attrs={'class': 'form-control', 'placeholder': _('https://www.youtube.com/watch?v=...')}),
'file': forms.FileInput(attrs={'class': 'form-control'}),
}
# The __init__ and FormHelper layout remains the same
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
@ -175,7 +277,7 @@ class TrainingMaterialForm(forms.ModelForm):
self.helper.layout = Layout(
'title',
'content', # Summernote is applied via the widgets dictionary
'content',
Row(
Column('video_link', css_class='col-md-6'),
Column('file', css_class='col-md-6'),
@ -188,7 +290,6 @@ class TrainingMaterialForm(forms.ModelForm):
)
)
class JobPostingForm(forms.ModelForm):
"""Form for creating and editing job postings"""
@ -203,7 +304,6 @@ class JobPostingForm(forms.ModelForm):
'created_by','open_positions','hash_tags','max_applications'
]
widgets = {
# Basic Information
'title': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Assistant Professor of Computer Science',
@ -221,8 +321,6 @@ class JobPostingForm(forms.ModelForm):
'class': 'form-select',
'required': True
}),
# Location
'location_city': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Boston'
@ -235,20 +333,10 @@ class JobPostingForm(forms.ModelForm):
'class': 'form-control',
'value': 'United States'
}),
'salary_range': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': '$60,000 - $80,000'
}),
# Application Information
# 'application_url': forms.URLInput(attrs={
# 'class': 'form-control',
# 'placeholder': 'https://university.edu/careers/job123',
# 'required': True
# }),
'application_start_date': forms.DateInput(attrs={
'class': 'form-control',
'type': 'date'
@ -257,7 +345,6 @@ class JobPostingForm(forms.ModelForm):
'class': 'form-control',
'type': 'date'
}),
'open_positions': forms.NumberInput(attrs={
'class': 'form-control',
'min': 1,
@ -266,10 +353,7 @@ class JobPostingForm(forms.ModelForm):
'hash_tags': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': '#hiring,#jobopening',
# 'validators':validate_hash_tags, # Assuming this is available
}),
# Internal Information
'position_number': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'UNIV-2025-001'
@ -282,7 +366,6 @@ class JobPostingForm(forms.ModelForm):
'class': 'form-control',
'type': 'date'
}),
'created_by': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'University Administrator'
@ -295,21 +378,16 @@ class JobPostingForm(forms.ModelForm):
}
def __init__(self,*args,**kwargs):
# Extract your custom argument BEFORE calling super()
self.is_anonymous_user = kwargs.pop('is_anonymous_user', False)
# Now call the parent __init__ with remaining args
super().__init__(*args, **kwargs)
if not self.instance.pk:# Creating new job posting
if not self.instance.pk:
if not self.is_anonymous_user:
self.fields['created_by'].initial = 'University Administrator'
# self.fields['status'].initial = 'Draft'
self.fields['location_city'].initial='Riyadh'
self.fields['location_state'].initial='Riyadh Province'
self.fields['location_country'].initial='Saudi Arabia'
def clean_hash_tags(self):
hash_tags=self.cleaned_data.get('hash_tags')
if hash_tags:
@ -318,7 +396,7 @@ class JobPostingForm(forms.ModelForm):
if not tag.startswith('#'):
raise forms.ValidationError("Each hashtag must start with '#' symbol and must be comma(,) sepearted.")
return ','.join(tags)
return hash_tags # Allow blank
return hash_tags
def clean_title(self):
title=self.cleaned_data.get('title')
@ -332,43 +410,7 @@ class JobPostingForm(forms.ModelForm):
description=self.cleaned_data.get('description')
if not description or len(description.strip())<20:
raise forms.ValidationError("Job description must be at least 20 characters long.")
return description.strip() # to remove leading/trailing whitespace
def clean_application_url(self):
url=self.cleaned_data.get('application_url')
if url:
validator=URLValidator()
try:
validator(url)
except forms.ValidationError:
raise forms.ValidationError('Please enter a valid URL (e.g., https://example.com)')
return url
# def clean(self):
# """Cross-field validation"""
# cleaned_data = super().clean()
# # Validate dates
# start_date = cleaned_data.get('start_date')
# application_deadline = cleaned_data.get('application_deadline')
# # Perform cross-field validation only if both fields have values
# if start_date and application_deadline:
# if application_deadline > start_date:
# self.add_error('application_deadline',
# 'The application deadline must be set BEFORE the job start date.')
# # # Validate that if status is ACTIVE, we have required fields
# # status = cleaned_data.get('status')
# # if status == 'ACTIVE':
# # if not cleaned_data.get('application_url'):
# # self.add_error('application_url',
# # 'Application URL is required for active jobs.')
# # if not cleaned_data.get('description'):
# # self.add_error('description',
# # 'Job description is required for active jobs.')
# return cleaned_data
return description.strip()
class JobPostingImageForm(forms.ModelForm):
class Meta:
@ -416,90 +458,6 @@ class FormTemplateForm(forms.ModelForm):
Field('is_active', css_class='form-check-input'),
Submit('submit', _('Create Template'), css_class='btn btn-primary mt-3')
)
# class BreakTimeForm(forms.ModelForm):
# class Meta:
# model = BreakTime
# fields = ['start_time', 'end_time']
# widgets = {
# 'start_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}),
# 'end_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}),
# }
# BreakTimeFormSet = formset_factory(BreakTimeForm, extra=1, can_delete=True)
# class InterviewScheduleForm(forms.ModelForm):
# candidates = forms.ModelMultipleChoiceField(
# queryset=Candidate.objects.none(),
# widget=forms.CheckboxSelectMultiple,
# required=True
# )
# working_days = forms.MultipleChoiceField(
# choices=[
# (0, 'Monday'),
# (1, 'Tuesday'),
# (2, 'Wednesday'),
# (3, 'Thursday'),
# (4, 'Friday'),
# (5, 'Saturday'),
# (6, 'Sunday'),
# ],
# widget=forms.CheckboxSelectMultiple,
# required=True
# )
# class Meta:
# model = InterviewSchedule
# fields = [
# 'candidates', 'start_date', 'end_date', 'working_days',
# 'start_time', 'end_time', 'interview_duration', 'buffer_time'
# ]
# widgets = {
# 'start_date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
# 'end_date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
# 'start_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}),
# 'end_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}),
# 'interview_duration': forms.NumberInput(attrs={'class': 'form-control'}),
# 'buffer_time': forms.NumberInput(attrs={'class': 'form-control'}),
# }
# def __init__(self, slug, *args, **kwargs):
# super().__init__(*args, **kwargs)
# # Filter candidates based on the selected job
# self.fields['candidates'].queryset = Candidate.objects.filter(
# job__slug=slug,
# stage='Interview'
# )
# def clean_working_days(self):
# working_days = self.cleaned_data.get('working_days')
# # Convert string values to integers
# return [int(day) for day in working_days]
class JobPostingCancelReasonForm(forms.ModelForm):
class Meta:
model = JobPosting
fields = ['cancel_reason']
class JobPostingStatusForm(forms.ModelForm):
class Meta:
model = JobPosting
fields = ['status']
widgets = {
'status': forms.Select(attrs={'class': 'form-select'}),
}
class FormTemplateIsActiveForm(forms.ModelForm):
class Meta:
model = FormTemplate
fields = ['is_active']
class CandidateExamDateForm(forms.ModelForm):
class Meta:
model = Candidate
fields = ['exam_date']
widgets = {
'exam_date': forms.DateTimeInput(attrs={'type': 'datetime-local', 'class': 'form-control'}),
}
class BreakTimeForm(forms.Form):
"""
@ -516,10 +474,8 @@ class BreakTimeForm(forms.Form):
label="End Time"
)
# Use the non-model form for the formset factory
BreakTimeFormSet = formset_factory(BreakTimeForm, extra=1, can_delete=True)
# --- InterviewScheduleForm remains unchanged ---
class InterviewScheduleForm(forms.ModelForm):
candidates = forms.ModelMultipleChoiceField(
queryset=Candidate.objects.none(),
@ -555,7 +511,6 @@ class InterviewScheduleForm(forms.ModelForm):
def __init__(self, slug, *args, **kwargs):
super().__init__(*args, **kwargs)
# Filter candidates based on the selected job
self.fields['candidates'].queryset = Candidate.objects.filter(
job__slug=slug,
stage='Interview'
@ -563,7 +518,6 @@ class InterviewScheduleForm(forms.ModelForm):
def clean_working_days(self):
working_days = self.cleaned_data.get('working_days')
# Convert string values to integers
return [int(day) for day in working_days]
class MeetingCommentForm(forms.ModelForm):
@ -593,26 +547,7 @@ class MeetingCommentForm(forms.ModelForm):
Submit('submit', _('Add Comment'), css_class='btn btn-primary mt-3')
)
# --- ScheduleInterviewForCandiateForm remains unchanged ---
class ScheduleInterviewForCandiateForm(forms.ModelForm):
class Meta:
model = InterviewSchedule
fields = ['start_date', 'end_date', 'start_time', 'end_time', 'interview_duration', 'buffer_time']
widgets = {
'start_date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
'end_date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
'start_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}),
'end_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}),
'interview_duration': forms.NumberInput(attrs={'class': 'form-control'}),
'buffer_time': forms.NumberInput(attrs={'class': 'form-control'}),
'break_start_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}),
'break_end_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}),
}
class InterviewForm(forms.ModelForm):
class Meta:
model = ScheduledInterview
fields = ['job','candidate']
@ -622,36 +557,6 @@ class ProfileImageUploadForm(forms.ModelForm):
model=Profile
fields=['profile_image']
# class UserEditForms(forms.ModelForm):
# class Meta:
# model = User
# fields = ['first_name', 'last_name']
from django.contrib.auth.forms import UserCreationForm
# class StaffUserCreationForm(UserCreationForm):
# email = forms.EmailField(required=True)
# first_name = forms.CharField(max_length=30)
# last_name = forms.CharField(max_length=150)
# class Meta:
# model = User
# fields = ("email", "first_name", "last_name", "password1", "password2")
# def save(self, commit=True):
# user = super().save(commit=False)
# user.email = self.cleaned_data["email"]
# user.first_name = self.cleaned_data["first_name"]
# user.last_name = self.cleaned_data["last_name"]
# user.username = self.cleaned_data["email"] # or generate
# user.is_staff = True
# if commit:
# user.save()
# return user
import re
class StaffUserCreationForm(UserCreationForm):
email = forms.EmailField(required=True)
first_name = forms.CharField(max_length=30, required=True)
@ -685,15 +590,37 @@ class StaffUserCreationForm(UserCreationForm):
user.email = self.cleaned_data["email"]
user.first_name = self.cleaned_data["first_name"]
user.last_name = self.cleaned_data["last_name"]
user.username = self.generate_username(user.email) # never use raw email if it has dots, etc.
user.username = self.generate_username(user.email)
user.is_staff = True
if commit:
user.save()
return user
class ToggleAccountForm(forms.Form):
pass
class JobPostingCancelReasonForm(forms.ModelForm):
class Meta:
model = JobPosting
fields = ['cancel_reason']
class JobPostingStatusForm(forms.ModelForm):
class Meta:
model = JobPosting
fields = ['status']
widgets = {
'status': forms.Select(attrs={'class': 'form-select'}),
}
class FormTemplateIsActiveForm(forms.ModelForm):
class Meta:
model = FormTemplate
fields = ['is_active']
class CandidateExamDateForm(forms.ModelForm):
class Meta:
model = Candidate
fields = ['exam_date']
widgets = {
'exam_date': forms.DateTimeInput(attrs={'type': 'datetime-local', 'class': 'form-control'}),
}

View File

@ -2,6 +2,7 @@ from django.urls import path
from . import views_frontend
from . import views
from . import views_integration
from . import views_source
urlpatterns = [
path('', views_frontend.dashboard_view, name='dashboard'),
@ -126,6 +127,15 @@ urlpatterns = [
# Source URLs
path('sources/', views_source.SourceListView.as_view(), name='source_list'),
path('sources/create/', views_source.SourceCreateView.as_view(), name='source_create'),
path('sources/<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'),

212
recruitment/views_source.py Normal file
View File

@ -0,0 +1,212 @@
from django.shortcuts import render, get_object_or_404, redirect
from django.views.generic import ListView, CreateView, UpdateView, DetailView, DeleteView
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.urls import reverse_lazy
from django.contrib import messages
from django.db import transaction
from django.http import JsonResponse
from django.db import models
import secrets
import string
from .models import Source, IntegrationLog
from .forms import SourceForm, generate_api_key, generate_api_secret
class SourceListView(LoginRequiredMixin, UserPassesTestMixin, ListView):
"""List all sources"""
model = Source
template_name = 'recruitment/source_list.html'
context_object_name = 'sources'
paginate_by = 10
def test_func(self):
return self.request.user.is_staff
def get_queryset(self):
queryset = super().get_queryset().order_by('name')
# Search functionality
search_query = self.request.GET.get('search', '')
if search_query:
queryset = queryset.filter(
models.Q(name__icontains=search_query) |
models.Q(source_type__icontains=search_query) |
models.Q(description__icontains=search_query)
)
return queryset
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['search_query'] = self.request.GET.get('search', '')
return context
class SourceCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView):
"""Create a new source"""
model = Source
form_class = SourceForm
template_name = 'recruitment/source_form.html'
success_url = reverse_lazy('source_list')
def test_func(self):
return self.request.user.is_staff
def form_valid(self, form):
# Set initial values
form.instance.created_by = self.request.user.get_full_name() or self.request.user.username
# Check if we need to generate API keys
if form.cleaned_data.get('generate_keys') == 'true':
form.instance.api_key = generate_api_key()
form.instance.api_secret = generate_api_secret()
# Log the key generation
IntegrationLog.objects.create(
source=form.instance,
action=IntegrationLog.ActionChoices.CREATE,
endpoint='/api/sources/',
method='POST',
request_data={'name': form.instance.name},
ip_address=self.request.META.get('REMOTE_ADDR'),
user_agent=self.request.META.get('HTTP_USER_AGENT', '')
)
response = super().form_valid(form)
# Add success message
messages.success(self.request, f'Source "{form.instance.name}" created successfully!')
return response
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['title'] = 'Create New Source'
context['generate_keys'] = self.request.GET.get('generate_keys', 'false')
return context
class SourceDetailView(LoginRequiredMixin, UserPassesTestMixin, DetailView):
"""View source details"""
model = Source
template_name = 'recruitment/source_detail.html'
context_object_name = 'source'
def test_func(self):
return self.request.user.is_staff
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Mask API keys in display
source = self.object
if source.api_key:
masked_key = source.api_key[:8] + '*' * 24
context['masked_api_key'] = masked_key
else:
context['masked_api_key'] = 'Not generated'
if source.api_secret:
masked_secret = source.api_secret[:12] + '*' * 52
context['masked_api_secret'] = masked_secret
else:
context['masked_api_secret'] = 'Not generated'
# Get recent integration logs
context['recent_logs'] = IntegrationLog.objects.filter(
source=source
).order_by('-created_at')[:10]
return context
class SourceUpdateView(LoginRequiredMixin, UserPassesTestMixin, UpdateView):
"""Update an existing source"""
model = Source
form_class = SourceForm
template_name = 'recruitment/source_form.html'
success_url = reverse_lazy('source_list')
def test_func(self):
return self.request.user.is_staff
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['title'] = f'Edit Source: {self.object.name}'
context['generate_keys'] = self.request.GET.get('generate_keys', 'false')
return context
def form_valid(self, form):
# Check if we need to generate new API keys
if form.cleaned_data.get('generate_keys') == 'true':
form.instance.api_key = generate_api_key()
form.instance.api_secret = generate_api_secret()
# Log the key regeneration
IntegrationLog.objects.create(
source=self.object,
action=IntegrationLog.ActionChoices.CREATE,
endpoint=f'/api/sources/{self.object.pk}/',
method='PUT',
request_data={'name': form.instance.name, 'regenerated_keys': True},
ip_address=self.request.META.get('REMOTE_ADDR'),
user_agent=self.request.META.get('HTTP_USER_AGENT', '')
)
messages.success(self.request, 'New API keys generated successfully!')
response = super().form_valid(form)
messages.success(self.request, f'Source "{form.instance.name}" updated successfully!')
return response
class SourceDeleteView(LoginRequiredMixin, UserPassesTestMixin, DeleteView):
"""Delete a source"""
model = Source
template_name = 'recruitment/source_confirm_delete.html'
success_url = reverse_lazy('source_list')
def test_func(self):
return self.request.user.is_staff
def delete(self, request, *args, **kwargs):
self.object = self.get_object()
success_url = self.get_success_url()
# Log the deletion
IntegrationLog.objects.create(
source=self.object,
action=IntegrationLog.ActionChoices.SYNC, # Using SYNC for deletion
endpoint=f'/api/sources/{self.object.pk}/',
method='DELETE',
request_data={'name': self.object.name},
ip_address=self.request.META.get('REMOTE_ADDR'),
user_agent=self.request.META.get('HTTP_USER_AGENT', '')
)
messages.success(request, f'Source "{self.object.name}" deleted successfully!')
return super().delete(request, *args, **kwargs)
def generate_api_keys_view(request):
"""API endpoint to generate API keys"""
if not request.user.is_staff:
return JsonResponse({'error': 'Permission denied'}, status=403)
if request.method == 'POST':
api_key = generate_api_key()
api_secret = generate_api_secret()
return JsonResponse({
'success': True,
'api_key': api_key,
'api_secret': api_secret,
'message': 'API keys generated successfully'
})
return JsonResponse({'error': 'Invalid request method'}, status=405)
def copy_to_clipboard_view(request):
"""HTMX endpoint to copy text to clipboard"""
if request.method == 'POST':
text_to_copy = request.POST.get('text', '')
return render(request, 'includes/copy_to_clipboard.html', {
'text': text_to_copy
})
return JsonResponse({'error': 'Invalid request method'}, status=405)

View File

@ -44,7 +44,7 @@
<div class="en text-xs">King Abdullah bin Abdulaziz University Hospital</div>
</div>
</div>
<img src="{% static 'image/kaauh.png' %}" alt="KAAUH Logo" style="max-height: 40px; max-width: 40px;">
<img src="{% static 'image/hospital_logo.png' %}" alt="KAAUH Logo" style="max-height: 40px; max-width: 40px;">
</div>
</div>
</div>
@ -55,11 +55,11 @@
{# --- MOBILE BRAND LOGIC: Show small logo on mobile, large on desktop (lg) --- #}
<a class="navbar-brand text-white d-block d-lg-none" href="{% url 'dashboard' %}" aria-label="Home">
<img src="{% static 'image/kaauh_green1.png' %}" alt="{% trans 'kaauh logo green bg' %}" class="navbar-brand-mobile">
<img src="{% static 'image/hospital_logo_1.png' %}" alt="{% trans 'kaauh logo green bg' %}" class="navbar-brand-mobile">
</a>
<a class="navbar-brand text-white d-none d-lg-block me-4 pe-4" href="{% url 'dashboard' %}" aria-label="Home">
<img src="{% static 'image/kaauh_green1.png' %}" alt="{% trans 'kaauh logo green bg' %}" style="width: 60px; height: 60px;">
<img src="{% static 'image/hospital_logo_1.png' %}" alt="{% trans 'kaauh logo green bg' %}" style="width: 60px; height: 60px;">
</a>
{# Toggler: order-lg-0 ensures it's before navigation links on desktop, but it stays where it is on mobile #}
@ -216,13 +216,21 @@
<li class="nav-item me-lg-4">
<a class="nav-link {% if request.resolver_match.url_name == 'list_meetings' %}active{% endif %}" href="{% url 'list_meetings' %}">
<span class="d-flex align-items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z" />
</svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z" />
</svg>
{% trans "Meetings" %}
</span>
</a>
</li>
<li class="nav-item me-lg-4">
<a class="nav-link {% if request.resolver_match.url_name == 'source_list' %}active{% endif %}" href="{% url 'source_list' %}">
<span class="d-flex align-items-center gap-2">
{% include "icons/sources.html" %}
{% trans "Sources" %}
</span>
</a>
</li>
{% comment %} <li class="nav-item me-lg-4">
<a class="nav-link {% if request.resolver_match.url_name == 'training_list' %}active{% endif %}" href="{% url 'training_list' %}">
<span class="d-flex align-items-center gap-2">
@ -356,4 +364,4 @@
{% block customJS %}{% endblock %}
</body>
</html>
</html>

View File

@ -0,0 +1,4 @@
<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>

After

Width:  |  Height:  |  Size: 628 B

View File

@ -0,0 +1,20 @@
<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>

View File

@ -65,11 +65,6 @@
</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>
@ -93,57 +88,6 @@
<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"

View File

@ -0,0 +1,128 @@
{% 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 %}

View File

@ -0,0 +1,285 @@
{% 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 %}

View File

@ -0,0 +1,376 @@
{% 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 %}

View File

@ -0,0 +1,209 @@
{% 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">&laquo;&laquo;</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">&laquo;</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">&raquo;</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">&raquo;&raquo;</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 %}