add reference and standard

This commit is contained in:
ismail 2026-01-08 09:50:43 +03:00
parent 5bb2abf8bb
commit 97de5919f2
59 changed files with 8395 additions and 332 deletions

View File

@ -121,4 +121,5 @@ STATIC_URL = 'static/'
OPENROUTER_API_KEY = "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"
AI_MODEL = "openrouter/xiaomi/mimo-v2-flash:free"
AI_MODEL = "openrouter/z-ai/glm-4.7"
# AI_MODEL = "openrouter/xiaomi/mimo-v2-flash:free"

View File

@ -53,7 +53,7 @@ class ComplaintSerializer(serializers.ModelSerializer):
patient_mrn = serializers.CharField(source='patient.mrn', read_only=True)
hospital_name = serializers.CharField(source='hospital.name', read_only=True)
department_name = serializers.CharField(source='department.name', read_only=True)
physician_name = serializers.SerializerMethodField()
staff_name = serializers.SerializerMethodField()
assigned_to_name = serializers.SerializerMethodField()
attachments = ComplaintAttachmentSerializer(many=True, read_only=True)
updates = ComplaintUpdateSerializer(many=True, read_only=True)
@ -64,7 +64,7 @@ class ComplaintSerializer(serializers.ModelSerializer):
fields = [
'id', 'patient', 'patient_name', 'patient_mrn', 'encounter_id',
'hospital', 'hospital_name', 'department', 'department_name',
'physician', 'physician_name',
'staff', 'staff_name',
'title', 'description', 'category', 'subcategory',
'priority', 'severity', 'source', 'status',
'assigned_to', 'assigned_to_name', 'assigned_at',
@ -140,10 +140,10 @@ class ComplaintSerializer(serializers.ModelSerializer):
# Create the complaint
return super().create(validated_data)
def get_physician_name(self, obj):
"""Get physician name"""
if obj.physician:
return obj.physician.get_full_name()
def get_staff_name(self, obj):
"""Get staff name"""
if obj.staff:
return f"{obj.staff.first_name} {obj.staff.last_name}"
return None
def get_assigned_to_name(self, obj):

View File

@ -19,7 +19,7 @@ from django.utils import timezone
logger = logging.getLogger(__name__)
def match_staff_from_name(staff_name: str, hospital_id: str, department_name: Optional[str] = None) -> Tuple[Optional[str], float, str]:
def match_staff_from_name(staff_name: str, hospital_id: str, department_name: Optional[str] = None, return_all: bool = False) -> Tuple[list, float, str]:
"""
Match staff member from extracted name using multiple matching strategies.
@ -27,19 +27,26 @@ def match_staff_from_name(staff_name: str, hospital_id: str, department_name: Op
staff_name: Name extracted from complaint (without titles)
hospital_id: Hospital ID to search within
department_name: Optional department name to prioritize matching
return_all: If True, return all matching staff. If False, return single best match.
Returns:
Tuple of (staff_id, confidence_score, matching_method)
- staff_id: UUID of matched staff or None
- confidence_score: Float from 0.0 to 1.0
- matching_method: Description of how staff was matched
If return_all=True: Tuple of (matches_list, confidence_score, matching_method)
- matches_list: List of dicts with matched staff details
- confidence_score: Float from 0.0 to 1.0 (best match confidence)
- matching_method: Description of how staff was matched
If return_all=False: Tuple of (staff_id, confidence_score, matching_method)
- staff_id: UUID of matched staff or None
- confidence_score: Float from 0.0 to 1.0
- matching_method: Description of how staff was matched
"""
from apps.organizations.models import Staff, Department
if not staff_name or not staff_name.strip():
return None, 0.0, "No staff name provided"
return [], 0.0, "No staff name provided"
staff_name = staff_name.strip()
matches = []
# Build base query - staff from this hospital, active status
base_query = Staff.objects.filter(
@ -72,12 +79,23 @@ def match_staff_from_name(staff_name: str, hospital_id: str, department_name: Op
if dept_id:
exact_query = exact_query.filter(department_id=dept_id)
staff = exact_query.first()
if staff:
exact_matches = list(exact_query)
if exact_matches:
confidence = 0.95 if dept_id else 0.90
method = f"Exact English match in {'correct' if dept_id else 'any'} department"
logger.info(f"Matched staff using exact English match: {staff.first_name} {staff.last_name} (confidence: {confidence})")
return str(staff.id), confidence, method
for staff in exact_matches:
matches.append({
'id': str(staff.id),
'name_en': f"{staff.first_name} {staff.last_name}",
'name_ar': f"{staff.first_name_ar} {staff.last_name_ar}" if staff.first_name_ar and staff.last_name_ar else "",
'job_title': staff.job_title,
'specialization': staff.specialization,
'department': staff.department.name if staff.department else None,
'department_id': str(staff.department.id) if staff.department else None,
'confidence': confidence,
'matching_method': method
})
logger.info(f"Found {len(exact_matches)} exact English matches for: {staff_name}")
# Layer 2: Exact Arabic match
arabic_query = base_query.filter(
@ -92,8 +110,20 @@ def match_staff_from_name(staff_name: str, hospital_id: str, department_name: Op
if full_arabic_name == staff_name:
confidence = 0.95 if dept_id else 0.90
method = f"Exact Arabic match in {'correct' if dept_id else 'any'} department"
logger.info(f"Matched staff using exact Arabic match: {staff.first_name_ar} {staff.last_name_ar} (confidence: {confidence})")
return str(staff.id), confidence, method
# Check if already in matches
if not any(m['id'] == str(staff.id) for m in matches):
matches.append({
'id': str(staff.id),
'name_en': f"{staff.first_name} {staff.last_name}",
'name_ar': f"{staff.first_name_ar} {staff.last_name_ar}",
'job_title': staff.job_title,
'specialization': staff.specialization,
'department': staff.department.name if staff.department else None,
'department_id': str(staff.department.id) if staff.department else None,
'confidence': confidence,
'matching_method': method
})
logger.info(f"Found Arabic match: {staff.first_name_ar} {staff.last_name_ar}")
# Layer 3: Partial match (first name or last name)
partial_query = base_query.filter(
@ -105,12 +135,25 @@ def match_staff_from_name(staff_name: str, hospital_id: str, department_name: Op
if dept_id:
partial_query = partial_query.filter(department_id=dept_id)
staff = partial_query.first()
if staff:
confidence = 0.70 if dept_id else 0.60
method = f"Partial match in {'correct' if dept_id else 'any'} department"
logger.info(f"Matched staff using partial match: {staff.first_name} {staff.last_name} (confidence: {confidence})")
return str(staff.id), confidence, method
partial_matches = list(partial_query)
for staff in partial_matches:
# Check if already in matches
if not any(m['id'] == str(staff.id) for m in matches):
confidence = 0.70 if dept_id else 0.60
method = f"Partial match in {'correct' if dept_id else 'any'} department"
matches.append({
'id': str(staff.id),
'name_en': f"{staff.first_name} {staff.last_name}",
'name_ar': f"{staff.first_name_ar} {staff.last_name_ar}" if staff.first_name_ar and staff.last_name_ar else "",
'job_title': staff.job_title,
'specialization': staff.specialization,
'department': staff.department.name if staff.department else None,
'department_id': str(staff.department.id) if staff.department else None,
'confidence': confidence,
'matching_method': method
})
if partial_matches:
logger.info(f"Found {len(partial_matches)} partial matches for: {staff_name}")
# Layer 4: Fuzzy match using individual words
# Handle cases like "Dr. Ahmed" or "Nurse Sarah"
@ -120,16 +163,52 @@ def match_staff_from_name(staff_name: str, hospital_id: str, department_name: Op
if dept_id:
word_query = word_query.filter(department_id=dept_id)
staff = word_query.first()
if staff:
confidence = 0.50 if dept_id else 0.45
method = f"Word match in {'correct' if dept_id else 'any'} department"
logger.info(f"Matched staff using word match: {staff.first_name} {staff.last_name} (confidence: {confidence})")
return str(staff.id), confidence, method
word_matches = list(word_query)
for staff in word_matches:
# Check if already in matches
if not any(m['id'] == str(staff.id) for m in matches):
confidence = 0.50 if dept_id else 0.45
method = f"Word match in {'correct' if dept_id else 'any'} department"
matches.append({
'id': str(staff.id),
'name_en': f"{staff.first_name} {staff.last_name}",
'name_ar': f"{staff.first_name_ar} {staff.last_name_ar}" if staff.first_name_ar and staff.last_name_ar else "",
'job_title': staff.job_title,
'specialization': staff.specialization,
'department': staff.department.name if staff.department else None,
'department_id': str(staff.department.id) if staff.department else None,
'confidence': confidence,
'matching_method': method
})
if word_matches:
logger.info(f"Found {len(word_matches)} word matches for: {staff_name}")
# No match found
logger.warning(f"No staff match found for name: {staff_name}")
return None, 0.0, "No match found"
# If return_all is False, return only the best match (highest confidence)
if not return_all:
if matches:
# Sort by confidence (descending)
matches.sort(key=lambda x: x['confidence'], reverse=True)
best_match = matches[0]
logger.info(
f"Best match: {best_match['name_en']} "
f"(confidence: {best_match['confidence']:.2f}, method: {best_match['matching_method']})"
)
return str(best_match['id']), best_match['confidence'], best_match['matching_method']
else:
logger.warning(f"No staff match found for name: {staff_name}")
return None, 0.0, "No match found"
# Return all matches
if matches:
# Sort by confidence (descending)
matches.sort(key=lambda x: x['confidence'], reverse=True)
best_confidence = matches[0]['confidence']
best_method = matches[0]['matching_method']
logger.info(f"Returning {len(matches)} matches for: {staff_name}")
return matches, best_confidence, best_method
else:
logger.warning(f"No staff match found for name: {staff_name}")
return [], 0.0, "No match found"
@shared_task
@ -709,9 +788,11 @@ def analyze_complaint_with_ai(complaint_id):
# Get staff_name from analyze_complaint result (already extracted by AI)
staff_name = analysis.get('staff_name', '').strip()
matched_staff_id = None
# Always get ALL matching staff for PX Admin review
staff_matches = []
staff_confidence = 0.0
staff_matching_method = None
matched_staff_id = None
# Capture old staff before matching
old_staff = complaint.staff
@ -720,39 +801,67 @@ def analyze_complaint_with_ai(complaint_id):
logger.info(f"AI extracted staff name: {staff_name}")
# Try matching WITH department filter first (higher confidence if match found)
matched_staff_id, staff_confidence, staff_matching_method = match_staff_from_name(
staff_matches, staff_confidence, staff_matching_method = match_staff_from_name(
staff_name=staff_name,
hospital_id=str(complaint.hospital.id),
department_name=department_name
department_name=department_name,
return_all=True # Return ALL matches
)
# If no match found with department, try WITHOUT department filter
if not matched_staff_id:
if not staff_matches:
logger.info(f"No match found with department filter '{department_name}', trying without department filter...")
matched_staff_id, staff_confidence, staff_matching_method = match_staff_from_name(
staff_matches, staff_confidence, staff_matching_method = match_staff_from_name(
staff_name=staff_name,
hospital_id=str(complaint.hospital.id),
department_name=None # Search all departments
department_name=None, # Search all departments
return_all=True
)
# Only assign staff if confidence is above threshold (0.6)
if matched_staff_id and staff_confidence >= 0.6:
from apps.organizations.models import Staff
try:
staff = Staff.objects.get(id=matched_staff_id)
complaint.staff = staff
# Logic for staff assignment
needs_staff_review = False
if staff_matches:
# If only ONE match, assign it (regardless of confidence for PX Admin review)
if len(staff_matches) == 1:
matched_staff_id = staff_matches[0]['id']
from apps.organizations.models import Staff
try:
staff = Staff.objects.get(id=matched_staff_id)
complaint.staff = staff
logger.info(
f"Assigned staff {staff.first_name} {staff.last_name} "
f"to complaint {complaint_id} "
f"(confidence: {staff_confidence:.2f}, method: {staff_matching_method})"
)
except Staff.DoesNotExist:
logger.warning(f"Staff {matched_staff_id} not found in database")
# Still mark for review if confidence is low
if staff_confidence < 0.6:
needs_staff_review = True
else:
# Multiple matches found - don't assign, mark for review
logger.info(
f"Assigned staff {staff.first_name} {staff.last_name} "
f"to complaint {complaint_id} "
f"(confidence: {staff_confidence:.2f}, method: {staff_matching_method})"
f"Multiple staff matches found ({len(staff_matches)}), "
f"marking for PX Admin review"
)
except Staff.DoesNotExist:
logger.warning(f"Staff {matched_staff_id} not found in database")
needs_staff_review = True
# Assign to department instead if available
if department_name:
# Department already set from AI analysis
pass
elif staff_matches[0].get('department_id'):
from apps.organizations.models import Department
try:
dept = Department.objects.get(id=staff_matches[0]['department_id'])
complaint.department = dept
logger.info(f"Assigned to department: {dept.name}")
except Department.DoesNotExist:
pass
else:
logger.info(
f"Staff match confidence {staff_confidence:.2f} below threshold 0.6, "
f"or no match found. Not assigning staff."
)
# No matches found
logger.warning(f"No staff match found for name: {staff_name}")
needs_staff_review = False # No review needed if no name found
# Save reasoning in metadata
# Use JSON-serializable values instead of model objects
@ -790,9 +899,12 @@ def analyze_complaint_with_ai(complaint_id):
'old_staff': old_staff_name,
'old_staff_id': old_staff_id,
'extracted_staff_name': staff_name,
'staff_matches': staff_matches,
'matched_staff_id': matched_staff_id,
'staff_confidence': staff_confidence,
'staff_matching_method': staff_matching_method
'staff_matching_method': staff_matching_method,
'needs_staff_review': needs_staff_review,
'staff_match_count': len(staff_matches)
}
complaint.save(update_fields=['severity', 'priority', 'category', 'department', 'staff', 'title', 'metadata'])

View File

@ -221,6 +221,14 @@ def complaint_detail(request, pk):
if complaint.hospital:
assignable_users = assignable_users.filter(hospital=complaint.hospital)
# Get departments for the complaint's hospital
hospital_departments = []
if complaint.hospital:
hospital_departments = Department.objects.filter(
hospital=complaint.hospital,
status='active'
).order_by('name')
# Check if overdue
complaint.check_overdue()
@ -232,6 +240,7 @@ def complaint_detail(request, pk):
'assignable_users': assignable_users,
'status_choices': ComplaintStatus.choices,
'can_edit': user.is_px_admin() or user.is_hospital_admin(),
'hospital_departments': hospital_departments,
}
return render(request, 'complaints/complaint_detail.html', context)
@ -462,6 +471,67 @@ def complaint_add_note(request, pk):
return redirect('complaints:complaint_detail', pk=pk)
@login_required
@require_http_methods(["POST"])
def complaint_change_department(request, pk):
"""Change complaint department"""
complaint = get_object_or_404(Complaint, pk=pk)
# Check permission
user = request.user
if not (user.is_px_admin() or user.is_hospital_admin()):
messages.error(request, "You don't have permission to change complaint department.")
return redirect('complaints:complaint_detail', pk=pk)
department_id = request.POST.get('department_id')
if not department_id:
messages.error(request, "Please select a department.")
return redirect('complaints:complaint_detail', pk=pk)
try:
department = Department.objects.get(id=department_id)
# Check department belongs to same hospital
if department.hospital != complaint.hospital:
messages.error(request, "Department does not belong to this complaint's hospital.")
return redirect('complaints:complaint_detail', pk=pk)
old_department = complaint.department
complaint.department = department
complaint.save(update_fields=['department'])
# Create update
ComplaintUpdate.objects.create(
complaint=complaint,
update_type='assignment',
message=f"Department changed to {department.name}",
created_by=request.user,
metadata={
'old_department_id': str(old_department.id) if old_department else None,
'new_department_id': str(department.id)
}
)
# Log audit
AuditService.log_event(
event_type='department_change',
description=f"Complaint department changed to {department.name}",
user=request.user,
content_object=complaint,
metadata={
'old_department_id': str(old_department.id) if old_department else None,
'new_department_id': str(department.id)
}
)
messages.success(request, f"Department changed to {department.name}.")
except Department.DoesNotExist:
messages.error(request, "Department not found.")
return redirect('complaints:complaint_detail', pk=pk)
@login_required
@require_http_methods(["POST"])
def complaint_escalate(request, pk):

View File

@ -18,6 +18,7 @@ urlpatterns = [
path('<uuid:pk>/', ui_views.complaint_detail, name='complaint_detail'),
path('<uuid:pk>/assign/', ui_views.complaint_assign, name='complaint_assign'),
path('<uuid:pk>/change-status/', ui_views.complaint_change_status, name='complaint_change_status'),
path('<uuid:pk>/change-department/', ui_views.complaint_change_department, name='complaint_change_department'),
path('<uuid:pk>/add-note/', ui_views.complaint_add_note, name='complaint_add_note'),
path('<uuid:pk>/escalate/', ui_views.complaint_escalate, name='complaint_escalate'),

View File

@ -1,6 +1,7 @@
"""
Complaints views and viewsets
"""
from django.db.models import Q
from django.utils import timezone
from rest_framework import status, viewsets
from rest_framework.decorators import action
@ -107,7 +108,7 @@ class ComplaintViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated]
filterset_fields = [
'status', 'severity', 'priority', 'category', 'source',
'hospital', 'department', 'physician', 'assigned_to',
'hospital', 'department', 'staff', 'assigned_to',
'is_overdue', 'hospital__organization'
]
search_fields = ['title', 'description', 'patient__mrn', 'patient__first_name', 'patient__last_name']
@ -123,7 +124,7 @@ class ComplaintViewSet(viewsets.ModelViewSet):
def get_queryset(self):
"""Filter complaints based on user role"""
queryset = super().get_queryset().select_related(
'patient', 'hospital', 'department', 'physician',
'patient', 'hospital', 'department', 'staff',
'assigned_to', 'resolved_by', 'closed_by'
).prefetch_related('attachments', 'updates')
@ -281,6 +282,257 @@ class ComplaintViewSet(viewsets.ModelViewSet):
serializer = ComplaintUpdateSerializer(update)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=['get'])
def staff_suggestions(self, request, pk=None):
"""
Get staff matching suggestions for a complaint.
Returns potential staff matches from AI analysis,
allowing PX Admins to review and select correct staff.
"""
complaint = self.get_object()
# Check if user is PX Admin
if not request.user.is_px_admin():
return Response(
{'error': 'Only PX Admins can access staff suggestions'},
status=status.HTTP_403_FORBIDDEN
)
# Get AI analysis metadata
ai_analysis = complaint.metadata.get('ai_analysis', {})
staff_matches = ai_analysis.get('staff_matches', [])
extracted_name = ai_analysis.get('extracted_staff_name', '')
needs_review = ai_analysis.get('needs_staff_review', False)
matched_staff_id = ai_analysis.get('matched_staff_id')
return Response({
'extracted_name': extracted_name,
'staff_matches': staff_matches,
'current_staff_id': matched_staff_id,
'needs_staff_review': needs_staff_review,
'staff_match_count': len(staff_matches)
})
@action(detail=True, methods=['get'])
def hospital_staff(self, request, pk=None):
"""
Get all staff from complaint's hospital for manual selection.
Allows PX Admins to manually select staff.
Supports filtering by department.
"""
complaint = self.get_object()
# Check if user is PX Admin
if not request.user.is_px_admin():
return Response(
{'error': 'Only PX Admins can access hospital staff list'},
status=status.HTTP_403_FORBIDDEN
)
from apps.organizations.models import Staff
# Get query params
department_id = request.query_params.get('department_id')
search = request.query_params.get('search', '').strip()
# Build query
queryset = Staff.objects.filter(
hospital=complaint.hospital,
status='active'
).select_related('department')
# Filter by department if specified
if department_id:
queryset = queryset.filter(department_id=department_id)
# Search by name if provided
if search:
queryset = queryset.filter(
Q(first_name__icontains=search) |
Q(last_name__icontains=search) |
Q(first_name_ar__icontains=search) |
Q(last_name_ar__icontains=search) |
Q(job_title__icontains=search)
)
# Order by department and name
queryset = queryset.order_by('department__name', 'first_name', 'last_name')
# Serialize
staff_list = []
for staff in queryset:
staff_list.append({
'id': str(staff.id),
'name_en': f"{staff.first_name} {staff.last_name}",
'name_ar': f"{staff.first_name_ar} {staff.last_name_ar}" if staff.first_name_ar and staff.last_name_ar else "",
'job_title': staff.job_title,
'specialization': staff.specialization,
'department': staff.department.name if staff.department else None,
'department_id': str(staff.department.id) if staff.department else None
})
return Response({
'hospital_id': str(complaint.hospital.id),
'hospital_name': complaint.hospital.name,
'staff_count': len(staff_list),
'staff': staff_list
})
@action(detail=True, methods=['post'])
def assign_staff(self, request, pk=None):
"""
Manually assign staff to a complaint.
Allows PX Admins to assign specific staff member,
especially when AI matching is ambiguous.
"""
complaint = self.get_object()
# Check if user is PX Admin
if not request.user.is_px_admin():
return Response(
{'error': 'Only PX Admins can assign staff to complaints'},
status=status.HTTP_403_FORBIDDEN
)
staff_id = request.data.get('staff_id')
reason = request.data.get('reason', '')
if not staff_id:
return Response(
{'error': 'staff_id is required'},
status=status.HTTP_400_BAD_REQUEST
)
from apps.organizations.models import Staff
try:
staff = Staff.objects.get(id=staff_id)
except Staff.DoesNotExist:
return Response(
{'error': 'Staff not found'},
status=status.HTTP_404_NOT_FOUND
)
# Check staff belongs to same hospital
if staff.hospital != complaint.hospital:
return Response(
{'error': 'Staff does not belong to complaint hospital'},
status=status.HTTP_400_BAD_REQUEST
)
# Update complaint
old_staff_id = str(complaint.staff.id) if complaint.staff else None
complaint.staff = staff
complaint.save(update_fields=['staff'])
# Update metadata to clear review flag
if not complaint.metadata:
complaint.metadata = {}
if 'ai_analysis' in complaint.metadata:
complaint.metadata['ai_analysis']['needs_staff_review'] = False
complaint.metadata['ai_analysis']['staff_manually_assigned'] = True
complaint.metadata['ai_analysis']['staff_assigned_by'] = str(request.user.id)
complaint.metadata['ai_analysis']['staff_assigned_at'] = timezone.now().isoformat()
complaint.metadata['ai_analysis']['staff_assignment_reason'] = reason
complaint.save(update_fields=['metadata'])
# Create update
ComplaintUpdate.objects.create(
complaint=complaint,
update_type='assignment',
message=f"Staff assigned to {staff.first_name} {staff.last_name} ({staff.job_title}). {reason}" if reason else f"Staff assigned to {staff.first_name} {staff.last_name} ({staff.job_title})",
created_by=request.user,
metadata={
'old_staff_id': old_staff_id,
'new_staff_id': str(staff.id),
'manual_assignment': True
}
)
# Log audit
AuditService.log_from_request(
event_type='staff_assigned',
description=f"Staff {staff.first_name} {staff.last_name} manually assigned to complaint by {request.user.get_full_name()}",
request=request,
content_object=complaint,
metadata={
'old_staff_id': old_staff_id,
'new_staff_id': str(staff.id),
'reason': reason
}
)
return Response({
'message': 'Staff assigned successfully',
'staff_id': str(staff.id),
'staff_name': f"{staff.first_name} {staff.last_name}"
})
@action(detail=True, methods=['post'])
def change_department(self, request, pk=None):
"""Change complaint department"""
complaint = self.get_object()
department_id = request.data.get('department_id')
if not department_id:
return Response(
{'error': 'department_id is required'},
status=status.HTTP_400_BAD_REQUEST
)
from apps.organizations.models import Department
try:
department = Department.objects.get(id=department_id)
except Department.DoesNotExist:
return Response(
{'error': 'Department not found'},
status=status.HTTP_404_NOT_FOUND
)
# Check department belongs to same hospital
if department.hospital != complaint.hospital:
return Response(
{'error': 'Department does not belong to complaint hospital'},
status=status.HTTP_400_BAD_REQUEST
)
# Update complaint
old_department_id = str(complaint.department.id) if complaint.department else None
complaint.department = department
complaint.save(update_fields=['department'])
# Create update
ComplaintUpdate.objects.create(
complaint=complaint,
update_type='assignment',
message=f"Department changed to {department.name}",
created_by=request.user,
metadata={
'old_department_id': old_department_id,
'new_department_id': str(department.id)
}
)
# Log audit
AuditService.log_from_request(
event_type='department_change',
description=f"Complaint department changed to {department.name}",
request=request,
content_object=complaint,
metadata={
'old_department_id': old_department_id,
'new_department_id': str(department.id)
}
)
return Response({
'message': 'Department changed successfully',
'department_id': str(department.id),
'department_name': department.name
})
@action(detail=True, methods=['post'])
def create_action_from_ai(self, request, pk=None):
"""Create PX Action from AI-suggested action"""
@ -397,6 +649,164 @@ class ComplaintViewSet(viewsets.ModelViewSet):
'message': 'Action created successfully from AI-suggested action'
}, status=status.HTTP_201_CREATED)
@action(detail=True, methods=['post'])
def send_notification(self, request, pk=None):
"""
Send email notification to staff member or department head.
Sends complaint notification with AI-generated summary (editable by user).
Logs the operation to NotificationLog and ComplaintUpdate.
"""
complaint = self.get_object()
# Get email message (required)
email_message = request.data.get('email_message', '').strip()
if not email_message:
return Response(
{'error': 'email_message is required'},
status=status.HTTP_400_BAD_REQUEST
)
# Get additional message (optional)
additional_message = request.data.get('additional_message', '').strip()
# Determine recipient
recipient = None
recipient_display = None
recipient_type = None
# Priority 1: Staff member mentioned in complaint
if complaint.staff and complaint.staff.user:
recipient = complaint.staff.user
recipient_display = complaint.staff.get_full_name()
recipient_type = 'Staff Member'
# Priority 2: Department head
elif complaint.department and complaint.department.manager:
recipient = complaint.department.manager
recipient_display = recipient.get_full_name()
recipient_type = 'Department Head'
# Check if we found a recipient
if not recipient or not recipient.email:
return Response(
{'error': 'No valid recipient found. Complaint must have either a staff member with user account, or a department manager with email.'},
status=status.HTTP_400_BAD_REQUEST
)
# Construct email content
subject = f"Complaint Notification - #{complaint.id}"
# Build email body
email_body = f"""
Dear {recipient.get_full_name()},
You have been assigned to review the following complaint:
COMPLAINT DETAILS:
----------------
ID: #{complaint.id}
Title: {complaint.title}
Severity: {complaint.get_severity_display()}
Priority: {complaint.get_priority_display()}
Status: {complaint.get_status_display()}
SUMMARY:
--------
{email_message}
"""
# Add patient info if available
if complaint.patient:
email_body += f"""
PATIENT INFORMATION:
------------------
Name: {complaint.patient.get_full_name()}
MRN: {complaint.patient.mrn}
"""
# Add additional message if provided
if additional_message:
email_body += f"""
ADDITIONAL MESSAGE:
------------------
{additional_message}
"""
# Add link to complaint
from django.contrib.sites.shortcuts import get_current_site
site = get_current_site(request)
complaint_url = f"https://{site.domain}/complaints/{complaint.id}/"
email_body += f"""
To view the full complaint details, please visit:
{complaint_url}
Thank you for your attention to this matter.
---
This is an automated message from PX360 Complaint Management System.
"""
# Send email using NotificationService
from apps.notifications.services import NotificationService
try:
notification_log = NotificationService.send_email(
email=recipient.email,
subject=subject,
message=email_body,
related_object=complaint,
metadata={
'notification_type': 'complaint_notification',
'recipient_type': recipient_type,
'recipient_id': str(recipient.id),
'sender_id': str(request.user.id),
'has_additional_message': bool(additional_message)
}
)
except Exception as e:
return Response(
{'error': f'Failed to send email: {str(e)}'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
# Create ComplaintUpdate entry
ComplaintUpdate.objects.create(
complaint=complaint,
update_type='communication',
message=f"Email notification sent to {recipient_type}: {recipient_display}",
created_by=request.user,
metadata={
'recipient_type': recipient_type,
'recipient_id': str(recipient.id),
'notification_log_id': str(notification_log.id) if notification_log else None
}
)
# Log audit
AuditService.log_from_request(
event_type='notification_sent',
description=f"Email notification sent to {recipient_type}: {recipient_display}",
request=request,
content_object=complaint,
metadata={
'recipient_type': recipient_type,
'recipient_id': str(recipient.id),
'recipient_email': recipient.email
}
)
return Response({
'success': True,
'message': 'Email notification sent successfully',
'recipient': recipient_display,
'recipient_type': recipient_type,
'recipient_email': recipient.email
})
class ComplaintAttachmentViewSet(viewsets.ModelViewSet):
"""ViewSet for Complaint Attachments"""

View File

@ -37,7 +37,8 @@ class AIService:
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
OPENROUTER_API_KEY = "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"
# Default configuration
DEFAULT_MODEL = "openrouter/xiaomi/mimo-v2-flash:free"
DEFAULT_MODEL = "openrouter/z-ai/glm-4.7"
# DEFAULT_MODEL = "openrouter/xiaomi/mimo-v2-flash:free"
DEFAULT_TEMPERATURE = 0.3
DEFAULT_MAX_TOKENS = 500
DEFAULT_TIMEOUT = 30

View File

@ -0,0 +1,65 @@
# Generated migration to add missing fields to Observation model
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('organizations', '0001_initial'), # Need hospital and department models
('observations', '0001_initial'),
]
operations = [
# Add hospital field (required for tenant isolation)
# Initially nullable, will be made required in next migration
migrations.AddField(
model_name='observation',
name='hospital',
field=models.ForeignKey(
null=True,
blank=True,
on_delete=django.db.models.deletion.CASCADE,
related_name='observations',
to='organizations.hospital'
),
),
# Add staff field (optional, for AI-matching like complaints)
migrations.AddField(
model_name='observation',
name='staff',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='observations',
to='organizations.staff'
),
),
# Add source field (to track how observation was submitted)
migrations.AddField(
model_name='observation',
name='source',
field=models.CharField(
blank=True,
choices=[
('staff_portal', 'Staff Portal'),
('web_form', 'Web Form'),
('mobile_app', 'Mobile App'),
('email', 'Email'),
('call_center', 'Call Center'),
('other', 'Other'),
],
default='staff_portal',
max_length=50
),
),
# Add indexes for hospital filtering
migrations.AddIndex(
model_name='observation',
index=models.Index(fields=['hospital', 'status', '-created_at'], name='obs_hospital_status_idx'),
),
]

View File

@ -0,0 +1,36 @@
# Generated by Django 5.0.14 on 2026-01-07 11:02
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('observations', '0002_add_missing_fields'),
('organizations', '0005_alter_staff_department'),
]
operations = [
migrations.RenameIndex(
model_name='observation',
new_name='observation_hospita_dcd21a_idx',
old_name='obs_hospital_status_idx',
),
migrations.AlterField(
model_name='observation',
name='hospital',
field=models.ForeignKey(default=1, help_text='Hospital where observation was made', on_delete=django.db.models.deletion.CASCADE, related_name='observations', to='organizations.hospital'),
preserve_default=False,
),
migrations.AlterField(
model_name='observation',
name='source',
field=models.CharField(choices=[('staff_portal', 'Staff Portal'), ('web_form', 'Web Form'), ('mobile_app', 'Mobile App'), ('email', 'Email'), ('call_center', 'Call Center'), ('other', 'Other')], default='staff_portal', help_text='How the observation was submitted', max_length=50),
),
migrations.AlterField(
model_name='observation',
name='staff',
field=models.ForeignKey(blank=True, help_text='Staff member mentioned in observation', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='observations', to='organizations.staff'),
),
]

View File

@ -166,6 +166,39 @@ class Observation(UUIDModel, TimeStampedModel):
db_index=True
)
# Organization (required for tenant isolation)
hospital = models.ForeignKey(
'organizations.Hospital',
on_delete=models.CASCADE,
related_name='observations',
help_text="Hospital where observation was made"
)
# Staff member mentioned in observation (optional, for AI-matching like complaints)
staff = models.ForeignKey(
'organizations.Staff',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='observations',
help_text="Staff member mentioned in observation"
)
# Source tracking
source = models.CharField(
max_length=50,
choices=[
('staff_portal', 'Staff Portal'),
('web_form', 'Web Form'),
('mobile_app', 'Mobile App'),
('email', 'Email'),
('call_center', 'Call Center'),
('other', 'Other'),
],
default='staff_portal',
help_text="How the observation was submitted"
)
# Internal routing
assigned_department = models.ForeignKey(
'organizations.Department',
@ -231,6 +264,7 @@ class Observation(UUIDModel, TimeStampedModel):
class Meta:
ordering = ['-created_at']
indexes = [
models.Index(fields=['hospital', 'status', '-created_at']),
models.Index(fields=['status', '-created_at']),
models.Index(fields=['severity', '-created_at']),
models.Index(fields=['tracking_code']),

View File

@ -0,0 +1,406 @@
"""
Management command to seed staff data with bilingual support (English and Arabic)
"""
import random
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from apps.accounts.models import User
from apps.organizations.models import Hospital, Department, Staff
# Saudi names data - Paired to ensure English and Arabic correspond
NAMES_MALE = [
{'en': 'Mohammed', 'ar': 'محمد'},
{'en': 'Ahmed', 'ar': 'أحمد'},
{'en': 'Abdullah', 'ar': 'عبدالله'},
{'en': 'Khalid', 'ar': 'خالد'},
{'en': 'Saud', 'ar': 'سعود'},
{'en': 'Fahd', 'ar': 'فهد'},
{'en': 'Abdulaziz', 'ar': 'عبدالعزيز'},
{'en': 'Sultan', 'ar': 'سلطان'},
{'en': 'Faisal', 'ar': 'فيصل'},
{'en': 'Omar', 'ar': 'عمر'},
{'en': 'Turki', 'ar': 'تركي'},
{'en': 'Nasser', 'ar': 'ناصر'},
{'en': 'Mishari', 'ar': 'مشاري'},
{'en': 'Abdulrahman', 'ar': 'عبدالرحمن'},
{'en': 'Yousef', 'ar': 'يوسف'},
{'en': 'Ali', 'ar': 'علي'}
]
NAMES_FEMALE = [
{'en': 'Fatimah', 'ar': 'فاطمة'},
{'en': 'Aisha', 'ar': 'عائشة'},
{'en': 'Maryam', 'ar': 'مريم'},
{'en': 'Noura', 'ar': 'نورة'},
{'en': 'Sarah', 'ar': 'سارة'},
{'en': 'Hind', 'ar': 'هند'},
{'en': 'Latifa', 'ar': 'لطيفة'},
{'en': 'Mona', 'ar': 'منى'},
{'en': 'Reem', 'ar': 'ريم'},
{'en': 'Jawaher', 'ar': 'جواهر'},
{'en': 'Lina', 'ar': 'لينا'},
{'en': 'Dua', 'ar': 'دعاء'},
{'en': 'Maha', 'ar': 'مها'},
{'en': 'Jumanah', 'ar': 'جمانة'},
{'en': 'Rahaf', 'ar': 'رحاب'},
{'en': 'Samar', 'ar': 'سمر'}
]
LAST_NAMES = [
{'en': 'Al-Otaibi', 'ar': 'العتيبي'},
{'en': 'Al-Dosari', 'ar': 'الدوسري'},
{'en': 'Al-Qahtani', 'ar': 'القحطاني'},
{'en': 'Al-Shammari', 'ar': 'الشمري'},
{'en': 'Al-Harbi', 'ar': 'الحربي'},
{'en': 'Al-Mutairi', 'ar': 'المطيري'},
{'en': 'Al-Anazi', 'ar': 'العنزي'},
{'en': 'Al-Zahrani', 'ar': 'الزهراني'},
{'en': 'Al-Ghamdi', 'ar': 'الغامدي'},
{'en': 'Al-Shehri', 'ar': 'الشهري'},
{'en': 'Al-Salem', 'ar': 'السالم'},
{'en': 'Al-Fahad', 'ar': 'الفالح'}
]
# Specializations for physicians
PHYSICIAN_SPECIALIZATIONS = [
'Internal Medicine', 'General Surgery', 'Pediatrics', 'Obstetrics & Gynecology',
'Cardiology', 'Orthopedics', 'Neurology', 'Dermatology', 'Ophthalmology',
'ENT', 'Urology', 'Nephrology', 'Gastroenterology', 'Pulmonology',
'Endocrinology', 'Rheumatology', 'Hematology', 'Oncology', 'Psychiatry'
]
# Job titles for nurses
NURSE_JOB_TITLES = [
'Registered Nurse', 'Senior Nurse', 'Nurse Practitioner', 'ICU Nurse',
'Emergency Nurse', 'Pediatric Nurse', 'Operating Room Nurse', 'Head Nurse'
]
# Job titles for admin staff
ADMIN_JOB_TITLES = [
'Medical Receptionist', 'Administrative Assistant', 'Medical Records Clerk',
'Hospital Administrator', 'Department Secretary', 'Patient Services Representative',
'Front Desk Officer', 'Billing Specialist'
]
class Command(BaseCommand):
help = 'Seed staff data with bilingual support (English and Arabic)'
def add_arguments(self, parser):
parser.add_argument(
'--hospital-code',
type=str,
help='Target hospital code (default: all hospitals)'
)
parser.add_argument(
'--count',
type=int,
default=10,
help='Number of staff to create per type (default: 10)'
)
parser.add_argument(
'--physicians',
type=int,
default=10,
help='Number of physicians to create (default: 10)'
)
parser.add_argument(
'--nurses',
type=int,
default=15,
help='Number of nurses to create (default: 15)'
)
parser.add_argument(
'--admin-staff',
type=int,
default=5,
help='Number of admin staff to create (default: 5)'
)
parser.add_argument(
'--create-users',
action='store_true',
help='Create user accounts for staff'
)
parser.add_argument(
'--clear',
action='store_true',
help='Clear existing staff first'
)
parser.add_argument(
'--dry-run',
action='store_true',
help='Preview without making changes'
)
def handle(self, *args, **options):
hospital_code = options['hospital_code']
count = options['count']
physicians_count = options['physicians']
nurses_count = options['nurses']
admin_staff_count = options['admin_staff']
create_users = options['create_users']
clear_existing = options['clear']
dry_run = options['dry_run']
self.stdout.write(f"\n{'='*60}")
self.stdout.write("Staff Data Seeding Command")
self.stdout.write(f"{'='*60}\n")
with transaction.atomic():
# Get hospitals
if hospital_code:
hospitals = Hospital.objects.filter(code=hospital_code)
if not hospitals.exists():
self.stdout.write(
self.style.ERROR(f"Hospital with code '{hospital_code}' not found")
)
return
else:
hospitals = Hospital.objects.filter(status='active')
if not hospitals.exists():
self.stdout.write(
self.style.ERROR("No hospitals found. Please create hospitals first.")
)
return
self.stdout.write(
self.style.SUCCESS(f"Found {hospitals.count()} hospital(s) to seed staff")
)
# Display configuration
self.stdout.write("\nConfiguration:")
self.stdout.write(f" Physicians per hospital: {physicians_count}")
self.stdout.write(f" Nurses per hospital: {nurses_count}")
self.stdout.write(f" Admin staff per hospital: {admin_staff_count}")
self.stdout.write(f" Total staff per hospital: {physicians_count + nurses_count + admin_staff_count}")
self.stdout.write(f" Create user accounts: {create_users}")
self.stdout.write(f" Clear existing: {clear_existing}")
self.stdout.write(f" Dry run: {dry_run}")
# Get all departments for assignment
all_departments = Department.objects.filter(status='active')
if not all_departments.exists():
self.stdout.write(
self.style.WARNING("\nNo departments found. Staff will be created without departments.")
)
# Clear existing staff if requested
if clear_existing:
if dry_run:
self.stdout.write(
self.style.WARNING(f"\nWould delete {Staff.objects.count()} existing staff")
)
else:
deleted_count = Staff.objects.count()
Staff.objects.all().delete()
self.stdout.write(
self.style.SUCCESS(f"\n✓ Deleted {deleted_count} existing staff")
)
# Track created staff
created_staff = []
# Seed physicians
physicians = self.create_staff_type(
hospitals=hospitals,
departments=all_departments,
staff_type=Staff.StaffType.PHYSICIAN,
count=physicians_count,
job_titles=PHYSICIAN_SPECIALIZATIONS,
create_users=create_users,
dry_run=dry_run
)
created_staff.extend(physicians)
# Seed nurses
nurses = self.create_staff_type(
hospitals=hospitals,
departments=all_departments,
staff_type=Staff.StaffType.NURSE,
count=nurses_count,
job_titles=NURSE_JOB_TITLES,
create_users=create_users,
dry_run=dry_run
)
created_staff.extend(nurses)
# Seed admin staff
admins = self.create_staff_type(
hospitals=hospitals,
departments=all_departments,
staff_type=Staff.StaffType.ADMIN,
count=admin_staff_count,
job_titles=ADMIN_JOB_TITLES,
create_users=create_users,
dry_run=dry_run
)
created_staff.extend(admins)
# Summary
self.stdout.write("\n" + "="*60)
self.stdout.write("Summary:")
self.stdout.write(f" Physicians created: {len(physicians)}")
self.stdout.write(f" Nurses created: {len(nurses)}")
self.stdout.write(f" Admin staff created: {len(admins)}")
self.stdout.write(f" Total staff created: {len(created_staff)}")
self.stdout.write("="*60 + "\n")
if dry_run:
self.stdout.write(self.style.WARNING("DRY RUN: No changes were made\n"))
else:
self.stdout.write(self.style.SUCCESS("Staff seeding completed successfully!\n"))
def create_staff_type(self, hospitals, departments, staff_type, count, job_titles,
create_users, dry_run):
"""Create staff of a specific type"""
created = []
staff_type_display = dict(Staff.StaffType.choices).get(staff_type, staff_type)
self.stdout.write(f"\nSeeding {staff_type_display}...")
for hospital in hospitals:
# Get departments for this hospital
hospital_depts = [d for d in departments if d.hospital == hospital]
for i in range(count):
# Generate names (paired to ensure English and Arabic correspond)
if staff_type == Staff.StaffType.NURSE:
# More female nurses (70%)
if random.random() < 0.7:
first_name = random.choice(NAMES_FEMALE)
else:
first_name = random.choice(NAMES_MALE)
else:
# Physicians and admin staff are mostly male (60%)
if random.random() < 0.6:
first_name = random.choice(NAMES_MALE)
else:
first_name = random.choice(NAMES_FEMALE)
last_name = random.choice(LAST_NAMES)
job_title = random.choice(job_titles)
# Generate employee ID
employee_id = self.generate_employee_id(hospital.code, staff_type)
# Generate license number for physicians
license_number = None
if staff_type == Staff.StaffType.PHYSICIAN:
license_number = self.generate_license_number()
# Select department (optional)
department = random.choice(hospital_depts) if hospital_depts and random.random() > 0.1 else None
# Specialization for all staff types (required field)
if staff_type == Staff.StaffType.PHYSICIAN:
specialization = job_title
elif staff_type == Staff.StaffType.NURSE:
specialization = random.choice(['General Nursing', 'Patient Care', 'Clinical Nursing', 'Nursing'])
elif staff_type == Staff.StaffType.ADMIN:
specialization = random.choice(['Administration', 'Hospital Operations', 'Medical Administration', 'Healthcare Admin'])
else:
specialization = 'General Staff'
if dry_run:
self.stdout.write(
f" Would create: {first_name['en']} {last_name['en']} ({first_name['ar']} {last_name['ar']}) - {job_title}"
)
created.append({'type': staff_type_display, 'name': f"{first_name['en']} {last_name['en']}"})
else:
# Create staff
staff = Staff.objects.create(
first_name=first_name['en'],
last_name=last_name['en'],
first_name_ar=first_name['ar'],
last_name_ar=last_name['ar'],
staff_type=staff_type,
job_title=job_title,
license_number=license_number,
specialization=specialization,
employee_id=employee_id,
hospital=hospital,
department=department,
status='active'
)
# Create user account if requested
if create_users:
self.create_user_for_staff(staff)
created.append(staff)
self.stdout.write(
self.style.SUCCESS(f" ✓ Created {len([s for s in created if isinstance(s, Staff)])} {staff_type_display}")
)
return created
def generate_employee_id(self, hospital_code, staff_type):
"""Generate unique employee ID"""
prefix = {
Staff.StaffType.PHYSICIAN: 'DR',
Staff.StaffType.NURSE: 'RN',
Staff.StaffType.ADMIN: 'ADM',
Staff.StaffType.OTHER: 'STF'
}.get(staff_type, 'STF')
random_num = random.randint(10000, 99999)
return f"{prefix}-{hospital_code}-{random_num}"
def generate_license_number(self):
"""Generate unique license number"""
random_num = random.randint(1000000, 9999999)
return f"MOH-LIC-{random_num}"
def create_user_for_staff(self, staff):
"""Create a user account for staff"""
username = self.generate_username(staff)
# Check if user already exists
if User.objects.filter(username=username).exists():
return
# Generate email
email = f"{staff.first_name.lower()}.{staff.last_name.lower()}@{staff.hospital.code.lower()}.sa"
# Check if email exists
if User.objects.filter(email=email).exists():
email = f"{username}@{staff.hospital.code.lower()}.sa"
# Create user
user = User.objects.create_user(
username=username,
email=email,
first_name=staff.first_name,
last_name=staff.last_name,
password='password123', # Default password
employee_id=staff.employee_id,
hospital=staff.hospital,
department=staff.department,
language='ar' if random.random() < 0.5 else 'en', # Random language preference
is_staff=True,
)
# Link staff to user
staff.user = user
staff.save(update_fields=['user'])
self.stdout.write(
self.style.SUCCESS(f" ✓ Created user: {username}")
)
def generate_username(self, staff):
"""Generate unique username"""
base = f"{staff.first_name.lower()}.{staff.last_name.lower()}"
username = base
# Add suffix if username exists
counter = 1
while User.objects.filter(username=username).exists():
username = f"{base}{counter}"
counter += 1
return username

View File

@ -0,0 +1,19 @@
# Generated by Django 5.0.14 on 2026-01-07 08:54
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('organizations', '0004_remove_patient_department'),
]
operations = [
migrations.AlterField(
model_name='staff',
name='department',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='staff', to='organizations.department'),
),
]

View File

@ -161,7 +161,7 @@ class Staff(UUIDModel, TimeStampedModel):
# Organization
hospital = models.ForeignKey(Hospital, on_delete=models.CASCADE, related_name='staff')
department = models.ForeignKey(Department, on_delete=models.SET_NULL, null=True, blank=True)
department = models.ForeignKey(Department, on_delete=models.SET_NULL, null=True, blank=True, related_name='staff')
status = models.CharField(max_length=20, choices=StatusChoices.choices, default=StatusChoices.ACTIVE)

View File

@ -0,0 +1,4 @@
"""
References app - Reference Section for document management
"""
default_app_config = 'apps.references.apps.ReferencesConfig'

120
apps/references/admin.py Normal file
View File

@ -0,0 +1,120 @@
"""
References admin - Django admin configuration for Reference Section
"""
from django.contrib import admin
from django.utils.html import format_html
from apps.references.models import ReferenceFolder, ReferenceDocument, ReferenceDocumentAccess
@admin.register(ReferenceFolder)
class ReferenceFolderAdmin(admin.ModelAdmin):
"""Admin interface for ReferenceFolder"""
list_display = ['name', 'name_ar', 'hospital', 'parent', 'get_document_count', 'is_active', 'order']
list_filter = ['hospital', 'is_active', 'parent']
search_fields = ['name', 'name_ar', 'description', 'description_ar']
prepopulated_fields = {}
readonly_fields = ['created_at', 'updated_at', 'get_full_path_display']
fieldsets = (
('Basic Information', {
'fields': ('hospital', 'name', 'name_ar', 'parent')
}),
('Description', {
'fields': ('description', 'description_ar')
}),
('UI Customization', {
'fields': ('icon', 'color', 'order')
}),
('Access Control', {
'fields': ('access_roles', 'is_active')
}),
('System Information', {
'fields': ('get_full_path_display', 'is_deleted', 'deleted_at', 'created_at', 'updated_at'),
'classes': ('collapse',)
}),
)
filter_horizontal = ['access_roles']
def get_full_path_display(self, obj):
"""Display full path in admin"""
return obj.get_full_path()
get_full_path_display.short_description = 'Full Path'
def get_document_count(self, obj):
"""Display document count"""
count = obj.get_document_count()
return f"{count} document{'s' if count != 1 else ''}"
get_document_count.short_description = 'Documents'
@admin.register(ReferenceDocument)
class ReferenceDocumentAdmin(admin.ModelAdmin):
"""Admin interface for ReferenceDocument"""
list_display = ['title', 'folder', 'hospital', 'get_file_icon_display', 'version', 'is_latest_version', 'download_count', 'is_published']
list_filter = ['hospital', 'folder', 'file_type', 'is_latest_version', 'is_published']
search_fields = ['title', 'title_ar', 'filename', 'description', 'description_ar', 'tags']
prepopulated_fields = {}
readonly_fields = ['file_type', 'file_size', 'download_count', 'created_at', 'updated_at']
fieldsets = (
('Basic Information', {
'fields': ('hospital', 'folder', 'title', 'title_ar')
}),
('File', {
'fields': ('file', 'filename', 'file_type', 'file_size', 'get_file_icon_display')
}),
('Description', {
'fields': ('description', 'description_ar')
}),
('Versioning', {
'fields': ('version', 'is_latest_version', 'parent_document')
}),
('Upload Information', {
'fields': ('uploaded_by',)
}),
('Usage Tracking', {
'fields': ('download_count', 'last_accessed_at')
}),
('Access Control', {
'fields': ('access_roles', 'is_published')
}),
('Tags', {
'fields': ('tags', 'metadata')
}),
('System Information', {
'fields': ('is_deleted', 'deleted_at', 'created_at', 'updated_at'),
'classes': ('collapse',)
}),
)
filter_horizontal = ['access_roles']
def get_file_icon_display(self, obj):
"""Display file icon"""
icon_class = obj.get_file_icon()
return format_html('<i class="fa {}"></i>', icon_class)
get_file_icon_display.short_description = 'File Type'
@admin.register(ReferenceDocumentAccess)
class ReferenceDocumentAccessAdmin(admin.ModelAdmin):
"""Admin interface for ReferenceDocumentAccess"""
list_display = ['document', 'user', 'action', 'ip_address', 'created_at']
list_filter = ['action', 'created_at', 'document__hospital']
search_fields = ['user__email', 'user__username', 'document__title']
readonly_fields = ['created_at']
fieldsets = (
('Access Details', {
'fields': ('document', 'user', 'action')
}),
('Context', {
'fields': ('ip_address', 'user_agent')
}),
('System Information', {
'fields': ('created_at',),
'classes': ('collapse',)
}),
)

11
apps/references/apps.py Normal file
View File

@ -0,0 +1,11 @@
"""
References app configuration
"""
from django.apps import AppConfig
class ReferencesConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.references'
verbose_name = 'Reference Section'
verbose_name_plural = 'Reference Section'

256
apps/references/forms.py Normal file
View File

@ -0,0 +1,256 @@
"""
References forms - Forms for Reference Section
"""
from django import forms
from django.utils.translation import gettext_lazy as _
from apps.references.models import ReferenceFolder, ReferenceDocument
class ReferenceFolderForm(forms.ModelForm):
"""Form for creating and editing reference folders"""
class Meta:
model = ReferenceFolder
fields = [
'name', 'name_ar',
'description', 'description_ar',
'parent', 'icon', 'color', 'order',
'access_roles', 'is_active'
]
widgets = {
'name': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': _('Enter folder name (English)')
}),
'name_ar': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': _('Enter folder name (Arabic)')
}),
'description': forms.Textarea(attrs={
'class': 'form-control',
'rows': 3,
'placeholder': _('Enter folder description (English)')
}),
'description_ar': forms.Textarea(attrs={
'class': 'form-control',
'rows': 3,
'placeholder': _('Enter folder description (Arabic)')
}),
'parent': forms.Select(attrs={
'class': 'form-select'
}),
'icon': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': _('e.g., fa-folder, fa-file-pdf')
}),
'color': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': _('e.g., #007bff')
}),
'order': forms.NumberInput(attrs={
'class': 'form-control',
'min': 0
}),
'access_roles': forms.CheckboxSelectMultiple(),
'is_active': forms.CheckboxInput(attrs={
'class': 'form-check-input'
})
}
def __init__(self, *args, **kwargs):
hospital = kwargs.pop('hospital', None)
super().__init__(*args, **kwargs)
# Filter parent folders by hospital
if hospital:
self.fields['parent'].queryset = ReferenceFolder.objects.filter(
hospital=hospital,
is_deleted=False
)
# Filter access roles
if hospital:
from django.contrib.auth.models import Group
self.fields['access_roles'].queryset = Group.objects.all()
def clean_name(self):
"""Ensure name is provided in at least one language"""
name = self.cleaned_data.get('name', '')
name_ar = self.cleaned_data.get('name_ar', '')
if not name and not name_ar:
raise forms.ValidationError(_('Please provide a name in English or Arabic.'))
return name
class ReferenceDocumentForm(forms.ModelForm):
"""Form for uploading and editing reference documents"""
new_version = forms.BooleanField(
required=False,
label=_('Upload as new version'),
help_text=_('Check this to create a new version of an existing document')
)
class Meta:
model = ReferenceDocument
fields = [
'folder', 'title', 'title_ar',
'file', 'description', 'description_ar',
'access_roles', 'is_published',
'tags'
]
widgets = {
'folder': forms.Select(attrs={
'class': 'form-select'
}),
'title': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': _('Enter document title (English)')
}),
'title_ar': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': _('Enter document title (Arabic)')
}),
'file': forms.FileInput(attrs={
'class': 'form-control'
}),
'description': forms.Textarea(attrs={
'class': 'form-control',
'rows': 4,
'placeholder': _('Enter document description (English)')
}),
'description_ar': forms.Textarea(attrs={
'class': 'form-control',
'rows': 4,
'placeholder': _('Enter document description (Arabic)')
}),
'access_roles': forms.CheckboxSelectMultiple(),
'is_published': forms.CheckboxInput(attrs={
'class': 'form-check-input'
}),
'tags': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': _('e.g., policy, procedure, handbook (comma-separated)')
})
}
def __init__(self, *args, **kwargs):
hospital = kwargs.pop('hospital', None)
folder = kwargs.pop('folder', None)
super().__init__(*args, **kwargs)
# Filter folders by hospital
if hospital:
self.fields['folder'].queryset = ReferenceFolder.objects.filter(
hospital=hospital,
is_active=True,
is_deleted=False
)
# Filter access roles
if hospital:
from django.contrib.auth.models import Group
self.fields['access_roles'].queryset = Group.objects.all()
# Make file required for new documents, optional for updates
if self.instance and self.instance.pk:
self.fields['file'].required = False
else:
self.fields['file'].required = True
# Make folder optional when uploading from within a folder
if folder:
self.fields['folder'].required = False
self.fields['folder'].initial = folder
def clean_title(self):
"""Ensure title is provided in at least one language"""
title = self.cleaned_data.get('title', '')
title_ar = self.cleaned_data.get('title_ar', '')
if not title and not title_ar:
raise forms.ValidationError(_('Please provide a title in English or Arabic.'))
return title
def clean(self):
"""Custom validation for new version creation"""
cleaned_data = super().clean()
new_version = cleaned_data.get('new_version')
file = cleaned_data.get('file')
# If uploading as new version, file is required
if new_version and not file and not (self.instance and self.instance.pk and self.instance.file):
raise forms.ValidationError({
'file': _('File is required when uploading a new version.')
})
return cleaned_data
class ReferenceDocumentSearchForm(forms.Form):
"""Form for searching and filtering documents"""
search = forms.CharField(
required=False,
label=_('Search'),
widget=forms.TextInput(attrs={
'class': 'form-control',
'placeholder': _('Search by title, description, or tags...')
})
)
folder = forms.ModelChoiceField(
required=False,
label=_('Folder'),
queryset=ReferenceFolder.objects.none(),
widget=forms.Select(attrs={
'class': 'form-select'
})
)
file_type = forms.ChoiceField(
required=False,
label=_('File Type'),
choices=[
('', _('All Types')),
('pdf', 'PDF'),
('doc', 'Word'),
('docx', 'Word'),
('xls', 'Excel'),
('xlsx', 'Excel'),
('ppt', 'PowerPoint'),
('pptx', 'PowerPoint'),
('jpg', 'Image'),
('jpeg', 'Image'),
('png', 'Image'),
('gif', 'Image'),
],
widget=forms.Select(attrs={
'class': 'form-select'
})
)
tags = forms.CharField(
required=False,
label=_('Tags'),
widget=forms.TextInput(attrs={
'class': 'form-control',
'placeholder': _('Filter by tags...')
})
)
def __init__(self, *args, **kwargs):
hospital = kwargs.pop('hospital', None)
super().__init__(*args, **kwargs)
# Filter folders by hospital
if hospital:
self.fields['folder'].queryset = ReferenceFolder.objects.filter(
hospital=hospital,
is_active=True,
is_deleted=False
)

View File

@ -0,0 +1,121 @@
# Generated by Django 5.0.14 on 2026-01-07 16:18
import apps.references.models
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
('organizations', '0005_alter_staff_department'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ReferenceFolder',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('is_deleted', models.BooleanField(db_index=True, default=False)),
('deleted_at', models.DateTimeField(blank=True, null=True)),
('name', models.CharField(db_index=True, max_length=200)),
('name_ar', models.CharField(blank=True, max_length=200, verbose_name='Name (Arabic)')),
('description', models.TextField(blank=True)),
('description_ar', models.TextField(blank=True, verbose_name='Description (Arabic)')),
('icon', models.CharField(blank=True, help_text="Icon class (e.g., 'fa-folder', 'fa-file-pdf')", max_length=50)),
('color', models.CharField(blank=True, help_text="Hex color code (e.g., '#007bff')", max_length=7)),
('order', models.IntegerField(default=0, help_text='Display order within parent folder')),
('is_active', models.BooleanField(db_index=True, default=True)),
('access_roles', models.ManyToManyField(blank=True, help_text='Roles that can access this folder (empty = all roles)', related_name='accessible_folders', to='auth.group')),
('hospital', models.ForeignKey(help_text='Tenant hospital for this record', on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_related', to='organizations.hospital')),
('parent', models.ForeignKey(blank=True, help_text='Parent folder for nested structure', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='subfolders', to='references.referencefolder')),
],
options={
'verbose_name': 'Reference Folder',
'verbose_name_plural': 'Reference Folders',
'ordering': ['parent__order', 'order', 'name'],
},
),
migrations.CreateModel(
name='ReferenceDocument',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('is_deleted', models.BooleanField(db_index=True, default=False)),
('deleted_at', models.DateTimeField(blank=True, null=True)),
('title', models.CharField(db_index=True, max_length=500)),
('title_ar', models.CharField(blank=True, max_length=500, verbose_name='Title (Arabic)')),
('file', models.FileField(max_length=500, upload_to=apps.references.models.document_upload_path)),
('filename', models.CharField(help_text='Original filename', max_length=500)),
('file_type', models.CharField(blank=True, help_text='File extension/type (e.g., pdf, docx, xlsx)', max_length=50)),
('file_size', models.IntegerField(help_text='File size in bytes')),
('description', models.TextField(blank=True)),
('description_ar', models.TextField(blank=True, verbose_name='Description (Arabic)')),
('version', models.CharField(default='1.0', help_text='Document version (e.g., 1.0, 1.1, 2.0)', max_length=20)),
('is_latest_version', models.BooleanField(db_index=True, default=True, help_text='Is this the latest version?')),
('download_count', models.IntegerField(default=0, help_text='Number of downloads')),
('last_accessed_at', models.DateTimeField(blank=True, null=True)),
('is_published', models.BooleanField(db_index=True, default=True, help_text='Is this document visible to users?')),
('tags', models.CharField(blank=True, help_text='Comma-separated tags for search (e.g., policy, procedure, handbook)', max_length=500)),
('metadata', models.JSONField(blank=True, default=dict)),
('access_roles', models.ManyToManyField(blank=True, help_text='Roles that can access this document (empty = all roles)', related_name='accessible_documents', to='auth.group')),
('hospital', models.ForeignKey(help_text='Tenant hospital for this record', on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_related', to='organizations.hospital')),
('parent_document', models.ForeignKey(blank=True, help_text='Previous version of this document', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='versions', to='references.referencedocument')),
('uploaded_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='uploaded_documents', to=settings.AUTH_USER_MODEL)),
('folder', models.ForeignKey(blank=True, help_text='Folder containing this document', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='references.referencefolder')),
],
options={
'verbose_name': 'Reference Document',
'verbose_name_plural': 'Reference Documents',
'ordering': ['title', '-created_at'],
},
),
migrations.CreateModel(
name='ReferenceDocumentAccess',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('action', models.CharField(choices=[('view', 'Viewed'), ('download', 'Downloaded'), ('preview', 'Previewed')], db_index=True, max_length=20)),
('ip_address', models.GenericIPAddressField(blank=True, null=True)),
('user_agent', models.TextField(blank=True)),
('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='access_logs', to='references.referencedocument')),
('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='document_accesses', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Document Access Log',
'verbose_name_plural': 'Document Access Logs',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['document', '-created_at'], name='references__documen_396fa2_idx'), models.Index(fields=['user', '-created_at'], name='references__user_id_56bf5f_idx'), models.Index(fields=['action', '-created_at'], name='references__action_b9899d_idx')],
},
),
migrations.AddIndex(
model_name='referencefolder',
index=models.Index(fields=['hospital', 'parent', 'order'], name='references__hospita_faa66f_idx'),
),
migrations.AddIndex(
model_name='referencefolder',
index=models.Index(fields=['hospital', 'is_active'], name='references__hospita_6c43f3_idx'),
),
migrations.AddIndex(
model_name='referencedocument',
index=models.Index(fields=['hospital', 'folder', 'is_latest_version'], name='references__hospita_36d516_idx'),
),
migrations.AddIndex(
model_name='referencedocument',
index=models.Index(fields=['hospital', 'is_published'], name='references__hospita_413b58_idx'),
),
migrations.AddIndex(
model_name='referencedocument',
index=models.Index(fields=['folder', 'title'], name='references__folder__e09b4c_idx'),
),
]

View File

@ -0,0 +1 @@
# Migrations for references app

389
apps/references/models.py Normal file
View File

@ -0,0 +1,389 @@
"""
References models - Reference Section for document management
This module implements a file server system for managing reference documents
with folder categorization, version control, and role-based access control.
"""
import os
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.db import models
from apps.core.models import TenantModel, TimeStampedModel, UUIDModel, SoftDeleteModel
User = get_user_model()
def document_upload_path(instance, filename):
"""
Generate upload path for reference documents.
Format: references/<hospital_id>/YYYY/MM/DD/<uuid>_<filename>
"""
hospital_id = instance.hospital.id if instance.hospital else 'default'
from django.utils import timezone
date_str = timezone.now().strftime('%Y/%m/%d')
# Get file extension
ext = os.path.splitext(filename)[1]
return f'references/{hospital_id}/{date_str}/{instance.id}{ext}'
class ReferenceFolder(UUIDModel, TimeStampedModel, SoftDeleteModel, TenantModel):
"""
Reference Folder model for organizing documents into hierarchical folders.
Features:
- Hospital-specific folders (via TenantModel)
- Nested folder hierarchy via self-referential parent field
- Bilingual support (English/Arabic)
- Role-based access control
- Soft delete support
"""
# Bilingual folder name
name = models.CharField(max_length=200, db_index=True)
name_ar = models.CharField(max_length=200, blank=True, verbose_name="Name (Arabic)")
# Description
description = models.TextField(blank=True)
description_ar = models.TextField(blank=True, verbose_name="Description (Arabic)")
# Hierarchy - self-referential for nested folders
parent = models.ForeignKey(
'self',
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='subfolders',
help_text="Parent folder for nested structure"
)
# UI customization
icon = models.CharField(
max_length=50,
blank=True,
help_text="Icon class (e.g., 'fa-folder', 'fa-file-pdf')"
)
color = models.CharField(
max_length=7,
blank=True,
help_text="Hex color code (e.g., '#007bff')"
)
# Ordering
order = models.IntegerField(
default=0,
help_text="Display order within parent folder"
)
# Role-based access control
access_roles = models.ManyToManyField(
'auth.Group',
blank=True,
related_name='accessible_folders',
help_text="Roles that can access this folder (empty = all roles)"
)
# Status
is_active = models.BooleanField(default=True, db_index=True)
class Meta:
ordering = ['parent__order', 'order', 'name']
verbose_name = 'Reference Folder'
verbose_name_plural = 'Reference Folders'
indexes = [
models.Index(fields=['hospital', 'parent', 'order']),
models.Index(fields=['hospital', 'is_active']),
]
def __str__(self):
hospital_name = self.hospital.name if self.hospital else 'No Hospital'
return f"{hospital_name} - {self.name}"
def get_full_path(self):
"""Get full folder path as a list of folder names"""
path = [self.name]
parent = self.parent
while parent:
path.insert(0, parent.name)
parent = parent.parent
return ' / '.join(path)
def has_access(self, user):
"""
Check if user has access to this folder.
Returns True if:
- Folder has no access_roles (public to all)
- User belongs to any of the access_roles
"""
if not self.access_roles.exists():
return True
return user.groups.filter(id__in=self.access_roles.all()).exists()
def get_subfolders(self):
"""Get all immediate subfolders"""
return self.subfolders.filter(is_active=True, is_deleted=False)
def get_documents(self):
"""Get all documents in this folder"""
return self.documents.filter(is_published=True, is_deleted=False)
def get_document_count(self):
"""Get count of documents in this folder (including subfolders)"""
count = self.get_documents().count()
for subfolder in self.get_subfolders():
count += subfolder.get_document_count()
return count
class ReferenceDocument(UUIDModel, TimeStampedModel, SoftDeleteModel, TenantModel):
"""
Reference Document model for storing and managing documents.
Features:
- Multi-version support
- Multiple file types (PDF, Word, Excel, etc.)
- Role-based access control
- Download tracking
- Bilingual support
- Soft delete support
"""
# Folder association
folder = models.ForeignKey(
ReferenceFolder,
on_delete=models.CASCADE,
related_name='documents',
null=True,
blank=True,
help_text="Folder containing this document"
)
# Bilingual title
title = models.CharField(max_length=500, db_index=True)
title_ar = models.CharField(max_length=500, blank=True, verbose_name="Title (Arabic)")
# File information
file = models.FileField(upload_to=document_upload_path, max_length=500)
filename = models.CharField(max_length=500, help_text="Original filename")
file_type = models.CharField(
max_length=50,
blank=True,
help_text="File extension/type (e.g., pdf, docx, xlsx)"
)
file_size = models.IntegerField(help_text="File size in bytes")
# Description
description = models.TextField(blank=True)
description_ar = models.TextField(blank=True, verbose_name="Description (Arabic)")
# Versioning
version = models.CharField(
max_length=20,
default='1.0',
help_text="Document version (e.g., 1.0, 1.1, 2.0)"
)
is_latest_version = models.BooleanField(
default=True,
db_index=True,
help_text="Is this the latest version?"
)
parent_document = models.ForeignKey(
'self',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='versions',
help_text="Previous version of this document"
)
# Upload tracking
uploaded_by = models.ForeignKey(
User,
on_delete=models.SET_NULL,
null=True,
related_name='uploaded_documents'
)
# Usage tracking
download_count = models.IntegerField(default=0, help_text="Number of downloads")
last_accessed_at = models.DateTimeField(null=True, blank=True)
# Visibility
is_published = models.BooleanField(
default=True,
db_index=True,
help_text="Is this document visible to users?"
)
# Role-based access control
access_roles = models.ManyToManyField(
'auth.Group',
blank=True,
related_name='accessible_documents',
help_text="Roles that can access this document (empty = all roles)"
)
# Tags for search
tags = models.CharField(
max_length=500,
blank=True,
help_text="Comma-separated tags for search (e.g., policy, procedure, handbook)"
)
# Additional metadata
metadata = models.JSONField(default=dict, blank=True)
class Meta:
ordering = ['title', '-created_at']
verbose_name = 'Reference Document'
verbose_name_plural = 'Reference Documents'
indexes = [
models.Index(fields=['hospital', 'folder', 'is_latest_version']),
models.Index(fields=['hospital', 'is_published']),
models.Index(fields=['folder', 'title']),
]
def __str__(self):
title = self.title or self.filename
version = f" v{self.version}" if self.version else ""
return f"{title}{version}"
def clean(self):
"""Validate file type"""
if self.file:
ext = os.path.splitext(self.file.name)[1].lower().lstrip('.')
# Allowed file types - can be extended as needed
allowed_types = [
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
'txt', 'rtf', 'csv', 'jpg', 'jpeg', 'png', 'gif'
]
if ext not in allowed_types:
raise ValidationError({
'file': f'File type "{ext}" is not allowed. Allowed types: {", ".join(allowed_types)}'
})
def save(self, *args, **kwargs):
"""Auto-populate file_type and file_size"""
if self.file:
# Extract file extension
ext = os.path.splitext(self.file.name)[1].lower().lstrip('.')
self.file_type = ext
# Get file size if not already set
if not self.file_size and hasattr(self.file, 'size'):
self.file_size = self.file.size
super().save(*args, **kwargs)
def has_access(self, user):
"""
Check if user has access to this document.
Returns True if:
- Document has no access_roles (public to all)
- User belongs to any of the access_roles
"""
if not self.access_roles.exists():
return True
return user.groups.filter(id__in=self.access_roles.all()).exists()
def increment_download_count(self):
"""Increment download count and update last_accessed_at"""
self.download_count += 1
from django.utils import timezone
self.last_accessed_at = timezone.now()
self.save(update_fields=['download_count', 'last_accessed_at'])
def get_tags_list(self):
"""Get tags as a list"""
return [tag.strip() for tag in self.tags.split(',') if tag.strip()]
def get_file_icon(self):
"""Get icon class based on file type"""
icon_map = {
'pdf': 'fa-file-pdf text-danger',
'doc': 'fa-file-word text-primary',
'docx': 'fa-file-word text-primary',
'xls': 'fa-file-excel text-success',
'xlsx': 'fa-file-excel text-success',
'ppt': 'fa-file-powerpoint text-warning',
'pptx': 'fa-file-powerpoint text-warning',
'jpg': 'fa-file-image text-info',
'jpeg': 'fa-file-image text-info',
'png': 'fa-file-image text-info',
'gif': 'fa-file-image text-info',
'txt': 'fa-file-lines text-secondary',
}
return icon_map.get(self.file_type.lower(), 'fa-file text-secondary')
def get_file_size_display(self):
"""Get human-readable file size"""
size = self.file_size
for unit in ['B', 'KB', 'MB', 'GB']:
if size < 1024:
return f"{size:.2f} {unit}"
size /= 1024
return f"{size:.2f} TB"
def get_version_history(self):
"""Get all versions of this document"""
versions = list(self.versions.all())
versions.append(self)
return sorted(versions, key=lambda x: x.version, reverse=True)
class ReferenceDocumentAccess(UUIDModel, TimeStampedModel):
"""
Reference Document Access model for tracking document access.
Features:
- Audit trail for document downloads/views
- Track user actions on documents
- IP address logging
"""
ACTION_CHOICES = [
('view', 'Viewed'),
('download', 'Downloaded'),
('preview', 'Previewed'),
]
# Document reference
document = models.ForeignKey(
ReferenceDocument,
on_delete=models.CASCADE,
related_name='access_logs'
)
# User who accessed the document
user = models.ForeignKey(
User,
on_delete=models.SET_NULL,
null=True,
related_name='document_accesses'
)
# Action type
action = models.CharField(
max_length=20,
choices=ACTION_CHOICES,
db_index=True
)
# Context information
ip_address = models.GenericIPAddressField(null=True, blank=True)
user_agent = models.TextField(blank=True)
class Meta:
ordering = ['-created_at']
verbose_name = 'Document Access Log'
verbose_name_plural = 'Document Access Logs'
indexes = [
models.Index(fields=['document', '-created_at']),
models.Index(fields=['user', '-created_at']),
models.Index(fields=['action', '-created_at']),
]
def __str__(self):
user_str = self.user.email if self.user else 'Anonymous'
return f"{user_str} - {self.get_action_display()} - {self.document}"

526
apps/references/ui_views.py Normal file
View File

@ -0,0 +1,526 @@
"""
References UI views - Server-rendered templates for Reference Section
"""
from django.contrib.auth.decorators import login_required
from django.contrib import messages
from django.core.paginator import Paginator
from django.db.models import Q
from django.http import JsonResponse
from django.shortcuts import get_object_or_404, render, redirect
from django.views.decorators.http import require_http_methods
from apps.references.models import ReferenceFolder, ReferenceDocument
from apps.references.forms import ReferenceFolderForm, ReferenceDocumentForm, ReferenceDocumentSearchForm
@login_required
def reference_dashboard(request):
"""
Reference dashboard - Main entry point for Reference Section.
Shows:
- Root folders
- Recent documents
- Quick search
- Statistics
"""
# Get current hospital - use tenant_hospital for PX admins, user.hospital for regular users
hospital = getattr(request, 'tenant_hospital', None) or request.user.hospital
if not hospital:
return render(request, 'core/no_hospital_assigned.html')
# Get root folders (no parent)
folders = ReferenceFolder.objects.filter(
hospital=hospital,
parent=None,
is_active=True,
is_deleted=False
).order_by('order', 'name')
# Filter by access
folders = [f for f in folders if f.has_access(request.user)]
# Get recent documents (last 10)
recent_documents = ReferenceDocument.objects.filter(
hospital=hospital,
is_published=True,
is_deleted=False
).select_related('folder').order_by('-created_at')[:10]
# Filter by access
recent_documents = [d for d in recent_documents if d.has_access(request.user)]
# Statistics
total_folders = ReferenceFolder.objects.filter(
hospital=hospital,
is_active=True,
is_deleted=False
).count()
total_documents = ReferenceDocument.objects.filter(
hospital=hospital,
is_published=True,
is_deleted=False
).count()
context = {
'folders': folders,
'recent_documents': recent_documents,
'total_folders': total_folders,
'total_documents': total_documents,
}
return render(request, 'references/dashboard.html', context)
@login_required
def folder_view(request, pk=None):
"""
Folder view - Browse folders and documents.
If pk is provided, shows contents of that folder.
Otherwise, shows root level folders.
"""
# Get current hospital - use tenant_hospital for PX admins, user.hospital for regular users
hospital = getattr(request, 'tenant_hospital', None) or request.user.hospital
if not hospital:
return render(request, 'core/no_hospital_assigned.html')
current_folder = None
breadcrumb = []
subfolders = []
documents = []
if pk:
current_folder = get_object_or_404(
ReferenceFolder,
pk=pk,
hospital=hospital,
is_deleted=False
)
# Check access
if not current_folder.has_access(request.user):
messages.error(request, "You don't have permission to access this folder.")
return redirect('references:dashboard')
# Build breadcrumb
breadcrumb = []
folder = current_folder
while folder:
breadcrumb.insert(0, folder)
folder = folder.parent
# Get subfolders
subfolders = current_folder.get_subfolders()
subfolders = [f for f in subfolders if f.has_access(request.user)]
# Get documents
documents = current_folder.get_documents()
documents = [d for d in documents if d.has_access(request.user)]
else:
# Root level - show root folders
subfolders = ReferenceFolder.objects.filter(
hospital=hospital,
parent=None,
is_active=True,
is_deleted=False
).order_by('order', 'name')
subfolders = [f for f in subfolders if f.has_access(request.user)]
context = {
'current_folder': current_folder,
'breadcrumb': breadcrumb,
'subfolders': subfolders,
'documents': documents,
}
return render(request, 'references/folder_view.html', context)
@login_required
@require_http_methods(["GET", "POST"])
def folder_create(request, parent_pk=None):
"""Create a new folder"""
# Get current hospital - use tenant_hospital for PX admins, user.hospital for regular users
hospital = getattr(request, 'tenant_hospital', None) or request.user.hospital
if not hospital:
messages.error(request, "No hospital assigned.")
return redirect('references:dashboard')
parent = None
if parent_pk:
parent = get_object_or_404(ReferenceFolder, pk=parent_pk, hospital=hospital, is_deleted=False)
if not parent.has_access(request.user):
messages.error(request, "You don't have permission to access this folder.")
return redirect('references:dashboard')
if request.method == 'POST':
form = ReferenceFolderForm(request.POST, hospital=hospital)
if form.is_valid():
folder = form.save(commit=False)
folder.hospital = hospital
folder.parent = parent
folder.save()
form.save_m2m()
messages.success(request, f"Folder '{folder.name}' created successfully.")
if parent:
return redirect('references:folder_view', pk=parent_pk)
return redirect('references:dashboard')
else:
form = ReferenceFolderForm(hospital=hospital)
if parent:
form.fields['parent'].initial = parent
# Get all folders for parent dropdown with level info
all_folders = []
for f in ReferenceFolder.objects.filter(hospital=hospital, is_deleted=False):
level = 0
temp = f
while temp.parent:
level += 1
temp = temp.parent
# Create a display string with appropriate number of dashes
f.level_display = '' * level
all_folders.append(f)
context = {
'form': form,
'folder': None,
'parent': parent,
'all_folders': all_folders,
}
return render(request, 'references/folder_form.html', context)
@login_required
@require_http_methods(["GET", "POST"])
def folder_edit(request, pk):
"""Edit a folder"""
# Get current hospital - use tenant_hospital for PX admins, user.hospital for regular users
hospital = getattr(request, 'tenant_hospital', None) or request.user.hospital
if not hospital:
messages.error(request, "No hospital assigned.")
return redirect('references:dashboard')
folder = get_object_or_404(ReferenceFolder, pk=pk, hospital=hospital, is_deleted=False)
# Check access
if not folder.has_access(request.user):
messages.error(request, "You don't have permission to edit this folder.")
return redirect('references:dashboard')
if request.method == 'POST':
form = ReferenceFolderForm(request.POST, instance=folder, hospital=hospital)
if form.is_valid():
form.save()
messages.success(request, f"Folder '{folder.name}' updated successfully.")
if folder.parent:
return redirect('references:folder_view', pk=folder.parent.id)
return redirect('references:dashboard')
else:
form = ReferenceFolderForm(instance=folder, hospital=hospital)
# Get all folders for parent dropdown with level info
all_folders = []
for f in ReferenceFolder.objects.filter(hospital=hospital, is_deleted=False):
level = 0
temp = f
while temp.parent:
level += 1
temp = temp.parent
# Create a display string with appropriate number of dashes
f.level_display = '' * level
all_folders.append(f)
context = {
'form': form,
'folder': folder,
'all_folders': all_folders,
}
return render(request, 'references/folder_form.html', context)
@login_required
@require_http_methods(["POST"])
def folder_delete(request, pk):
"""Soft delete a folder"""
# Get current hospital - use tenant_hospital for PX admins, user.hospital for regular users
hospital = getattr(request, 'tenant_hospital', None) or request.user.hospital
if not hospital:
return JsonResponse({'success': False, 'error': 'No hospital assigned'}, status=400)
folder = get_object_or_404(ReferenceFolder, pk=pk, hospital=hospital, is_deleted=False)
# Check access
if not folder.has_access(request.user):
return JsonResponse({'success': False, 'error': 'Permission denied'}, status=403)
# Soft delete
folder.is_deleted = True
folder.save()
return JsonResponse({'success': True})
@login_required
def document_view(request, pk):
"""Document detail view"""
# Get current hospital - use tenant_hospital for PX admins, user.hospital for regular users
hospital = getattr(request, 'tenant_hospital', None) or request.user.hospital
if not hospital:
return render(request, 'core/no_hospital_assigned.html')
document = get_object_or_404(
ReferenceDocument.objects.select_related('folder', 'uploaded_by'),
pk=pk,
hospital=hospital,
is_deleted=False
)
# Check access
if not document.has_access(request.user):
messages.error(request, "You don't have permission to access this document.")
return redirect('references:dashboard')
# Get version history
versions = document.get_version_history()
# Get related documents (same folder)
related_documents = []
if document.folder:
related_documents = ReferenceDocument.objects.filter(
folder=document.folder,
is_published=True,
is_deleted=False
).exclude(pk=document.pk)[:5]
related_documents = [d for d in related_documents if d.has_access(request.user)]
context = {
'document': document,
'versions': versions,
'related_documents': related_documents,
}
return render(request, 'references/document_view.html', context)
@login_required
@require_http_methods(["GET", "POST"])
def document_create(request, folder_pk=None):
"""Upload a new document"""
# Get current hospital - use tenant_hospital for PX admins, user.hospital for regular users
hospital = getattr(request, 'tenant_hospital', None) or request.user.hospital
if not hospital:
messages.error(request, "No hospital assigned.")
return redirect('references:dashboard')
folder = None
if folder_pk:
folder = get_object_or_404(ReferenceFolder, pk=folder_pk, hospital=hospital, is_deleted=False)
if not folder.has_access(request.user):
messages.error(request, "You don't have permission to access this folder.")
return redirect('references:dashboard')
if request.method == 'POST':
form = ReferenceDocumentForm(request.POST, request.FILES, hospital=hospital, folder=folder)
if form.is_valid():
document = form.save(commit=False)
document.hospital = hospital
# Use folder from form, fallback to URL parameter folder
document.folder = form.cleaned_data.get('folder') or folder
document.uploaded_by = request.user
if 'file' in request.FILES:
document.filename = request.FILES['file'].name
document.save()
form.save_m2m()
messages.success(request, f"Document '{document.title}' uploaded successfully.")
if document.folder:
return redirect('references:folder_view', pk=document.folder.id)
return redirect('references:dashboard')
else:
# Show error to user
messages.error(request, "Please fix the errors below to upload the document.")
else:
form = ReferenceDocumentForm(hospital=hospital, folder=folder)
# Get all folders for dropdown with level info
all_folders = []
for f in ReferenceFolder.objects.filter(hospital=hospital, is_deleted=False):
level = 0
temp = f
while temp.parent:
level += 1
temp = temp.parent
# Create a display string with appropriate number of dashes
f.level_display = '' * level
all_folders.append(f)
context = {
'form': form,
'folder': folder,
'all_folders': all_folders,
}
return render(request, 'references/document_form.html', context)
@login_required
@require_http_methods(["GET", "POST"])
def document_edit(request, pk):
"""Edit a document"""
# Get current hospital - use tenant_hospital for PX admins, user.hospital for regular users
hospital = getattr(request, 'tenant_hospital', None) or request.user.hospital
if not hospital:
messages.error(request, "No hospital assigned.")
return redirect('references:dashboard')
document = get_object_or_404(ReferenceDocument, pk=pk, hospital=hospital, is_deleted=False)
# Check access
if not document.has_access(request.user):
messages.error(request, "You don't have permission to edit this document.")
return redirect('references:dashboard')
if request.method == 'POST':
form = ReferenceDocumentForm(request.POST, request.FILES, instance=document, hospital=hospital)
if form.is_valid():
# Check if uploading as new version
new_version = form.cleaned_data.get('new_version')
file = request.FILES.get('file')
if new_version and file:
# Create new version
old_document = document
document.pk = None # Create new instance
document.parent_document = old_document
document.uploaded_by = request.user
document.filename = file.name
document.save()
form.save_m2m()
# Mark old version as not latest
old_document.is_latest_version = False
old_document.save()
messages.success(request, f"New version '{document.title}' created successfully.")
else:
# Just update metadata
if file:
document.filename = file.name
form.save()
messages.success(request, f"Document '{document.title}' updated successfully.")
return redirect('references:document_view', pk=document.id)
else:
form = ReferenceDocumentForm(instance=document, hospital=hospital)
# Get all folders for dropdown with level info
all_folders = []
for f in ReferenceFolder.objects.filter(hospital=hospital, is_deleted=False):
level = 0
temp = f
while temp.parent:
level += 1
temp = temp.parent
# Create a display string with appropriate number of dashes
f.level_display = '' * level
all_folders.append(f)
context = {
'form': form,
'document': document,
'all_folders': all_folders,
}
return render(request, 'references/document_form.html', context)
@login_required
@require_http_methods(["POST"])
def document_delete(request, pk):
"""Soft delete a document"""
# Get current hospital - use tenant_hospital for PX admins, user.hospital for regular users
hospital = getattr(request, 'tenant_hospital', None) or request.user.hospital
if not hospital:
return JsonResponse({'success': False, 'error': 'No hospital assigned'}, status=400)
document = get_object_or_404(ReferenceDocument, pk=pk, hospital=hospital, is_deleted=False)
# Check access
if not document.has_access(request.user):
return JsonResponse({'success': False, 'error': 'Permission denied'}, status=403)
# Soft delete
document.is_deleted = True
document.save()
return JsonResponse({'success': True})
@login_required
def search(request):
"""
Search documents across all folders.
"""
# Get current hospital - use tenant_hospital for PX admins, user.hospital for regular users
hospital = getattr(request, 'tenant_hospital', None) or request.user.hospital
if not hospital:
return render(request, 'core/no_hospital_assigned.html')
form = ReferenceDocumentSearchForm(request.GET or None, hospital=hospital)
documents = []
if form.is_valid():
documents = ReferenceDocument.objects.filter(
hospital=hospital,
is_published=True,
is_deleted=False
).select_related('folder')
# Apply filters
search = form.cleaned_data.get('search')
if search:
documents = documents.filter(
Q(title__icontains=search) |
Q(title_ar__icontains=search) |
Q(description__icontains=search) |
Q(description_ar__icontains=search) |
Q(tags__icontains=search)
)
folder = form.cleaned_data.get('folder')
if folder:
documents = documents.filter(folder=folder)
file_type = form.cleaned_data.get('file_type')
if file_type:
documents = documents.filter(file_type__iexact=file_type)
tags = form.cleaned_data.get('tags')
if tags:
tag_list = [tag.strip() for tag in tags.split(',')]
for tag in tag_list:
documents = documents.filter(tags__icontains=tag)
# Filter by access
documents = [d for d in documents if d.has_access(request.user)]
# Pagination
page_size = int(request.GET.get('page_size', 20))
paginator = Paginator(documents, page_size)
page_number = request.GET.get('page', 1)
page_obj = paginator.get_page(page_number)
context = {
'form': form,
'page_obj': page_obj,
'documents': page_obj.object_list,
}
return render(request, 'references/search.html', context)

44
apps/references/urls.py Normal file
View File

@ -0,0 +1,44 @@
"""
References URL configuration
"""
from django.urls import path
from . import views, ui_views
app_name = 'references'
# API Endpoints
urlpatterns_api = [
path('api/folders/', views.folder_list, name='api_folder_list'),
path('api/folders/<uuid:pk>/', views.folder_detail, name='api_folder_detail'),
path('api/documents/', views.document_list, name='api_document_list'),
path('api/documents/<uuid:pk>/', views.document_detail, name='api_document_detail'),
path('api/documents/<uuid:pk>/download/', views.document_download, name='api_document_download'),
path('api/documents/<uuid:pk>/versions/', views.document_version_history, name='api_document_versions'),
]
# UI Views
urlpatterns_ui = [
# Dashboard
path('', ui_views.reference_dashboard, name='dashboard'),
# Folders
path('folders/', ui_views.folder_view, name='folder_view'),
path('folders/new/', ui_views.folder_create, name='folder_create'),
path('folders/new/<uuid:parent_pk>/', ui_views.folder_create, name='folder_create_in_parent'),
path('folders/<uuid:pk>/', ui_views.folder_view, name='folder_view'),
path('folders/<uuid:pk>/edit/', ui_views.folder_edit, name='folder_edit'),
path('folders/<uuid:pk>/delete/', ui_views.folder_delete, name='folder_delete'),
# Documents
path('documents/new/', ui_views.document_create, name='document_create'),
path('documents/new/<uuid:folder_pk>/', ui_views.document_create, name='document_create_in_folder'),
path('documents/<uuid:pk>/', ui_views.document_view, name='document_view'),
path('documents/<uuid:pk>/edit/', ui_views.document_edit, name='document_edit'),
path('documents/<uuid:pk>/delete/', ui_views.document_delete, name='document_delete'),
# Search
path('search/', ui_views.search, name='search'),
]
urlpatterns = urlpatterns_ui + urlpatterns_api

270
apps/references/views.py Normal file
View File

@ -0,0 +1,270 @@
"""
References views - View functions for Reference Section
"""
from django.contrib.auth.decorators import login_required
from django.core.exceptions import PermissionDenied
from django.http import JsonResponse, Http404, FileResponse
from django.shortcuts import get_object_or_404, redirect
from django.utils import timezone
from apps.core.mixins import TenantRequiredMixin
from apps.references.models import ReferenceFolder, ReferenceDocument, ReferenceDocumentAccess
from apps.references.forms import ReferenceFolderForm, ReferenceDocumentForm
@login_required
def folder_list(request):
"""API endpoint to list folders for the current hospital"""
# Get current hospital - use tenant_hospital for PX admins, user.hospital for regular users
hospital = getattr(request, 'tenant_hospital', None) or request.user.hospital
if not hospital:
return JsonResponse({'error': 'No hospital assigned'}, status=400)
parent_id = request.GET.get('parent')
folders = ReferenceFolder.objects.filter(
hospital=hospital,
is_active=True,
is_deleted=False
)
if parent_id:
try:
parent = ReferenceFolder.objects.get(id=parent_id, hospital=hospital)
folders = folders.filter(parent=parent)
except ReferenceFolder.DoesNotExist:
folders = folders.filter(parent=None)
else:
folders = folders.filter(parent=None)
# Filter by access
folders = [f for f in folders if f.has_access(request.user)]
data = [
{
'id': str(folder.id),
'name': folder.name,
'name_ar': folder.name_ar,
'description': folder.description,
'description_ar': folder.description_ar,
'icon': folder.icon,
'color': folder.color,
'document_count': folder.get_document_count(),
'order': folder.order
}
for folder in folders
]
return JsonResponse({'folders': data})
@login_required
def folder_detail(request, pk):
"""API endpoint to get folder details"""
# Get current hospital - use tenant_hospital for PX admins, user.hospital for regular users
hospital = getattr(request, 'tenant_hospital', None) or request.user.hospital
if not hospital:
return JsonResponse({'error': 'No hospital assigned'}, status=400)
folder = get_object_or_404(ReferenceFolder, pk=pk, hospital=hospital, is_deleted=False)
if not folder.has_access(request.user):
raise PermissionDenied()
data = {
'id': str(folder.id),
'name': folder.name,
'name_ar': folder.name_ar,
'description': folder.description,
'description_ar': folder.description_ar,
'icon': folder.icon,
'color': folder.color,
'document_count': folder.get_document_count(),
'full_path': folder.get_full_path()
}
return JsonResponse({'folder': data})
@login_required
def document_list(request):
"""API endpoint to list documents with filtering"""
# Get current hospital - use tenant_hospital for PX admins, user.hospital for regular users
hospital = getattr(request, 'tenant_hospital', None) or request.user.hospital
if not hospital:
return JsonResponse({'error': 'No hospital assigned'}, status=400)
documents = ReferenceDocument.objects.filter(
hospital=hospital,
is_published=True,
is_deleted=False
)
# Filter by folder
folder_id = request.GET.get('folder')
if folder_id:
try:
folder = ReferenceFolder.objects.get(id=folder_id, hospital=hospital)
documents = documents.filter(folder=folder)
except ReferenceFolder.DoesNotExist:
pass
# Filter by file type
file_type = request.GET.get('file_type')
if file_type:
documents = documents.filter(file_type__iexact=file_type)
# Search
search = request.GET.get('search')
if search:
from django.db.models import Q
documents = documents.filter(
Q(title__icontains=search) |
Q(title_ar__icontains=search) |
Q(description__icontains=search) |
Q(description_ar__icontains=search) |
Q(tags__icontains=search)
)
# Filter by tags
tags = request.GET.get('tags')
if tags:
tag_list = [tag.strip() for tag in tags.split(',')]
for tag in tag_list:
documents = documents.filter(tags__icontains=tag)
# Filter by access
documents = [d for d in documents if d.has_access(request.user)]
data = [
{
'id': str(doc.id),
'title': doc.title,
'title_ar': doc.title_ar,
'description': doc.description,
'description_ar': doc.description_ar,
'filename': doc.filename,
'file_type': doc.file_type,
'file_size': doc.file_size,
'file_size_display': doc.get_file_size_display(),
'version': doc.version,
'is_latest_version': doc.is_latest_version,
'download_count': doc.download_count,
'tags': doc.tags,
'folder_id': str(doc.folder.id) if doc.folder else None,
'folder_name': doc.folder.name if doc.folder else None,
'created_at': doc.created_at.isoformat(),
'last_accessed_at': doc.last_accessed_at.isoformat() if doc.last_accessed_at else None
}
for doc in documents
]
return JsonResponse({'documents': data})
@login_required
def document_detail(request, pk):
"""API endpoint to get document details"""
# Get current hospital - use tenant_hospital for PX admins, user.hospital for regular users
hospital = getattr(request, 'tenant_hospital', None) or request.user.hospital
if not hospital:
return JsonResponse({'error': 'No hospital assigned'}, status=400)
document = get_object_or_404(ReferenceDocument, pk=pk, hospital=hospital, is_deleted=False)
if not document.has_access(request.user):
raise PermissionDenied()
data = {
'id': str(document.id),
'title': document.title,
'title_ar': document.title_ar,
'description': document.description,
'description_ar': document.description_ar,
'filename': document.filename,
'file_type': document.file_type,
'file_size': document.file_size,
'file_size_display': document.get_file_size_display(),
'version': document.version,
'is_latest_version': document.is_latest_version,
'download_count': document.download_count,
'tags': document.tags,
'folder_id': str(document.folder.id) if document.folder else None,
'folder_name': document.folder.name if document.folder else None,
'created_at': document.created_at.isoformat(),
'last_accessed_at': document.last_accessed_at.isoformat() if document.last_accessed_at else None,
'uploaded_by': {
'email': document.uploaded_by.email,
'full_name': document.uploaded_by.get_full_name()
} if document.uploaded_by else None
}
return JsonResponse({'document': data})
@login_required
def document_download(request, pk):
"""
Handle document download with access logging
"""
# Get current hospital - use tenant_hospital for PX admins, user.hospital for regular users
hospital = getattr(request, 'tenant_hospital', None) or request.user.hospital
if not hospital:
raise Http404("No hospital assigned")
document = get_object_or_404(ReferenceDocument, pk=pk, hospital=hospital, is_deleted=False)
if not document.has_access(request.user):
raise PermissionDenied()
# Increment download count
document.increment_download_count()
# Log the access
ReferenceDocumentAccess.objects.create(
document=document,
user=request.user,
action='download',
ip_address=request.META.get('REMOTE_ADDR'),
user_agent=request.META.get('HTTP_USER_AGENT', '')
)
# Return file response
if document.file:
return FileResponse(
document.file,
as_attachment=True,
filename=document.filename
)
raise Http404("File not found")
@login_required
def document_version_history(request, pk):
"""API endpoint to get version history of a document"""
# Get current hospital - use tenant_hospital for PX admins, user.hospital for regular users
hospital = getattr(request, 'tenant_hospital', None) or request.user.hospital
if not hospital:
return JsonResponse({'error': 'No hospital assigned'}, status=400)
document = get_object_or_404(ReferenceDocument, pk=pk, hospital=hospital, is_deleted=False)
if not document.has_access(request.user):
raise PermissionDenied()
versions = document.get_version_history()
data = [
{
'id': str(ver.id),
'version': ver.version,
'is_latest_version': ver.is_latest_version,
'filename': ver.filename,
'file_size': ver.file_size,
'file_size_display': ver.get_file_size_display(),
'created_at': ver.created_at.isoformat()
}
for ver in versions
]
return JsonResponse({'versions': data})

View File

@ -0,0 +1 @@
default_app_config = 'apps.standards.apps.StandardsConfig'

52
apps/standards/admin.py Normal file
View File

@ -0,0 +1,52 @@
from django.contrib import admin
from apps.standards.models import (
StandardSource,
StandardCategory,
Standard,
StandardCompliance,
StandardAttachment
)
@admin.register(StandardSource)
class StandardSourceAdmin(admin.ModelAdmin):
list_display = ['name', 'code', 'is_active', 'created_at']
list_filter = ['is_active', 'created_at']
search_fields = ['name', 'name_ar', 'code', 'description']
ordering = ['name']
@admin.register(StandardCategory)
class StandardCategoryAdmin(admin.ModelAdmin):
list_display = ['name', 'order', 'is_active', 'created_at']
list_filter = ['is_active', 'created_at']
search_fields = ['name', 'name_ar', 'description']
ordering = ['order', 'name']
@admin.register(Standard)
class StandardAdmin(admin.ModelAdmin):
list_display = ['code', 'title', 'source', 'category', 'department', 'is_active', 'effective_date']
list_filter = ['source', 'category', 'department', 'is_active', 'effective_date']
search_fields = ['code', 'title', 'title_ar', 'description']
ordering = ['source', 'category', 'code']
date_hierarchy = 'effective_date'
@admin.register(StandardCompliance)
class StandardComplianceAdmin(admin.ModelAdmin):
list_display = ['department', 'standard', 'status', 'last_assessed_date', 'assessor', 'created_at']
list_filter = ['status', 'last_assessed_date', 'created_at']
search_fields = ['department__name', 'standard__code', 'standard__title', 'notes', 'evidence_summary']
ordering = ['-created_at']
raw_id_fields = ['department', 'standard', 'assessor']
@admin.register(StandardAttachment)
class StandardAttachmentAdmin(admin.ModelAdmin):
list_display = ['filename', 'compliance', 'uploaded_by', 'created_at']
list_filter = ['created_at']
search_fields = ['filename', 'description']
ordering = ['-created_at']
raw_id_fields = ['compliance', 'uploaded_by']

7
apps/standards/apps.py Normal file
View File

@ -0,0 +1,7 @@
from django.apps import AppConfig
class StandardsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.standards'
verbose_name = 'Standards Section'

66
apps/standards/forms.py Normal file
View File

@ -0,0 +1,66 @@
from django import forms
from django.core.validators import FileExtensionValidator
from apps.standards.models import (
StandardSource,
StandardCategory,
Standard,
StandardCompliance,
StandardAttachment
)
class StandardSourceForm(forms.ModelForm):
class Meta:
model = StandardSource
fields = ['name', 'name_ar', 'code', 'description', 'website', 'is_active']
widgets = {
'description': forms.Textarea(attrs={'rows': 3}),
}
class StandardCategoryForm(forms.ModelForm):
class Meta:
model = StandardCategory
fields = ['name', 'name_ar', 'description', 'order', 'is_active']
widgets = {
'description': forms.Textarea(attrs={'rows': 3}),
}
class StandardForm(forms.ModelForm):
class Meta:
model = Standard
fields = ['code', 'title', 'title_ar', 'description',
'department', 'effective_date', 'review_date', 'is_active']
widgets = {
'description': forms.Textarea(attrs={'rows': 5}),
'effective_date': forms.DateInput(attrs={'type': 'date'}),
'review_date': forms.DateInput(attrs={'type': 'date'}),
}
class StandardComplianceForm(forms.ModelForm):
class Meta:
model = StandardCompliance
fields = ['status', 'last_assessed_date', 'assessor', 'notes', 'evidence_summary']
widgets = {
'last_assessed_date': forms.DateInput(attrs={'type': 'date'}),
'notes': forms.Textarea(attrs={'rows': 3}),
'evidence_summary': forms.Textarea(attrs={'rows': 3}),
}
class StandardAttachmentForm(forms.ModelForm):
class Meta:
model = StandardAttachment
fields = ['file', 'description']
widgets = {
'description': forms.Textarea(attrs={'rows': 2}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['file'].widget.attrs.update({
'accept': '.pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.zip'
})

View File

@ -0,0 +1,119 @@
# Generated by Django 5.0.14 on 2026-01-07 20:27
import django.core.validators
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('organizations', '0005_alter_staff_department'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='StandardCategory',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=100)),
('name_ar', models.CharField(blank=True, max_length=100, verbose_name='Name (Arabic)')),
('description', models.TextField(blank=True)),
('order', models.PositiveIntegerField(default=0, help_text='Display order')),
('is_active', models.BooleanField(db_index=True, default=True)),
],
options={
'verbose_name': 'Standard Category',
'verbose_name_plural': 'Standard Categories',
'ordering': ['order', 'name'],
},
),
migrations.CreateModel(
name='StandardSource',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=100)),
('name_ar', models.CharField(blank=True, max_length=100, verbose_name='Name (Arabic)')),
('code', models.CharField(db_index=True, max_length=50, unique=True)),
('description', models.TextField(blank=True)),
('website', models.URLField(blank=True)),
('is_active', models.BooleanField(db_index=True, default=True)),
],
options={
'verbose_name': 'Standard Source',
'verbose_name_plural': 'Standard Sources',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Standard',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('code', models.CharField(db_index=True, help_text='e.g., CBAHI-PS-01', max_length=50)),
('title', models.CharField(max_length=300)),
('title_ar', models.CharField(blank=True, max_length=300, verbose_name='Title (Arabic)')),
('description', models.TextField(help_text='Full description of the standard')),
('effective_date', models.DateField(blank=True, help_text='When standard becomes effective', null=True)),
('review_date', models.DateField(blank=True, help_text='Next review date', null=True)),
('is_active', models.BooleanField(db_index=True, default=True)),
('department', models.ForeignKey(blank=True, help_text='Department-specific standard (null if applicable to all)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='standards', to='organizations.department')),
('category', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='standards', to='standards.standardcategory')),
('source', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='standards', to='standards.standardsource')),
],
options={
'verbose_name': 'Standard',
'verbose_name_plural': 'Standards',
'ordering': ['source', 'category', 'code'],
},
),
migrations.CreateModel(
name='StandardCompliance',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('status', models.CharField(choices=[('not_assessed', 'Not Assessed'), ('met', 'Met'), ('partially_met', 'Partially Met'), ('not_met', 'Not Met')], db_index=True, default='not_assessed', max_length=20)),
('last_assessed_date', models.DateField(blank=True, help_text='Date of last assessment', null=True)),
('notes', models.TextField(blank=True, help_text='Assessment notes')),
('evidence_summary', models.TextField(blank=True, help_text='Summary of evidence')),
('assessor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assessments', to=settings.AUTH_USER_MODEL)),
('department', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='compliance_records', to='organizations.department')),
('standard', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='compliance_records', to='standards.standard')),
],
options={
'verbose_name': 'Standard Compliance',
'verbose_name_plural': 'Standard Compliance',
'ordering': ['-created_at'],
'unique_together': {('department', 'standard')},
},
),
migrations.CreateModel(
name='StandardAttachment',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('file', models.FileField(upload_to='standards/attachments/%Y/%m/', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['pdf', 'doc', 'docx', 'xls', 'xlsx', 'jpg', 'jpeg', 'png', 'zip'])])),
('filename', models.CharField(help_text='Original filename', max_length=255)),
('description', models.TextField(blank=True, help_text='Attachment description')),
('uploaded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='uploaded_standards_attachments', to=settings.AUTH_USER_MODEL)),
('compliance', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='standards.standardcompliance')),
],
options={
'verbose_name': 'Standard Attachment',
'verbose_name_plural': 'Standard Attachments',
'ordering': ['-created_at'],
},
),
]

View File

165
apps/standards/models.py Normal file
View File

@ -0,0 +1,165 @@
"""
Standards Section Models - Track compliance standards (CBAHI, MOH, CHI, etc.)
"""
from django.db import models
from django.core.validators import FileExtensionValidator
from apps.core.models import TimeStampedModel, UUIDModel, StatusChoices
class StandardSource(UUIDModel, TimeStampedModel):
"""Standard sources like CBAHI, MOH, CHI, JCI, etc."""
name = models.CharField(max_length=100)
name_ar = models.CharField(max_length=100, blank=True, verbose_name="Name (Arabic)")
code = models.CharField(max_length=50, unique=True, db_index=True)
description = models.TextField(blank=True)
website = models.URLField(blank=True)
is_active = models.BooleanField(default=True, db_index=True)
class Meta:
ordering = ['name']
verbose_name = 'Standard Source'
verbose_name_plural = 'Standard Sources'
def __str__(self):
return self.name
class StandardCategory(UUIDModel, TimeStampedModel):
"""Group standards by category (Patient Safety, Quality Management, etc.)"""
name = models.CharField(max_length=100)
name_ar = models.CharField(max_length=100, blank=True, verbose_name="Name (Arabic)")
description = models.TextField(blank=True)
order = models.PositiveIntegerField(default=0, help_text="Display order")
is_active = models.BooleanField(default=True, db_index=True)
class Meta:
ordering = ['order', 'name']
verbose_name = 'Standard Category'
verbose_name_plural = 'Standard Categories'
def __str__(self):
return self.name
class Standard(UUIDModel, TimeStampedModel):
"""Actual standard requirements"""
class ComplianceStatus(models.TextChoices):
NOT_ASSESSED = 'not_assessed', 'Not Assessed'
MET = 'met', 'Met'
PARTIALLY_MET = 'partially_met', 'Partially Met'
NOT_MET = 'not_met', 'Not Met'
code = models.CharField(max_length=50, db_index=True, help_text="e.g., CBAHI-PS-01")
title = models.CharField(max_length=300)
title_ar = models.CharField(max_length=300, blank=True, verbose_name="Title (Arabic)")
description = models.TextField(help_text="Full description of the standard")
# Relationships
source = models.ForeignKey(
StandardSource,
on_delete=models.PROTECT,
related_name='standards'
)
category = models.ForeignKey(
StandardCategory,
on_delete=models.PROTECT,
related_name='standards'
)
department = models.ForeignKey(
'organizations.Department',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='standards',
help_text="Department-specific standard (null if applicable to all)"
)
# Dates
effective_date = models.DateField(null=True, blank=True, help_text="When standard becomes effective")
review_date = models.DateField(null=True, blank=True, help_text="Next review date")
is_active = models.BooleanField(default=True, db_index=True)
class Meta:
ordering = ['source', 'category', 'code']
verbose_name = 'Standard'
verbose_name_plural = 'Standards'
def __str__(self):
return f"{self.code}: {self.title}"
class StandardCompliance(UUIDModel, TimeStampedModel):
"""Track compliance status per department"""
class ComplianceStatus(models.TextChoices):
NOT_ASSESSED = 'not_assessed', 'Not Assessed'
MET = 'met', 'Met'
PARTIALLY_MET = 'partially_met', 'Partially Met'
NOT_MET = 'not_met', 'Not Met'
department = models.ForeignKey(
'organizations.Department',
on_delete=models.CASCADE,
related_name='compliance_records'
)
standard = models.ForeignKey(
Standard,
on_delete=models.CASCADE,
related_name='compliance_records'
)
status = models.CharField(
max_length=20,
choices=ComplianceStatus.choices,
default=ComplianceStatus.NOT_ASSESSED,
db_index=True
)
last_assessed_date = models.DateField(null=True, blank=True, help_text="Date of last assessment")
assessor = models.ForeignKey(
'accounts.User',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='assessments'
)
notes = models.TextField(blank=True, help_text="Assessment notes")
evidence_summary = models.TextField(blank=True, help_text="Summary of evidence")
class Meta:
ordering = ['-created_at']
verbose_name = 'Standard Compliance'
verbose_name_plural = 'Standard Compliance'
unique_together = [['department', 'standard']]
def __str__(self):
return f"{self.department.name} - {self.standard.code} - {self.status}"
class StandardAttachment(UUIDModel, TimeStampedModel):
"""Proof of compliance - evidence documents"""
compliance = models.ForeignKey(
StandardCompliance,
on_delete=models.CASCADE,
related_name='attachments'
)
file = models.FileField(
upload_to='standards/attachments/%Y/%m/',
validators=[FileExtensionValidator(allowed_extensions=['pdf', 'doc', 'docx', 'xls', 'xlsx', 'jpg', 'jpeg', 'png', 'zip'])]
)
filename = models.CharField(max_length=255, help_text="Original filename")
description = models.TextField(blank=True, help_text="Attachment description")
uploaded_by = models.ForeignKey(
'accounts.User',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='uploaded_standards_attachments'
)
class Meta:
ordering = ['-created_at']
verbose_name = 'Standard Attachment'
verbose_name_plural = 'Standard Attachments'
def __str__(self):
return f"{self.compliance} - {self.filename}"

View File

@ -0,0 +1,61 @@
from rest_framework import serializers
from apps.standards.models import (
StandardSource,
StandardCategory,
Standard,
StandardCompliance,
StandardAttachment
)
class StandardSourceSerializer(serializers.ModelSerializer):
class Meta:
model = StandardSource
fields = '__all__'
read_only_fields = ['id', 'created_at', 'updated_at']
class StandardCategorySerializer(serializers.ModelSerializer):
class Meta:
model = StandardCategory
fields = '__all__'
read_only_fields = ['id', 'created_at', 'updated_at']
class StandardSerializer(serializers.ModelSerializer):
source_name = serializers.CharField(source='source.name', read_only=True)
category_name = serializers.CharField(source='category.name', read_only=True)
department_name = serializers.CharField(source='department.name', read_only=True)
class Meta:
model = Standard
fields = '__all__'
read_only_fields = ['id', 'created_at', 'updated_at']
class StandardComplianceSerializer(serializers.ModelSerializer):
department_name = serializers.CharField(source='department.name', read_only=True)
standard_code = serializers.CharField(source='standard.code', read_only=True)
standard_title = serializers.CharField(source='standard.title', read_only=True)
assessor_name = serializers.CharField(source='assessor.get_full_name', read_only=True)
attachment_count = serializers.SerializerMethodField()
class Meta:
model = StandardCompliance
fields = '__all__'
read_only_fields = ['id', 'created_at', 'updated_at']
def get_attachment_count(self, obj):
return obj.attachments.count()
class StandardAttachmentSerializer(serializers.ModelSerializer):
uploaded_by_name = serializers.CharField(source='uploaded_by.get_full_name', read_only=True)
standard_code = serializers.CharField(source='compliance.standard.code', read_only=True)
department_name = serializers.CharField(source='compliance.department.name', read_only=True)
class Meta:
model = StandardAttachment
fields = '__all__'
read_only_fields = ['id', 'uploaded_at', 'created_at', 'updated_at']

View File

View File

@ -0,0 +1,50 @@
"""
Template filters for Standards app
"""
from django import template
register = template.Library()
@register.filter
def get_unique(data_list, field_path):
"""
Get unique values from a list of dictionaries based on a dot-notation path.
Usage: {{ standards_data|get_unique:"standard.source" }}
Args:
data_list: List of dictionaries
field_path: Dot-separated path to the field (e.g., "standard.source")
Returns:
List of unique values for the specified field path
"""
if not data_list:
return []
values = []
seen = set()
for item in data_list:
# Navigate through the dot-notation path
value = item
try:
for attr in field_path.split('.'):
if value is None:
break
# Handle both dict and object access
if isinstance(value, dict):
value = value.get(attr)
else:
value = getattr(value, attr, None)
# Only add non-None values that haven't been seen
if value is not None and value not in seen:
values.append(value)
seen.add(value)
except (AttributeError, KeyError, TypeError):
# Skip items that don't have the path
continue
return values

52
apps/standards/urls.py Normal file
View File

@ -0,0 +1,52 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from apps.standards.views import (
StandardSourceViewSet,
StandardCategoryViewSet,
StandardViewSet,
StandardComplianceViewSet,
StandardAttachmentViewSet,
standards_dashboard,
department_standards_view,
standard_detail,
standard_compliance_update,
standard_attachment_upload,
standards_search,
get_compliance_status,
standard_create,
create_compliance_ajax,
update_compliance_ajax,
)
# API Router
router = DefaultRouter()
router.register(r'sources', StandardSourceViewSet, basename='standard-source')
router.register(r'categories', StandardCategoryViewSet, basename='standard-category')
router.register(r'standards', StandardViewSet, basename='standard')
router.register(r'compliance', StandardComplianceViewSet, basename='standard-compliance')
router.register(r'attachments', StandardAttachmentViewSet, basename='standard-attachment')
app_name = 'standards'
urlpatterns = [
# API endpoints
path('api/', include(router.urls)),
# API endpoint for compliance status
path('api/compliance/<uuid:department_id>/<uuid:standard_id>/', get_compliance_status, name='compliance_status'),
# UI Views
path('', standards_dashboard, name='dashboard'),
path('search/', standards_search, name='search'),
path('departments/<uuid:pk>/', department_standards_view, name='department_standards'),
path('departments/<uuid:department_id>/create-standard/', standard_create, name='standard_create'),
path('standards/create/', standard_create, name='standard_create_global'),
path('standards/<uuid:pk>/', standard_detail, name='standard_detail'),
path('compliance/<uuid:compliance_id>/update/', standard_compliance_update, name='standard_compliance_update'),
path('attachments/upload/<uuid:compliance_id>/', standard_attachment_upload, name='attachment_upload'),
# AJAX endpoints
path('api/compliance/create/', create_compliance_ajax, name='compliance_create_ajax'),
path('api/compliance/update/', update_compliance_ajax, name='compliance_update_ajax'),
]

423
apps/standards/views.py Normal file
View File

@ -0,0 +1,423 @@
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
from django.shortcuts import render, get_object_or_404, redirect
from django.contrib.auth.decorators import login_required
from django.http import JsonResponse
from django.db.models import Count, Q
from django.utils import timezone
from apps.standards.models import (
StandardSource,
StandardCategory,
Standard,
StandardCompliance,
StandardAttachment
)
from apps.organizations.models import Department
from apps.standards.forms import (
StandardSourceForm,
StandardCategoryForm,
StandardForm,
StandardComplianceForm,
StandardAttachmentForm
)
# ==================== API ViewSets ====================
class StandardSourceViewSet(viewsets.ModelViewSet):
queryset = StandardSource.objects.all()
filterset_fields = ['is_active']
search_fields = ['name', 'name_ar', 'code']
ordering = ['name']
def get_serializer_class(self):
from apps.standards.serializers import StandardSourceSerializer
return StandardSourceSerializer
class StandardCategoryViewSet(viewsets.ModelViewSet):
queryset = StandardCategory.objects.all()
filterset_fields = ['is_active']
search_fields = ['name', 'name_ar']
ordering = ['order', 'name']
def get_serializer_class(self):
from apps.standards.serializers import StandardCategorySerializer
return StandardCategorySerializer
class StandardViewSet(viewsets.ModelViewSet):
queryset = Standard.objects.all()
filterset_fields = ['source', 'category', 'department', 'is_active']
search_fields = ['code', 'title', 'title_ar', 'description']
ordering = ['source', 'category', 'code']
def get_serializer_class(self):
from apps.standards.serializers import StandardSerializer
return StandardSerializer
class StandardComplianceViewSet(viewsets.ModelViewSet):
queryset = StandardCompliance.objects.all()
filterset_fields = ['department', 'standard', 'status']
search_fields = ['department__name', 'standard__code', 'notes']
ordering = ['-created_at']
def get_serializer_class(self):
from apps.standards.serializers import StandardComplianceSerializer
return StandardComplianceSerializer
class StandardAttachmentViewSet(viewsets.ModelViewSet):
queryset = StandardAttachment.objects.all()
filterset_fields = ['compliance']
search_fields = ['filename', 'description']
ordering = ['-uploaded_at']
def get_serializer_class(self):
from apps.standards.serializers import StandardAttachmentSerializer
return StandardAttachmentSerializer
# ==================== UI Views ====================
@login_required
def standards_dashboard(request):
"""Standards dashboard with statistics"""
# Get current hospital from tenant_hospital (set by middleware)
hospital = getattr(request, 'tenant_hospital', None)
if not hospital:
return render(request, 'core/no_hospital_assigned.html')
departments = hospital.departments.filter(status='active')
# Get compliance statistics
compliance_records = StandardCompliance.objects.filter(
department__hospital=hospital
)
stats = {
'total_standards': Standard.objects.filter(is_active=True).count(),
'total_departments': departments.count(),
'met': compliance_records.filter(status='met').count(),
'partially_met': compliance_records.filter(status='partially_met').count(),
'not_met': compliance_records.filter(status='not_met').count(),
'not_assessed': compliance_records.filter(status='not_assessed').count(),
}
# Recent compliance updates
recent_updates = compliance_records.order_by('-updated_at')[:10]
context = {
'hospital': hospital,
'departments': departments,
'stats': stats,
'recent_updates': recent_updates,
}
return render(request, 'standards/dashboard.html', context)
@login_required
def department_standards_view(request, pk):
"""View all standards for a department"""
department = get_object_or_404(Department, pk=pk)
# Check if user is PX admin
hospital = getattr(request, 'tenant_hospital', None)
is_px_admin = request.user.is_superuser or (
hasattr(request.user, 'hospital_user') and
request.user.hospital_user.hospital == hospital and
request.user.hospital_user.role == 'px_admin'
) if hospital else False
# Get all active standards (both department-specific and general)
department_standards = Standard.objects.filter(is_active=True).filter(
Q(department=department) | Q(department__isnull=True)
).order_by('source', 'category', 'code')
# Get compliance status for each standard
standards_data = []
for standard in department_standards:
compliance = StandardCompliance.objects.filter(
department=department,
standard=standard
).first()
standards_data.append({
'standard': standard,
'compliance': compliance,
'attachment_count': compliance.attachments.count() if compliance else 0,
})
context = {
'department': department,
'standards_data': standards_data,
'is_px_admin': is_px_admin,
}
return render(request, 'standards/department_standards.html', context)
@login_required
def standard_detail(request, pk):
"""View standard details and compliance history"""
standard = get_object_or_404(Standard, pk=pk)
# Get compliance records for all departments
compliance_records = StandardCompliance.objects.filter(
standard=standard
).select_related('department', 'assessor').order_by('-created_at')
context = {
'standard': standard,
'compliance_records': compliance_records,
}
return render(request, 'standards/standard_detail.html', context)
@login_required
def standard_compliance_update(request, compliance_id):
"""Update compliance status"""
compliance = get_object_or_404(StandardCompliance, pk=compliance_id)
if request.method == 'POST':
form = StandardComplianceForm(request.POST, instance=compliance)
if form.is_valid():
form.save()
return redirect('standards:department_standards', pk=compliance.department.pk)
else:
form = StandardComplianceForm(instance=compliance)
context = {
'compliance': compliance,
'form': form,
}
return render(request, 'standards/compliance_form.html', context)
@login_required
def standard_attachment_upload(request, compliance_id):
"""Upload attachment for compliance"""
compliance = get_object_or_404(StandardCompliance, pk=compliance_id)
if request.method == 'POST':
form = StandardAttachmentForm(request.POST, request.FILES)
if form.is_valid():
attachment = form.save(commit=False)
attachment.compliance = compliance
attachment.uploaded_by = request.user
attachment.filename = request.FILES['file'].name
attachment.save()
return redirect('standards:standard_compliance_update', compliance_id=compliance.pk)
else:
form = StandardAttachmentForm()
context = {
'compliance': compliance,
'form': form,
}
return render(request, 'standards/attachment_upload.html', context)
@login_required
def standards_search(request):
"""Search standards"""
query = request.GET.get('q', '')
source_filter = request.GET.get('source', '')
category_filter = request.GET.get('category', '')
status_filter = request.GET.get('status', '')
# Get current hospital from tenant_hospital (set by middleware)
hospital = getattr(request, 'tenant_hospital', None)
if not hospital:
return render(request, 'core/no_hospital_assigned.html')
# Build queryset
standards = Standard.objects.filter(is_active=True)
if query:
standards = standards.filter(
Q(code__icontains=query) |
Q(title__icontains=query) |
Q(title_ar__icontains=query) |
Q(description__icontains=query)
)
if source_filter:
standards = standards.filter(source_id=source_filter)
if category_filter:
standards = standards.filter(category_id=category_filter)
standards = standards.select_related('source', 'category').order_by('source', 'category', 'code')
# Get filters
sources = StandardSource.objects.filter(is_active=True)
categories = StandardCategory.objects.filter(is_active=True)
context = {
'hospital': hospital,
'standards': standards,
'query': query,
'source_filter': source_filter,
'category_filter': category_filter,
'sources': sources,
'categories': categories,
}
return render(request, 'standards/search.html', context)
@login_required
def standard_create(request, department_id=None):
"""Create a new standard (PX Admin only)"""
# Check if user is PX admin
hospital = getattr(request, 'tenant_hospital', None)
if not hospital:
return render(request, 'core/no_hospital_assigned.html')
is_px_admin = request.user.is_superuser or (
hasattr(request.user, 'hospital_user') and
request.user.hospital_user.hospital == hospital and
request.user.hospital_user.role == 'px_admin'
)
if not is_px_admin:
from django.contrib import messages
messages.error(request, 'You do not have permission to create standards.')
if department_id:
return redirect('standards:department_standards', pk=department_id)
return redirect('standards:dashboard')
if request.method == 'POST':
form = StandardForm(request.POST)
if form.is_valid():
standard = form.save(commit=False)
standard.save()
from django.contrib import messages
messages.success(request, 'Standard created successfully.')
if department_id:
return redirect('standards:department_standards', pk=department_id)
return redirect('standards:dashboard')
else:
form = StandardForm()
# If department_id is provided, pre-select that department
if department_id:
from apps.organizations.models import Department
department = Department.objects.filter(pk=department_id).first()
if department:
form.fields['department'].initial = department
# Get all departments for the hospital
departments = hospital.departments.filter(status='active')
context = {
'form': form,
'department_id': department_id,
'departments': departments,
'hospital': hospital,
}
return render(request, 'standards/standard_form.html', context)
@login_required
def create_compliance_ajax(request):
"""Create compliance record via AJAX"""
if request.method != 'POST':
return JsonResponse({'success': False, 'error': 'Invalid request method'})
department_id = request.POST.get('department_id')
standard_id = request.POST.get('standard_id')
if not department_id or not standard_id:
return JsonResponse({'success': False, 'error': 'Missing required fields'})
try:
department = Department.objects.get(pk=department_id)
standard = Standard.objects.get(pk=standard_id)
# Check if compliance already exists
compliance, created = StandardCompliance.objects.get_or_create(
department=department,
standard=standard,
defaults={
'assessor': request.user,
'last_assessed_date': timezone.now().date(),
}
)
return JsonResponse({
'success': True,
'compliance_id': compliance.id,
'status': compliance.status,
'created': created,
})
except Exception as e:
return JsonResponse({'success': False, 'error': str(e)})
@login_required
def update_compliance_ajax(request):
"""Update compliance record via AJAX"""
if request.method != 'POST':
return JsonResponse({'success': False, 'error': 'Invalid request method'})
compliance_id = request.POST.get('compliance_id')
status = request.POST.get('status')
notes = request.POST.get('notes', '')
evidence_summary = request.POST.get('evidence_summary', '')
if not compliance_id or not status:
return JsonResponse({'success': False, 'error': 'Missing required fields'})
try:
compliance = StandardCompliance.objects.get(pk=compliance_id)
compliance.status = status
compliance.notes = notes
compliance.evidence_summary = evidence_summary
compliance.assessor = request.user
compliance.last_assessed_date = timezone.now().date()
compliance.save()
return JsonResponse({
'success': True,
'status': compliance.status,
'status_display': compliance.get_status_display(),
})
except Exception as e:
return JsonResponse({'success': False, 'error': str(e)})
@login_required
def get_compliance_status(request, department_id, standard_id):
"""API endpoint to get compliance status"""
compliance = StandardCompliance.objects.filter(
department_id=department_id,
standard_id=standard_id
).first()
if compliance:
data = {
'status': compliance.status,
'last_assessed_date': compliance.last_assessed_date,
'assessor': compliance.assessor.get_full_name() if compliance.assessor else None,
'notes': compliance.notes,
'attachment_count': compliance.attachments.count(),
}
else:
data = {
'status': 'not_assessed',
'last_assessed_date': None,
'assessor': None,
'notes': '',
'attachment_count': 0,
}
return JsonResponse(data)

View File

@ -65,6 +65,8 @@ LOCAL_APPS = [
'apps.dashboard',
'apps.appreciation',
'apps.observations',
'apps.references',
'apps.standards',
]
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS

View File

@ -40,6 +40,8 @@ urlpatterns = [
path('ai-engine/', include('apps.ai_engine.urls')),
path('appreciation/', include('apps.appreciation.urls', namespace='appreciation')),
path('observations/', include('apps.observations.urls', namespace='observations')),
path('references/', include('apps.references.urls', namespace='references')),
path('standards/', include('apps.standards.urls', namespace='standards')),
# API endpoints
path('api/auth/', include('apps.accounts.urls')),

View File

@ -0,0 +1,168 @@
# Observation Model Fixes
## Overview
Fixed critical issues in the Observation model to align with the system's tenant isolation architecture and improve parity with the Complaint model.
## Issues Fixed
### 1. Missing Hospital ForeignKey (Critical)
**Problem:** The Observation model lacked a `hospital` field, breaking tenant isolation.
**Impact:**
- Cannot filter observations by hospital
- Cannot track which hospital owns the observation
- Prevents proper routing in multi-hospital environment
- Breaks the tenant isolation pattern used throughout the system
**Solution:**
- Added `hospital` ForeignKey to `organizations.Hospital`
- Field is required for new observations
- Added database index for efficient filtering by hospital and status
### 2. Missing Staff ForeignKey
**Problem:** Observations couldn't track which staff member was mentioned in the report.
**Solution:**
- Added optional `staff` ForeignKey to `organizations.Staff`
- Enables AI-matching similar to Complaint model
- Useful for tracking observations about specific staff members
### 3. Missing Source Tracking
**Problem:** No way to track how observations were submitted.
**Solution:**
- Added `source` field with choices:
- `staff_portal` - Staff Portal (default)
- `web_form` - Web Form
- `mobile_app` - Mobile App
- `email` - Email
- `call_center` - Call Center
- `other` - Other
## Model Changes
### Added Fields to Observation Model
```python
hospital = models.ForeignKey(
'organizations.Hospital',
on_delete=models.CASCADE,
related_name='observations',
help_text="Hospital where observation was made"
)
staff = models.ForeignKey(
'organizations.Staff',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='observations',
help_text="Staff member mentioned in observation"
)
source = models.CharField(
max_length=50,
choices=[
('staff_portal', 'Staff Portal'),
('web_form', 'Web Form'),
('mobile_app', 'Mobile App'),
('email', 'Email'),
('call_center', 'Call Center'),
('other', 'Other'),
],
default='staff_portal',
help_text="How the observation was submitted"
)
```
### Added Database Indexes
```python
models.Index(fields=['hospital', 'status', '-created_at'])
```
## Migration Strategy
### Migration 0002_add_missing_fields.py
- Adds `hospital`, `staff`, and `source` fields
- Initially makes `hospital` nullable to support existing data
- Since there are 0 existing observations, no data migration needed
### Future Migration
After deployment, a follow-up migration can make `hospital` required if needed:
```python
migrations.AlterField(
model_name='observation',
name='hospital',
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='observations',
to='organizations.hospital'
),
)
```
## Why Not Unify with Complaints?
Despite similarities, the models should remain separate:
### Key Differences
| Aspect | Complaint | Observation |
|--------|-----------|-------------|
| **Purpose** | Patient-reported issues | Staff-reported issues |
| **Reporter** | Patient FK (required) | Anonymous or staff ID |
| **Workflow** | Open → In Progress → Resolved → Closed | New → Triaged → Assigned → In Progress → Resolved → Closed (plus REJECTED, DUPLICATE) |
| **SLA** | Response time SLAs with tracking | Triage timeline, not response time |
| **Tracking** | Reference number (optional) | Tracking code (required, OBS-XXXXXX) |
| **Location** | No location field | Location text field |
| **Incident Time** | No separate field | Incident datetime field |
| **Triage** | No triage fields | Triage tracking (triaged_by, triaged_at) |
| **Resolution Survey** | Links to SurveyInstance | No resolution survey |
| **Encounter ID** | Links to patient encounters | No encounter tracking |
### Different Business Logic
- **Complaints**: Customer service workflow, satisfaction surveys, SLA escalation
- **Observations**: Safety/quality workflow, triage process, anonymous reporting
### Different Stakeholders
- **Complaints**: Patient experience teams, call center
- **Observations**: Quality teams, safety officers, clinical governance
## Next Steps
1. **Run Migration:**
```bash
python manage.py migrate observations
```
2. **Update Forms:** Ensure observation forms include hospital selection
- Public observation form
- Staff portal observation form
- Admin observation form
3. **Update Views:** Add hospital filtering to observation list views
4. **Update APIs:** Include hospital field in observation APIs
5. **Update Templates:** Display hospital information in observation detail views
6. **Consider AI Integration:** Add staff name extraction similar to complaints
- Use `apps.core.ai_service.extract_person_name()`
- Match extracted names to Staff records
- Populate `staff` field automatically
## Files Modified
1. **apps/observations/models.py**
- Added `hospital` ForeignKey
- Added `staff` ForeignKey
- Added `source` field
- Added hospital status index
2. **apps/observations/migrations/0002_add_missing_fields.py**
- Migration to add new fields
## Backwards Compatibility
- Migration makes `hospital` initially nullable
- Model code enforces `hospital` as required for new records
- Existing code without hospital selection will work but with validation errors for new observations
- Consider updating form validation to ensure hospital is always provided

View File

@ -0,0 +1,416 @@
# References Section - Reference Documentation
## Overview
The References Section is a file server system for managing reference documents with folder categorization. It provides a simple and straightforward way to store files and group them by folders, accessible by employees at any time.
## Features
### 1. **Folder Management**
- **Hierarchical Structure**: Create nested folders (folders within folders)
- **Bilingual Support**: Names and descriptions in both English and Arabic
- **Customization**: Icons and colors for visual organization
- **Access Control**: Role-based access control per folder
- **Soft Delete**: Folders can be marked as deleted without actual removal
- **Ordering**: Custom display order for folders
### 2. **Document Management**
- **Multiple File Types**: Support for PDF, Word, Excel, PowerPoint, Images, and more
- **Version Control**: Track multiple versions of the same document
- **Bilingual Titles**: Document titles and descriptions in both languages
- **Tags**: Comma-separated tags for easy searching
- **Access Control**: Role-based access control per document
- **Download Tracking**: Track how many times documents are downloaded
- **Publication Control**: Documents can be published/unpublished
### 3. **Search & Discovery**
- Full-text search across titles, descriptions, and tags
- Filter by folder
- Filter by file type
- Filter by tags
- Pagination for large result sets
### 4. **Audit Trail**
- Track all document accesses (view, download, preview)
- Log user actions with IP addresses
- Track download counts and last accessed dates
## Models
### ReferenceFolder
Main folder model with the following fields:
- `name`: English folder name (required)
- `name_ar`: Arabic folder name (optional)
- `description`: English description (optional)
- `description_ar`: Arabic description (optional)
- `parent`: Parent folder for nested structure
- `icon`: FontAwesome icon class (e.g., 'fa-folder')
- `color`: Hex color code (e.g., '#007bff')
- `order`: Display order
- `access_roles`: Groups that can access this folder
- `is_active`: Active status
- `is_deleted`: Soft delete flag
- `hospital`: Hospital tenant (via TenantModel)
**Key Methods:**
- `get_full_path()`: Get full folder path as breadcrumb
- `has_access(user)`: Check if user has access
- `get_subfolders()`: Get immediate subfolders
- `get_documents()`: Get documents in this folder
- `get_document_count()`: Count documents including subfolders
### ReferenceDocument
Document model with the following fields:
- `folder`: Associated folder
- `title`: English title (required)
- `title_ar`: Arabic title (optional)
- `file`: File upload
- `filename`: Original filename
- `file_type`: File extension (e.g., pdf, docx)
- `file_size`: File size in bytes
- `description`: English description (optional)
- `description_ar`: Arabic description (optional)
- `version`: Version string (e.g., '1.0', '1.1')
- `is_latest_version`: Is this the latest version?
- `parent_document`: Previous version reference
- `uploaded_by`: User who uploaded
- `download_count`: Number of downloads
- `last_accessed_at`: Last access timestamp
- `is_published`: Visibility status
- `access_roles`: Groups that can access this document
- `tags`: Comma-separated tags
- `metadata`: Additional metadata (JSON)
**Key Methods:**
- `has_access(user)`: Check if user has access
- `increment_download_count()`: Track downloads
- `get_tags_list()`: Get tags as list
- `get_file_icon()`: Get appropriate icon class
- `get_file_size_display()`: Human-readable file size
- `get_version_history()`: Get all versions
### ReferenceDocumentAccess
Access log model for tracking:
- `document`: Referenced document
- `user`: User who accessed
- `action`: Action type (view, download, preview)
- `ip_address`: User's IP address
- `user_agent`: Browser user agent
## Views
### UI Views (Server-Rendered)
1. **`reference_dashboard`** - Main entry point showing:
- Root folders
- Recent documents
- Statistics (total folders, total documents)
2. **`folder_view`** - Browse folder contents:
- Shows subfolders
- Shows documents
- Breadcrumb navigation
3. **`folder_create`** - Create new folder:
- Form with all folder fields
- Parent folder option
- Access control configuration
4. **`folder_edit`** - Edit existing folder:
- Update folder details
- Change parent folder
- Modify access control
5. **`folder_delete`** - Soft delete folder (AJAX):
- Mark folder as deleted
- Documents in folder remain
6. **`document_view`** - Document details:
- Document information
- Version history
- Related documents
- Download button
7. **`document_create`** - Upload new document:
- File upload
- Metadata input
- Folder selection
- New version option
8. **`document_edit`** - Edit document:
- Update metadata
- Upload new file (optional)
- Create new version option
9. **`document_delete`** - Soft delete document (AJAX)
10. **`search`** - Search documents:
- Full-text search
- Multiple filters
- Pagination
## URLs
```python
# Dashboard
/references/ # Main dashboard
# Folders
/references/folder/<pk>/ # View folder
/references/folder/create/ # Create folder
/references/folder/<pk>/create/ # Create subfolder
/references/folder/<pk>/edit/ # Edit folder
/references/folder/<pk>/delete/ # Delete folder
# Documents
/references/document/<pk>/ # View document
/references/document/create/ # Upload document
/references/document/<pk>/create/ # Upload to folder
/references/document/<pk>/edit/ # Edit document
/references/document/<pk>/delete/ # Delete document
# Search
/references/search/ # Search documents
```
## Access Control
The system implements role-based access control:
1. **Folder Access**: A folder can have specific access roles assigned
- If no roles assigned → Accessible to all users
- If roles assigned → Only users in those roles can access
2. **Document Access**: Similar to folder access
- Inherits folder access if not restricted
- Can have its own access restrictions
3. **Cascade Effect**: When accessing documents through folder navigation:
- User must have access to both folder AND document
- Document-specific access overrides folder access
## File Storage
Files are stored with the following path structure:
```
references/<hospital_id>/YYYY/MM/DD/<uuid>.<ext>
```
Example:
```
references/1/2026/01/07/a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf
```
## Usage Examples
### Creating a Folder
1. Navigate to `/references/`
2. Click "New Folder" button
3. Fill in folder information:
- Name (English) - Required
- Name (Arabic) - Optional
- Description (English) - Optional
- Description (Arabic) - Optional
- Parent Folder - Optional (for nested folders)
- Icon - Optional (e.g., 'fa-folder')
- Color - Optional (e.g., '#007bff')
- Order - Optional (default: 0)
- Access Roles - Optional (leave empty for public)
4. Click "Create Folder"
### Uploading a Document
1. Navigate to folder where you want to upload
2. Click "Upload Document" button
3. Fill in document information:
- Title (English) - Required
- Title (Arabic) - Optional
- File - Required
- Description (English) - Optional
- Description (Arabic) - Optional
- Version - Optional (default: '1.0')
- Access Roles - Optional
- Tags - Optional (comma-separated)
4. Click "Upload Document"
### Searching Documents
1. Navigate to `/references/search/`
2. Use search/filter options:
- Search by text
- Filter by folder
- Filter by file type
- Filter by tags
3. Results are paginated
### Creating Document Versions
1. Go to document detail page
2. Click "Edit Document"
3. Check "Upload as new version"
4. Select new file
5. Click "Update Document"
6. Old version is marked as not latest
7. New version becomes the current version
## Templates
All templates are in `templates/references/`:
- `dashboard.html` - Main dashboard
- `folder_view.html` - Folder contents
- `folder_form.html` - Create/edit folder
- `document_view.html` - Document details
- `document_form.html` - Upload/edit document
- `search.html` - Search results
## Forms
- `ReferenceFolderForm` - Folder creation/editing
- `ReferenceDocumentForm` - Document upload/editing
- `ReferenceDocumentSearchForm` - Document search
## Admin Integration
All models are registered with Django Admin:
- ReferenceFolder - Full CRUD with inline actions
- ReferenceDocument - Full CRUD with file preview
- ReferenceDocumentAccess - Read-only access logs
## File Upload Configuration
The system supports the following file types:
- PDF (.pdf)
- Word (.doc, .docx)
- Excel (.xls, .xlsx)
- PowerPoint (.ppt, .pptx)
- Text (.txt, .rtf)
- CSV (.csv)
- Images (.jpg, .jpeg, .png, .gif)
## Best Practices
1. **Folder Organization**
- Use meaningful names in both languages
- Keep structure simple and logical
- Limit nesting depth (3-4 levels max)
2. **Document Management**
- Use clear, descriptive titles
- Add meaningful tags for searchability
- Keep document versions organized
- Archive old versions, don't delete
3. **Access Control**
- Assign access roles at folder level for broad control
- Use document-level access for sensitive files
- Regularly review access permissions
4. **File Uploads**
- Keep file sizes reasonable (under 50MB recommended)
- Use PDF format for documents when possible
- Optimize images before uploading
## Integration with Hospital System
The References section is fully integrated with the hospital tenant system:
- Each hospital has its own set of folders and documents
- Files are segregated by hospital ID
- Users can only access their assigned hospital's references
- Context processors ensure hospital context is always available
## Sidebar Navigation
The References section is accessible from the sidebar:
```
Sidebar → References (icon: bi-folder)
```
This link navigates to `/references/` (dashboard).
## Future Enhancements
Potential improvements for future versions:
1. **Bulk Operations**
- Bulk upload documents
- Bulk move/copy folders
- Bulk delete
2. **Advanced Search**
- Full-text indexing with search engine
- Document content search (PDF text extraction)
- Saved search queries
3. **Integration**
- Email documents directly from system
- Generate document download reports
- Integrate with external document storage (S3, etc.)
4. **Collaboration**
- Document comments/annotations
- Document sharing links
- Workflow approvals for documents
## Troubleshooting
### Issue: Folder creation not refreshing
**Solution**: The folder_form template has been fixed to use correct field names:
- `name` (not `name_en`)
- `description` (not `description_en`)
- Removed non-existent fields: `visibility`, `tags`
### Issue: Can't see folders/documents
**Possible causes:**
1. User doesn't have hospital assigned → Check user.hospital
2. Access roles not configured → Check folder/document access_roles
3. Folder/document is inactive → Check is_active/is_published flags
4. Soft deleted → Check is_deleted flag
### Issue: File upload fails
**Possible causes:**
1. File type not supported → Check allowed_types in model.clean()
2. File size too large → Check Django settings for FILE_UPLOAD_MAX_MEMORY_SIZE
3. Insufficient permissions → Check media directory write permissions
## Technical Implementation
### File Upload Handling
Files are handled via Django's FileField with custom upload path function:
- Path includes hospital ID for segregation
- Date-based organization (YYYY/MM/DD)
- UUID-based filename for uniqueness
### Soft Delete
Both folders and documents use soft delete:
- `is_deleted` flag instead of actual deletion
- Maintains audit trail
- Allows recovery if needed
### Version Control
Documents support versioning:
- `parent_document` links to previous version
- `is_latest_version` indicates current version
- `version` string for semantic versioning
## Conclusion
The References section provides a comprehensive document management system with folder-based organization, role-based access control, version tracking, and full audit capabilities. It's simple and straightforward to use while providing powerful features for managing organizational documents.

View File

@ -74,6 +74,10 @@ msgid "Social Media"
msgstr ""
#: templates/layouts/partials/sidebar.html:96
msgid "References"
msgstr ""
#: templates/layouts/partials/sidebar.html:107
msgid "Analytics"
msgstr ""

View File

@ -245,17 +245,6 @@
</div>
</div>
{% if complaint.physician %}
<div class="row mb-3">
<div class="col-md-12">
<div class="info-label">{{ _("Physician") }}</div>
<div class="info-value">
Dr. {{ complaint.physician.first_name }} {{ complaint.physician.last_name }}
<span class="text-muted">({{ complaint.physician.specialty }})</span>
</div>
</div>
</div>
{% endif %}
{% if complaint.department %}
<div class="row mb-3">
@ -315,6 +304,76 @@
</div>
{% endif %}
<!-- Staff Suggestions Section -->
{% if complaint.metadata.ai_analysis.staff_matches and user.is_px_admin %}
<div class="row mb-3">
<div class="col-md-12">
<div class="info-label">
<i class="bi bi-people me-1"></i>
Staff Suggestions
{% if complaint.metadata.ai_analysis.needs_staff_review %}
<span class="badge bg-warning ms-2">Needs Review</span>
{% endif %}
</div>
<div class="info-value">
{% if complaint.metadata.ai_analysis.extracted_staff_name %}
<p class="text-muted mb-2">
AI extracted name: <strong>"{{ complaint.metadata.ai_analysis.extracted_staff_name }}"</strong>
({% if complaint.metadata.ai_analysis.staff_match_count %}{{ complaint.metadata.ai_analysis.staff_match_count }} potential match{{ complaint.metadata.ai_analysis.staff_match_count|pluralize }}{% else %}No matches found{% endif %})
</p>
{% endif %}
<div class="list-group">
{% for staff_match in complaint.metadata.ai_analysis.staff_matches %}
<div class="list-group-item list-group-item-action {% if complaint.staff and staff_match.id == complaint.staff.id|stringformat:"s" %}list-group-item-primary{% endif %}">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="d-flex align-items-center">
<i class="bi bi-person me-2 text-primary"></i>
<strong>{{ staff_match.name_en }}</strong>
{% if staff_match.name_ar %}
<span class="text-muted ms-2">({{ staff_match.name_ar }})</span>
{% endif %}
</div>
{% if staff_match.job_title %}
<small class="text-muted">{{ staff_match.job_title }}</small>
{% endif %}
{% if staff_match.specialization %}
<small class="text-muted"> • {{ staff_match.specialization }}</small>
{% endif %}
{% if staff_match.department %}
<small class="text-muted"> • {{ staff_match.department }}</small>
{% endif %}
</div>
<div class="text-end">
<span class="badge {% if staff_match.confidence >= 0.7 %}bg-success{% elif staff_match.confidence >= 0.5 %}bg-warning{% else %}bg-danger{% endif %}">
{{ staff_match.confidence|mul:100|floatformat:0 }}% confidence
</span>
{% if complaint.staff and staff_match.id == complaint.staff.id|stringformat:"s" %}
<div class="small text-success mt-1">
<i class="bi bi-check-circle-fill"></i> Currently assigned
</div>
{% elif not complaint.staff %}
<button class="btn btn-sm btn-outline-primary mt-1" onclick="assignStaff('{{ staff_match.id }}', '{{ staff_match.name_en }}')">
<i class="bi bi-person-plus"></i> Select
</button>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
<div class="mt-3">
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#staffSelectionModal">
<i class="bi bi-search me-1"></i> Search All Staff
</button>
</div>
</div>
</div>
</div>
{% endif %}
<hr>
<div class="mb-3">
@ -348,7 +407,7 @@
</span>
</div>
<div class="col-md-6 text-md-end">
<small class="text-muted">Confidence: {{ complaint.emotion_confidence|floatformat:0 }}%</small>
<small class="text-muted">Confidence: {{ complaint.emotion_confidence|mul:100|floatformat:0 }}%</small>
</div>
</div>
<div class="mb-1">
@ -575,6 +634,46 @@
<h6 class="mb-0"><i class="bi bi-lightning-fill me-2"></i>{% trans "Quick Actions" %}</h6>
</div>
<div class="card-body">
{% if user.is_px_admin %}
<!-- Change Staff (PX Admin only) -->
<div class="mb-3">
<label class="form-label">{% trans "Change Staff" %}</label>
<button type="button" class="btn btn-outline-info w-100" data-bs-toggle="modal" data-bs-target="#staffSelectionModal">
<i class="bi bi-person-badge me-1"></i> {{ _("Assign/Change Staff")}}
</button>
{% if complaint.metadata.ai_analysis.needs_staff_review %}
<small class="text-warning">
<i class="bi bi-exclamation-triangle me-1"></i>
This complaint needs staff review
</small>
{% endif %}
</div>
<hr>
{% endif %}
<!-- Change Department -->
{% if can_edit and hospital_departments %}
<form method="post" action="{% url 'complaints:complaint_change_department' complaint.id %}" class="mb-3">
{% csrf_token %}
<label class="form-label">{% trans "Change Department" %}</label>
<div class="input-group">
<select name="department_id" class="form-select" required>
<option value="">{{ _("Select department...")}}</option>
{% for dept in hospital_departments %}
<option value="{{ dept.id }}"
{% if complaint.department and complaint.department.id == dept.id %}selected{% endif %}>
{{ dept.name_en }}
{% if dept.name_ar %}({{ dept.name_ar }}){% endif %}
</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-primary">
<i class="bi bi-building"></i>
</button>
</div>
</form>
{% endif %}
<!-- Assign -->
<form method="post" action="{% url 'complaints:complaint_assign' complaint.id %}" class="mb-3">
{% csrf_token %}
@ -613,6 +712,12 @@
</button>
</form>
<!-- Send Notification -->
<button type="button" class="btn btn-info w-100 mb-2" data-bs-toggle="modal"
data-bs-target="#sendNotificationModal">
<i class="bi bi-envelope me-1"></i> {{ _("Send Notification") }}
</button>
<!-- Escalate -->
<button type="button" class="btn btn-danger w-100" data-bs-toggle="modal"
data-bs-target="#escalateModal">
@ -805,7 +910,416 @@
</div>
</div>
<!-- Staff Selection Modal -->
<div class="modal fade" id="staffSelectionModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-person-badge me-2"></i>Select Staff Member
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<!-- Department Filter -->
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label">Filter by Department</label>
<select id="staffDepartmentFilter" class="form-select">
<option value="">All Departments</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Search Staff</label>
<input type="text" id="staffSearchInput" class="form-control" placeholder="Search by name or job title...">
</div>
</div>
<!-- Staff List -->
<div id="staffListContainer" class="border rounded" style="max-height: 400px; overflow-y: auto;">
<div class="text-center py-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" id="assignSelectedStaffBtn" class="btn btn-primary" onclick="assignSelectedStaff()" disabled>
<i class="bi bi-person-check me-1"></i>Assign Selected Staff
</button>
</div>
</div>
</div>
</div>
<!-- Send Notification Modal -->
<div class="modal fade" id="sendNotificationModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-envelope me-2"></i>Send Complaint Notification
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<!-- AI Summary (EDITABLE) -->
<div class="mb-3">
<label class="form-label">
<i class="bi bi-robot me-1"></i>
AI Summary
<small class="text-muted">(you can edit this before sending)</small>
</label>
<textarea id="emailMessage" class="form-control" rows="5"
placeholder="Enter message to send...">{{ complaint.short_description }}</textarea>
<small class="text-muted">
This is AI-generated summary. You can edit it before sending.
</small>
</div>
<!-- Recipient Information -->
<div class="card mb-3">
<div class="card-body">
<h6 class="card-title">
<i class="bi bi-person-check me-1"></i>Recipient
</h6>
{% if complaint.staff and complaint.staff.user %}
<!-- Staff has user account - will receive email -->
<div class="alert alert-success mb-2">
<i class="bi bi-check-circle-fill me-1"></i>
<strong>Primary Recipient:</strong> {{ complaint.staff.get_full_name }}
{% if complaint.staff.job_title %}
<br><small class="text-muted">{{ complaint.staff.job_title }}</small>
{% endif %}
</div>
{% elif complaint.staff %}
<!-- Staff exists but has no user account -->
<div class="alert alert-warning mb-2">
<i class="bi bi-exclamation-triangle-fill me-1"></i>
<strong>Staff Member Assigned:</strong> {{ complaint.staff.get_full_name }}
{% if complaint.staff.job_title %}
<br><small class="text-muted">{{ complaint.staff.job_title }}</small>
{% endif %}
<hr class="my-2">
<small class="text-muted">
<i class="bi bi-info-circle me-1"></i>
This staff member has no user account in the system.
</small>
</div>
{% if complaint.department and complaint.department.manager %}
<!-- Department manager is the actual recipient -->
<div class="alert alert-info mb-0">
<i class="bi bi-person-badge me-1"></i>
<strong>Actual Recipient:</strong> {{ complaint.department.manager.get_full_name }}
<br><small class="text-muted">Department Head of {{ complaint.department.name_en }}</small>
</div>
{% else %}
<!-- No fallback recipient -->
<div class="alert alert-danger mb-0">
<i class="bi bi-x-circle-fill me-1"></i>
<strong>No recipient available</strong>
<br><small>The assigned staff has no user account and no department manager is set.</small>
</div>
{% endif %}
{% elif complaint.department and complaint.department.manager %}
<!-- No staff, but department manager exists -->
<div class="alert alert-info mb-0">
<i class="bi bi-person-badge me-1"></i>
<strong>Department Head:</strong> {{ complaint.department.manager.get_full_name }}
<br><small class="text-muted">Manager of {{ complaint.department.name_en }}</small>
</div>
{% else %}
<!-- No recipient at all -->
<div class="alert alert-danger mb-0">
<i class="bi bi-exclamation-triangle-fill me-1"></i>
<strong>No recipient available</strong>
<br><small>No staff or department manager assigned to this complaint.</small>
</div>
{% endif %}
</div>
</div>
<!-- Optional Additional Message -->
<div class="mb-3">
<label class="form-label">
Additional Message (Optional)
</label>
<textarea id="additionalMessage" class="form-control" rows="3"
placeholder="Add any additional notes to send..."></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
Cancel
</button>
<button type="button" id="sendNotificationBtn" class="btn btn-primary"
onclick="sendNotification()">
<i class="bi bi-send me-1"></i>Send Email
</button>
</div>
</div>
</div>
</div>
<script>
let selectedStaffId = null;
let allHospitalStaff = [];
let currentLanguage = document.documentElement.lang || 'en';
// Load hospital staff when modal opens
document.getElementById('staffSelectionModal').addEventListener('shown.bs.modal', function() {
loadHospitalDepartments();
loadHospitalStaff();
});
// Load hospital departments
function loadHospitalDepartments() {
const hospitalId = '{{ complaint.hospital.id }}';
const departmentSelect = document.getElementById('staffDepartmentFilter');
fetch(`/organizations/api/departments/?hospital=${hospitalId}`, {
credentials: 'same-origin'
})
.then(response => response.json())
.then(data => {
// Clear existing options except the first one
departmentSelect.innerHTML = '<option value="">All Departments</option>';
// Populate with departments
data.results.forEach(dept => {
const option = document.createElement('option');
option.value = dept.id;
const deptName = currentLanguage === 'ar' && dept.name_ar ? dept.name_ar : dept.name_en;
option.textContent = deptName;
departmentSelect.appendChild(option);
});
})
.catch(error => {
console.error('Error loading departments:', error);
});
}
// Load hospital staff from API
function loadHospitalStaff(departmentId = null, search = '') {
const container = document.getElementById('staffListContainer');
let url = `/complaints/api/complaints/{{ complaint.id }}/hospital_staff/`;
const params = new URLSearchParams();
if (departmentId) params.append('department_id', departmentId);
if (search) params.append('search', search);
if (params.toString()) {
url += '?' + params.toString();
}
container.innerHTML = `
<div class="text-center py-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
`;
fetch(url, {
credentials: 'same-origin',
headers: {
'X-CSRFToken': getCookie('csrftoken')
}
})
.then(response => response.json())
.then(data => {
allHospitalStaff = data.staff || [];
renderStaffList(allHospitalStaff);
})
.catch(error => {
console.error('Error loading staff:', error);
container.innerHTML = `
<div class="alert alert-danger m-3">
<i class="bi bi-exclamation-triangle me-2"></i>
Failed to load staff. Please try again.
</div>
`;
});
}
// Render staff list
function renderStaffList(staffList) {
const container = document.getElementById('staffListContainer');
if (!staffList || staffList.length === 0) {
container.innerHTML = `
<div class="text-center py-4 text-muted">
<i class="bi bi-people" style="font-size: 3rem;"></i>
<p class="mt-3">No staff found</p>
</div>
`;
return;
}
let html = '<div class="list-group list-group-flush">';
// Group by department
const grouped = {};
staffList.forEach(staff => {
const dept = staff.department || 'No Department';
if (!grouped[dept]) grouped[dept] = [];
grouped[dept].push(staff);
});
// Render groups
for (const [department, staffMembers] of Object.entries(grouped)) {
html += `<div class="list-group-item bg-light fw-bold"><i class="bi bi-building me-2"></i>${department}</div>`;
staffMembers.forEach(staff => {
html += `
<div class="list-group-item list-group-item-action staff-item"
data-staff-id="${staff.id}"
data-staff-name="${staff.name_en}"
onclick="selectStaff('${staff.id}', '${staff.name_en.replace(/'/g, "\\'")}')">
<div class="form-check">
<input class="form-check-input" type="radio" name="selectedStaff"
id="staff_${staff.id}" value="${staff.id}"
${selectedStaffId === staff.id ? 'checked' : ''}>
<label class="form-check-label w-100 cursor-pointer" for="staff_${staff.id}">
<div>
<strong>${staff.name_en}</strong>
${staff.name_ar ? `<span class="text-muted ms-2">(${staff.name_ar})</span>` : ''}
</div>
<small class="text-muted">
${staff.job_title || ''}
${staff.specialization ? '• ' + staff.specialization : ''}
</small>
</label>
</div>
</div>
`;
});
}
html += '</div>';
container.innerHTML = html;
}
// Select staff from list
function selectStaff(staffId, staffName) {
selectedStaffId = staffId;
// Update radio buttons
document.querySelectorAll('input[name="selectedStaff"]').forEach(radio => {
radio.checked = (radio.value === staffId);
});
// Enable assign button
document.getElementById('assignSelectedStaffBtn').disabled = false;
}
// Assign staff directly from suggestions
function assignStaff(staffId, staffName) {
if (!confirm(`Assign ${staffName} to this complaint?`)) {
return;
}
const data = {
staff_id: staffId,
reason: 'Selected from AI suggestions'
};
fetch(`/complaints/api/complaints/{{ complaint.id }}/assign_staff/`, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCookie('csrftoken')
},
body: JSON.stringify(data)
})
.then(response => response.json())
.then(result => {
if (result.message) {
alert('Staff assigned successfully!');
location.reload();
} else {
alert('Error: ' + (result.error || 'Unknown error'));
}
})
.catch(error => {
console.error('Error:', error);
alert('Failed to assign staff. Please try again.');
});
}
// Assign selected staff from modal
function assignSelectedStaff() {
if (!selectedStaffId) {
alert('Please select a staff member first.');
return;
}
const staffName = document.querySelector(`input[name="selectedStaff"]:checked`)?.closest('.staff-item')?.dataset?.staffName || 'selected staff';
const reason = prompt('Reason for assignment (optional):', 'Manual selection from hospital staff list');
const data = {
staff_id: selectedStaffId,
reason: reason || 'Manual selection from hospital staff list'
};
const btn = document.getElementById('assignSelectedStaffBtn');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Assigning...';
fetch(`/complaints/api/complaints/{{ complaint.id }}/assign_staff/`, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCookie('csrftoken')
},
body: JSON.stringify(data)
})
.then(response => response.json())
.then(result => {
if (result.message) {
alert('Staff assigned successfully!');
location.reload();
} else {
alert('Error: ' + (result.error || 'Unknown error'));
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-person-check me-1"></i>Assign Selected Staff';
}
})
.catch(error => {
console.error('Error:', error);
alert('Failed to assign staff. Please try again.');
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-person-check me-1"></i>Assign Selected Staff';
});
}
// Department filter change handler
document.getElementById('staffDepartmentFilter')?.addEventListener('change', function(e) {
const departmentId = e.target.value || null;
const search = document.getElementById('staffSearchInput').value.trim();
loadHospitalStaff(departmentId, search);
});
// Search input handler (debounced)
let searchTimeout;
document.getElementById('staffSearchInput')?.addEventListener('input', function(e) {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
const departmentId = document.getElementById('staffDepartmentFilter').value || null;
const search = e.target.value.trim();
loadHospitalStaff(departmentId, search);
}, 300);
});
function createAction() {
const assignTo = document.getElementById('actionAssignTo').value;
const btn = document.getElementById('createActionBtn');
@ -819,8 +1333,9 @@ function createAction() {
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Creating...';
fetch(`/api/complaints/{{ complaint.id }}/create_action_from_ai/`, {
fetch(`/complaints/api/complaints/{{ complaint.id }}/create_action_from_ai/`, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCookie('csrftoken')
@ -860,5 +1375,58 @@ function getCookie(name) {
}
return cookieValue;
}
function sendNotification() {
const btn = document.getElementById('sendNotificationBtn');
const emailMessage = document.getElementById('emailMessage').value;
const additionalMessage = document.getElementById('additionalMessage').value;
// Validate email message is not empty
if (!emailMessage.trim()) {
alert('Please enter a message to send.');
return;
}
// Disable button and show loading
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Sending...';
// Get CSRF token
const csrftoken = getCookie('csrftoken');
fetch(`/complaints/api/complaints/{{ complaint.id }}/send_notification/`, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrftoken
},
body: JSON.stringify({
email_message: emailMessage,
additional_message: additionalMessage
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('Email sent successfully!');
// Close modal
const modal = bootstrap.Modal.getInstance(document.getElementById('sendNotificationModal'));
modal.hide();
// Reload page to show timeline entry
location.reload();
} else {
alert('Error: ' + (data.error || 'Unknown error'));
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-send me-1"></i>Send Email';
}
})
.catch(error => {
console.error('Error:', error);
alert('Failed to send email. Please try again.');
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-send me-1"></i>Send Email';
});
}
</script>
{% endblock %}

View File

@ -97,9 +97,9 @@
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">{% trans "Physician" %}</label>
<select name="physician_id" class="form-select" id="physicianSelect">
<option value="">{{ _("Select physician")}}</option>
<label class="form-label">{% trans "Staff" %}</label>
<select name="staff_id" class="form-select" id="staffSelect">
<option value="">{{ _("Select staff")}}</option>
</select>
</div>
</div>

View File

@ -340,11 +340,24 @@ function getName(category) {
return category.name_en;
}
// Load categories
function loadCategories() {
// Load categories based on hospital
function loadCategories(hospitalId) {
if (!hospitalId) {
// Clear categories if no hospital selected
const categorySelect = $('#id_category');
categorySelect.find('option:not(:first)').remove();
$('#subcategory_container').hide();
$('#subcategory_description').hide();
$('#category_description').hide();
$('#id_subcategory').find('option:not(:first)').remove();
$('#id_subcategory').prop('required', false);
return;
}
$.ajax({
url: '{% url "complaints:api_load_categories" %}',
type: 'GET',
data: { hospital_id: hospitalId },
success: function(response) {
// Store all categories
allCategories = response.categories;
@ -429,17 +442,17 @@ function loadSubcategories(categoryId) {
}
}
// Initialize complaint form - called when form is loaded
function initializeComplaintForm() {
// Detect current language from HTML
const htmlLang = document.documentElement.lang;
if (htmlLang === 'ar') {
currentLanguage = 'ar';
}
// Load categories immediately
loadCategories();
}
// Handle hospital change
$('#id_hospital').on('change', function() {
const hospitalId = $(this).val();
loadCategories(hospitalId);
// Clear category and subcategory when hospital changes
$('#id_category').val('');
$('#id_subcategory').val('');
$('#subcategory_container').hide();
$('#subcategory_description').hide();
$('#category_description').hide();
});
// Handle category change
$('#id_category').on('change', function() {
@ -510,9 +523,6 @@ $('#public_complaint_form').on('submit', function(e) {
}
});
});
// Initialize form on page load
initializeComplaintForm();
});
</script>
{% endblock %}

File diff suppressed because it is too large Load Diff

View File

@ -121,6 +121,24 @@
</a>
</li>
<!-- References -->
<li class="nav-item">
<a class="nav-link {% if 'references' in request.path %}active{% endif %}"
href="{% url 'references:dashboard' %}">
<i class="bi bi-folder"></i>
{% trans "References" %}
</a>
</li>
<!-- Standards -->
<li class="nav-item">
<a class="nav-link {% if 'standards' in request.path %}active{% endif %}"
href="{% url 'standards:dashboard' %}">
<i class="bi bi-shield-check"></i>
{% trans "Standards" %}
</a>
</li>
<hr class="my-2" style="border-color: rgba(255,255,255,0.1);">
<!-- Analytics -->

View File

@ -48,7 +48,7 @@
</head>
<body>
<!-- Header -->
<header class="public-header">
{% comment %} <header class="public-header">
<div class="container">
<div class="d-flex align-items-center justify-content-between">
<h3 class="mb-0">
@ -59,7 +59,7 @@
</span>
</div>
</div>
</header>
</header> {% endcomment %}
<!-- Main Content -->
<main>
@ -69,13 +69,13 @@
</main>
<!-- Footer -->
<footer class="public-footer">
{% comment %} <footer class="public-footer">
<div class="container text-center">
<p class="mb-0 text-muted">
&copy; {% now "Y" %} PX360. {% trans "All rights reserved." %}
</p>
</div>
</footer>
</footer> {% endcomment %}
<!-- jQuery -->
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>

View File

@ -0,0 +1,162 @@
{% extends "layouts/base.html" %}
{% load i18n static %}
{% block title %}{% trans "Reference Section" %} - {% trans "PX360" %}{% endblock %}
{% block content %}
<div class="container-fluid px-4">
<!-- Page Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-0">{% trans "Reference Section" %}</h1>
<p class="text-muted small mb-0">{% trans "Access and manage reference documents" %}</p>
</div>
<div class="d-flex gap-2">
<a href="{% url 'references:search' %}" class="btn btn-outline-primary">
<i class="fas fa-search me-2"></i>{% trans "Search" %}
</a>
<a href="{% url 'references:folder_create' %}" class="btn btn-primary">
<i class="fas fa-folder-plus me-2"></i>{% trans "New Folder" %}
</a>
<a href="{% url 'references:document_create' %}" class="btn btn-success">
<i class="fas fa-upload me-2"></i>{% trans "Upload Document" %}
</a>
</div>
</div>
<!-- Statistics Cards -->
<div class="row mb-4">
<div class="col-md-6">
<div class="card">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="bg-primary bg-opacity-10 rounded-3 p-3">
<i class="fas fa-folder text-primary fa-2x"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<h6 class="text-muted mb-1">{% trans "Total Folders" %}</h6>
<h2 class="mb-0">{{ total_folders }}</h2>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="bg-success bg-opacity-10 rounded-3 p-3">
<i class="fas fa-file-alt text-success fa-2x"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<h6 class="text-muted mb-1">{% trans "Total Documents" %}</h6>
<h2 class="mb-0">{{ total_documents }}</h2>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<!-- Folders -->
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<h5 class="mb-0">{% trans "Folders" %}</h5>
</div>
<div class="card-body">
{% if folders %}
<div class="row">
{% for folder in folders %}
<div class="col-md-6 col-lg-4 mb-3">
<a href="{% url 'references:folder_view' folder.id %}" class="card h-100 text-decoration-none text-dark folder-card">
<div class="card-body">
<div class="d-flex align-items-center mb-2">
<div class="flex-shrink-0">
{% if folder.icon %}
<i class="{{ folder.icon }} fa-2x text-primary"></i>
{% else %}
<i class="fas fa-folder fa-2x text-primary"></i>
{% endif %}
</div>
<div class="flex-grow-1 ms-3">
<h6 class="mb-0">{{ folder.name }}</h6>
<small class="text-muted">{{ folder.document_count }} {% trans "documents" %}</small>
</div>
</div>
{% if folder.description %}
<p class="small text-muted mb-0">{{ folder.description|truncatewords:10 }}</p>
{% endif %}
</div>
</a>
</div>
{% empty %}
<p class="text-muted text-center py-5">
<i class="fas fa-folder-open fa-3x mb-3 d-block text-muted"></i>
{% trans "No folders yet. Create your first folder to get started." %}
</p>
{% endfor %}
</div>
{% else %}
<div class="text-center py-5">
<i class="fas fa-folder-open fa-3x mb-3 text-muted"></i>
<p class="text-muted mb-3">{% trans "No folders yet" %}</p>
<a href="{% url 'references:folder_create' %}" class="btn btn-primary">
<i class="fas fa-plus me-2"></i>{% trans "Create Folder" %}
</a>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Recent Documents -->
<div class="col-lg-4">
<div class="card">
<div class="card-header">
<h5 class="mb-0">{% trans "Recent Documents" %}</h5>
</div>
<div class="card-body">
{% if recent_documents %}
<div class="list-group list-group-flush">
{% for document in recent_documents %}
<a href="{% url 'references:document_view' document.id %}" class="list-group-item list-group-item-action text-decoration-none">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<i class="{{ document.get_file_icon }} fa-lg"></i>
</div>
<div class="flex-grow-1 ms-3">
<h6 class="mb-1 small">{{ document.title|truncatechars:40 }}</h6>
<small class="text-muted">{{ document.get_file_size_display }}</small>
</div>
</div>
</a>
{% endfor %}
</div>
<a href="{% url 'references:search' %}" class="btn btn-outline-primary btn-sm w-100 mt-3">
{% trans "View All Documents" %}
</a>
{% else %}
<p class="text-muted text-center py-4">
{% trans "No documents uploaded yet" %}
</p>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<style>
.folder-card:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
transform: translateY(-2px);
transition: all 0.2s ease;
}
</style>
{% endblock %}

View File

@ -0,0 +1,372 @@
{% extends "layouts/base.html" %}
{% load i18n %}
{% load static %}
{% block title %}{% if document %}{{ _("Edit Document") }}{% else %}{{ _("Upload Document") }}{% endif %} - PX360{% endblock %}
{% block extra_css %}
<style>
.form-section {
background: #fff;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 25px;
margin-bottom: 20px;
}
.form-section-title {
font-size: 1.1rem;
font-weight: 600;
color: #495057;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 2px solid #667eea;
}
.required-field::after {
content: " *";
color: #dc3545;
}
.file-upload-wrapper {
border: 2px dashed #dee2e6;
border-radius: 8px;
padding: 30px;
text-align: center;
transition: all 0.3s;
}
.file-upload-wrapper:hover {
border-color: #667eea;
background-color: #f8f9fa;
}
.current-file-info {
background: #e7f3ff;
border-left: 4px solid #0066cc;
padding: 15px;
margin-bottom: 15px;
border-radius: 4px;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid">
<!-- Page Header -->
<div class="mb-4">
{% if folder %}
<a href="{% url 'references:folder_view' folder.id %}" class="btn btn-outline-secondary btn-sm mb-3">
<i class="bi bi-arrow-left me-1"></i> {{ _("Back to Folder")}}
</a>
{% else %}
<a href="{% url 'references:dashboard' %}" class="btn btn-outline-secondary btn-sm mb-3">
<i class="bi bi-arrow-left me-1"></i> {{ _("Back to Dashboard")}}
</a>
{% endif %}
<h2 class="mb-1">
{% if document %}
<i class="bi bi-pencil-square text-primary me-2"></i>{{ _("Edit Document")}}
{% else %}
<i class="bi bi-upload text-primary me-2"></i>{{ _("Upload Document")}}
{% endif %}
</h2>
<p class="text-muted mb-0">
{% if document %}
{{ _("Update document information or upload a new version")}}
{% else %}
{{ _("Upload a new document to the reference library")}}
{% endif %}
</p>
</div>
<form method="post" enctype="multipart/form-data" id="documentForm">
{% csrf_token %}
<!-- Django Messages -->
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }} alert-dismissible fade show mb-4" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
{% endif %}
<!-- Form Errors -->
{% if form.non_field_errors %}
<div class="alert alert-danger mb-4">
<strong><i class="bi bi-exclamation-triangle me-2"></i>{% trans "Please fix the following errors:" %}</strong>
<ul class="mb-0 mt-2">
{% for error in form.non_field_errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
<div class="row">
<div class="col-lg-8">
<!-- Document File -->
<div class="form-section">
<h5 class="form-section-title">
<i class="bi bi-file-earmark me-2"></i>{{ _("Document File")}}
</h5>
{% if document %}
<div class="current-file-info">
<h6 class="mb-2"><i class="bi bi-file-earmark-text me-2"></i>{{ _("Current File")}}</h6>
<ul class="mb-0 small">
<li><strong>{{ _("Filename:") }}</strong> {{ document.filename }}</li>
<li><strong>{{ _("File Size:") }}</strong> {{ document.file_size|filesizeformat }}</li>
<li><strong>{{ _("Version:") }}</strong> {{ document.version }}</li>
<li><strong>{{ _("Uploaded:") }}</strong> {{ document.created_at|date:"M d, Y H:i" }}</li>
</ul>
</div>
{% endif %}
<div class="file-upload-wrapper">
<i class="bi bi-cloud-upload display-4 text-muted mb-3 d-block"></i>
<p class="mb-3">
<strong>{% trans "Drag & drop your file here" %}</strong><br>
<span class="text-muted">{% trans "or click to browse" %}</span>
</p>
<input type="file" name="file" class="form-control{% if form.file.errors %} is-invalid{% endif %}" id="fileInput"
accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.jpg,.jpeg,.png"
{% if not document %}required{% endif %}>
{% if form.file.errors %}
<div class="invalid-feedback d-block">
{% for error in form.file.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
<small class="form-text text-muted">
{% trans "Supported formats:" %} PDF, DOC, DOCX, XLS, XLSX, PPT, PPTX, TXT, JPG, PNG<br>
{% trans "Maximum file size:" %} 50 MB
</small>
</div>
{% if document %}
<div class="form-check mt-3">
<input class="form-check-input" type="checkbox" name="new_version" id="newVersionCheck">
<label class="form-check-label" for="newVersionCheck">
{% trans "Upload as new version (increment version number)" %}
</label>
</div>
{% endif %}
</div>
<!-- Document Details -->
<div class="form-section">
<h5 class="form-section-title">
<i class="bi bi-info-circle me-2"></i>{{ _("Document Information")}}
</h5>
<div class="mb-3">
<label class="form-label required-field">{% trans "Title (English)" %}</label>
<input type="text" name="title" class="form-control{% if form.title.errors %} is-invalid{% endif %}"
value="{% if form.title.value %}{{ form.title.value }}{% elif document %}{{ document.title }}{% endif %}"
placeholder="{% trans 'Enter document title in English' %}" required>
{% if form.title.errors %}
<div class="invalid-feedback d-block">
{% for error in form.title.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
</div>
<div class="mb-3">
<label class="form-label">{% trans "Title (Arabic)" %}</label>
<input type="text" name="title_ar" class="form-control"
value="{% if form.title_ar.value %}{{ form.title_ar.value }}{% elif document %}{{ document.title_ar }}{% endif %}"
placeholder="{% trans 'Enter document title in Arabic (optional)' %}">
</div>
<div class="mb-3">
<label class="form-label">{% trans "Description (English)" %}</label>
<textarea name="description" class="form-control" rows="4"
placeholder="{% trans 'Detailed description of the document' %}">{% if form.description.value %}{{ form.description.value }}{% elif document %}{{ document.description }}{% endif %}</textarea>
</div>
<div class="mb-3">
<label class="form-label">{% trans "Description (Arabic)" %}</label>
<textarea name="description_ar" class="form-control" rows="4"
placeholder="{% trans 'Arabic description (optional)' %}">{% if form.description_ar.value %}{{ form.description_ar.value }}{% elif document %}{{ document.description_ar }}{% endif %}</textarea>
</div>
<div class="mb-3">
<label class="form-label{% if not folder %} required-field{% endif %}">{% trans "Folder" %}</label>
<select name="folder" class="form-select{% if form.folder.errors %} is-invalid{% endif %}"
{% if not folder %}required{% endif %}>
<option value="">{{ _("Select folder")}}</option>
{% for folder_option in all_folders %}
<option value="{{ folder_option.id }}"
{% if document and document.folder_id == folder_option.id %}selected{% endif %}
{% if folder and folder.id == folder_option.id %}selected{% endif %}>
{{ folder_option.level_display }}{{ folder_option.name }}
{% if folder_option.name_ar %}/ {{ folder_option.name_ar }}{% endif %}
</option>
{% endfor %}
</select>
{% if form.folder.errors %}
<div class="invalid-feedback d-block">
{% for error in form.folder.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
</div>
<div class="mb-3">
<label class="form-label">{% trans "Tags" %}</label>
<input type="text" name="tags" class="form-control"
value="{% if form.tags.value %}{{ form.tags.value }}{% elif document %}{{ document.tags }}{% endif %}"
placeholder="{% trans 'Comma-separated tags (e.g., HR, Safety, 2024)' %}">
<small class="form-text text-muted">{{ _("Use tags to categorize and search documents easily")}}</small>
</div>
</div>
<!-- Access Control -->
<div class="form-section">
<h5 class="form-section-title">
<i class="bi bi-shield-lock me-2"></i>{{ _("Access Control")}}
</h5>
<div class="mb-3">
<label class="form-label">{% trans "Access Roles" %}</label>
{% for choice in form.access_roles %}
<div class="form-check">
{{ choice.tag }}
<label class="form-check-label" for="{{ choice.id_for_label }}">
{{ choice.choice_label }}
</label>
</div>
{% empty %}
<p class="text-muted small">{{ _("No access roles available")}}</p>
{% endfor %}
<small class="form-text text-muted">{{ _("Select roles that can access this document")}}</small>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="is_published" id="isPublishedCheck"
{% if not document or document.is_published %}checked{% endif %}>
<label class="form-check-label" for="isPublishedCheck">
{% trans "Publish this document (make it visible to others)" %}
</label>
</div>
</div>
</div>
<!-- Sidebar -->
<div class="col-lg-4">
<!-- Information -->
<div class="alert alert-info">
<h6 class="alert-heading">
<i class="bi bi-info-circle me-2"></i>{{ _("Document Guidelines")}}
</h6>
<p class="mb-0 small">
{{ _("Upload important documents, policies, procedures, and reference materials for easy access by your team.")}}
</p>
<hr class="my-2">
<ul class="mb-0 mt-2 small">
<li>{{ _("Use descriptive titles in both languages")}}</li>
<li>{{ _("Add clear descriptions")}}</li>
<li>{{ _("Choose the right document type")}}</li>
<li>{{ _("Set appropriate visibility levels")}}</li>
<li>{{ _("Use tags for easy searching")}}</li>
<li>{{ _("Upload new versions when updating")}}</li>
</ul>
</div>
<!-- Current Document Info (if editing) -->
{% if document %}
<div class="alert alert-secondary">
<h6 class="alert-heading">
<i class="bi bi-file-earmark me-2"></i>{{ _("Current Document")}}
</h6>
<ul class="mb-0 small">
<li><strong>{{ _("Created:") }}</strong> {{ document.created_at|date:"M d, Y H:i" }}</li>
<li><strong>{{ _("Updated:") }}</strong> {{ document.updated_at|date:"M d, Y H:i" }}</li>
<li><strong>{{ _("Version:") }}</strong> {{ document.version }}</li>
<li><strong>{{ _("File Size:") }}</strong> {{ document.file_size|filesizeformat }}</li>
</ul>
</div>
{% endif %}
<!-- Action Buttons -->
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary btn-lg">
<i class="bi bi-check-circle me-2"></i>
{% if document %}{{ _("Update Document")}}{% else %}{{ _("Upload Document")}}{% endif %}
</button>
{% if folder %}
<a href="{% url 'references:folder_view' folder.id %}" class="btn btn-outline-secondary">
<i class="bi bi-x-circle me-2"></i>{{ _("Cancel")}}
</a>
{% else %}
<a href="{% url 'references:dashboard' %}" class="btn btn-outline-secondary">
<i class="bi bi-x-circle me-2"></i>{{ _("Cancel")}}
</a>
{% endif %}
</div>
</div>
</div>
</form>
</div>
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('documentForm');
const fileInput = document.getElementById('fileInput');
const fileUploadWrapper = document.querySelector('.file-upload-wrapper');
// Drag and drop functionality
fileUploadWrapper.addEventListener('dragover', function(e) {
e.preventDefault();
this.style.borderColor = '#667eea';
this.style.backgroundColor = '#e7f3ff';
});
fileUploadWrapper.addEventListener('dragleave', function(e) {
e.preventDefault();
this.style.borderColor = '#dee2e6';
this.style.backgroundColor = '#fff';
});
fileUploadWrapper.addEventListener('drop', function(e) {
e.preventDefault();
this.style.borderColor = '#dee2e6';
this.style.backgroundColor = '#fff';
const files = e.dataTransfer.files;
if (files.length > 0) {
fileInput.files = files;
updateFileName(files[0].name);
}
});
// Handle file selection via click
fileInput.addEventListener('change', function(e) {
if (this.files.length > 0) {
updateFileName(this.files[0].name);
}
});
function updateFileName(fileName) {
const icon = fileUploadWrapper.querySelector('i');
const p = fileUploadWrapper.querySelector('p');
icon.className = 'bi bi-file-earmark-check display-4 text-success mb-3 d-block';
p.innerHTML = `<strong>${fileName}</strong><br><span class="text-muted">{% trans "File selected" %}</span>`;
}
// Form validation
form.addEventListener('submit', function(e) {
if (!form.checkValidity()) {
e.preventDefault();
e.stopPropagation();
}
form.classList.add('was-validated');
});
});
</script>
{% endblock %}

View File

@ -0,0 +1,213 @@
{% extends "layouts/base.html" %}
{% load i18n %}
{% block title %}{{ document.title }} - {% trans "Reference Section" %}{% endblock %}
{% block content %}
<div class="container-fluid px-4">
<!-- Page Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-1">
<li class="breadcrumb-item">
<a href="{% url 'references:dashboard' %}">{% trans "Reference Section" %}</a>
</li>
{% if document.folder %}
{% for folder in document.folder.get_full_path_folders %}
<li class="breadcrumb-item">
<a href="{% url 'references:folder_view' folder.id %}">{{ folder.name }}</a>
</li>
{% endfor %}
{% endif %}
<li class="breadcrumb-item active">{{ document.title }}</li>
</ol>
</nav>
<h1 class="h3 mb-0">
<i class="{{ document.get_file_icon }} me-2"></i>
{{ document.title }}
</h1>
<p class="text-muted small mb-0">{{ document.description }}</p>
</div>
<div class="d-flex gap-2">
<a href="{% url 'references:document_edit' document.id %}" class="btn btn-outline-primary">
<i class="fas fa-edit me-2"></i>{% trans "Edit" %}
</a>
<a href="{% url 'references:api_document_download' document.id %}" class="btn btn-primary">
<i class="fas fa-download me-2"></i>{% trans "Download" %}
</a>
</div>
</div>
<div class="row">
<!-- Document Details -->
<div class="col-lg-8">
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">{% trans "Document Details" %}</h5>
</div>
<div class="card-body">
<dl class="row">
<dt class="col-sm-4">{% trans "File Name" %}</dt>
<dd class="col-sm-8">{{ document.filename }}</dd>
<dt class="col-sm-4">{% trans "File Type" %}</dt>
<dd class="col-sm-8">
<span class="badge bg-light text-dark">{{ document.file_type|upper }}</span>
</dd>
<dt class="col-sm-4">{% trans "File Size" %}</dt>
<dd class="col-sm-8">{{ document.get_file_size_display }}</dd>
<dt class="col-sm-4">{% trans "Version" %}</dt>
<dd class="col-sm-8">
{% if document.is_latest_version %}
<span class="badge bg-success">{{ document.version }}</span>
{% else %}
<span class="badge bg-secondary">{{ document.version }}</span>
{% endif %}
</dd>
<dt class="col-sm-4">{% trans "Downloads" %}</dt>
<dd class="col-sm-8">{{ document.download_count }}</dd>
<dt class="col-sm-4">{% trans "Uploaded By" %}</dt>
<dd class="col-sm-8">
{% if document.uploaded_by %}
{{ document.uploaded_by.get_full_name }}
{% else %}
{% trans "Unknown" %}
{% endif %}
</dd>
<dt class="col-sm-4">{% trans "Created" %}</dt>
<dd class="col-sm-8">{{ document.created_at|date:"M d, Y H:i" }}</dd>
<dt class="col-sm-4">{% trans "Last Modified" %}</dt>
<dd class="col-sm-8">{{ document.updated_at|date:"M d, Y H:i" }}</dd>
{% if document.tags %}
<dt class="col-sm-4">{% trans "Tags" %}</dt>
<dd class="col-sm-8">
{% for tag in document.tags_list %}
<span class="badge bg-info text-white me-1">{{ tag }}</span>
{% endfor %}
</dd>
{% endif %}
</dl>
</div>
</div>
<!-- Version History -->
{% if versions %}
<div class="card">
<div class="card-header">
<h5 class="mb-0">{% trans "Version History" %}</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>{% trans "Version" %}</th>
<th>{% trans "File" %}</th>
<th>{% trans "Size" %}</th>
<th>{% trans "Created" %}</th>
</tr>
</thead>
<tbody>
{% for version in versions %}
<tr{% if version.is_latest_version %} class="table-light"{% endif %}>
<td>
{% if version.is_latest_version %}
<span class="badge bg-success">{{ version.version }}</span>
{% else %}
<span class="badge bg-secondary">{{ version.version }}</span>
{% endif %}
</td>
<td>{{ version.filename }}</td>
<td>{{ version.get_file_size_display }}</td>
<td>{{ version.created_at|date:"M d, Y H:i" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
</div>
<!-- Sidebar -->
<div class="col-lg-4">
<!-- Actions -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">{% trans "Actions" %}</h5>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<a href="{% url 'references:api_document_download' document.id %}" class="btn btn-primary">
<i class="fas fa-download me-2"></i>{% trans "Download Document" %}
</a>
<a href="{% url 'references:document_edit' document.id %}" class="btn btn-outline-primary">
<i class="fas fa-edit me-2"></i>{% trans "Edit Metadata" %}
</a>
{% if document.folder %}
<a href="{% url 'references:document_create_in_folder' folder_pk=document.folder.id %}" class="btn btn-outline-success">
<i class="fas fa-upload me-2"></i>{% trans "Upload New Version" %}
</a>
{% endif %}
<button type="button" class="btn btn-outline-danger" onclick="confirmDelete()">
<i class="fas fa-trash me-2"></i>{% trans "Delete Document" %}
</button>
</div>
</div>
</div>
<!-- Description -->
{% if document.description %}
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">{% trans "Description" %}</h5>
</div>
<div class="card-body">
<p class="mb-0">{{ document.description|linebreaks }}</p>
</div>
</div>
{% endif %}
<!-- Related Documents -->
{% if related_documents %}
<div class="card">
<div class="card-header">
<h5 class="mb-0">{% trans "Related Documents" %}</h5>
</div>
<div class="card-body">
<div class="list-group list-group-flush">
{% for related in related_documents %}
<a href="{% url 'references:document_view' related.id %}" class="list-group-item list-group-item-action text-decoration-none">
<i class="{{ related.get_file_icon }} me-2"></i>
{{ related.title|truncatechars:30 }}
</a>
{% endfor %}
</div>
</div>
</div>
{% endif %}
</div>
</div>
</div>
<form id="deleteForm" method="post" action="{% url 'references:document_delete' document.id %}" style="display: none;">
{% csrf_token %}
</form>
<script>
function confirmDelete() {
if (confirm('{% trans "Are you sure you want to delete this document?" %}')) {
document.getElementById('deleteForm').submit();
}
}
</script>
{% endblock %}

View File

@ -0,0 +1,256 @@
{% extends "layouts/base.html" %}
{% load i18n %}
{% load static %}
{% block title %}{% if folder %}{{ _("Edit Folder") }}{% else %}{{ _("New Folder") }}{% endif %} - PX360{% endblock %}
{% block extra_css %}
<style>
.form-section {
background: #fff;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 25px;
margin-bottom: 20px;
}
.form-section-title {
font-size: 1.1rem;
font-weight: 600;
color: #495057;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 2px solid #667eea;
}
.required-field::after {
content: " *";
color: #dc3545;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid">
<!-- Page Header -->
<div class="mb-4">
{% if folder %}
<a href="{% url 'references:folder_view' folder.id %}" class="btn btn-outline-secondary btn-sm mb-3">
<i class="bi bi-arrow-left me-1"></i> {{ _("Back to Folder")}}
</a>
{% elif parent %}
<a href="{% url 'references:folder_view' parent.id %}" class="btn btn-outline-secondary btn-sm mb-3">
<i class="bi bi-arrow-left me-1"></i> {{ _("Back to Folder")}}
</a>
{% else %}
<a href="{% url 'references:dashboard' %}" class="btn btn-outline-secondary btn-sm mb-3">
<i class="bi bi-arrow-left me-1"></i> {{ _("Back to Dashboard")}}
</a>
{% endif %}
<h2 class="mb-1">
{% if folder %}
<i class="bi bi-pencil-square text-primary me-2"></i>{{ _("Edit Folder")}}
{% else %}
<i class="bi bi-folder-plus text-primary me-2"></i>{{ _("New Folder")}}
{% endif %}
</h2>
<p class="text-muted mb-0">
{% if folder %}
{{ _("Update folder information")}}
{% else %}
{{ _("Create a new folder to organize your documents")}}
{% endif %}
</p>
</div>
<form method="post" id="folderForm">
{% csrf_token %}
<div class="row">
<div class="col-lg-8">
<!-- Folder Details -->
<div class="form-section">
<h5 class="form-section-title">
<i class="bi bi-info-circle me-2"></i>{{ _("Folder Information")}}
</h5>
<div class="mb-3">
<label class="form-label required-field">{% trans "Name (English)" %}</label>
<input type="text" name="name" class="form-control"
value="{% if folder %}{{ folder.name }}{% endif %}"
placeholder="{% trans 'Enter folder name in English' %}" required>
</div>
<div class="mb-3">
<label class="form-label">{% trans "Name (Arabic)" %}</label>
<input type="text" name="name_ar" class="form-control"
value="{% if folder %}{{ folder.name_ar }}{% endif %}"
placeholder="{% trans 'Enter folder name in Arabic (optional)' %}">
</div>
<div class="mb-3">
<label class="form-label">{% trans "Description (English)" %}</label>
<textarea name="description" class="form-control" rows="4"
placeholder="{% trans 'Optional description of this folder' %}">{% if folder %}{{ folder.description }}{% endif %}</textarea>
</div>
<div class="mb-3">
<label class="form-label">{% trans "Description (Arabic)" %}</label>
<textarea name="description_ar" class="form-control" rows="4"
placeholder="{% trans 'Optional Arabic description' %}">{% if folder %}{{ folder.description_ar }}{% endif %}</textarea>
</div>
<div class="mb-3">
<label class="form-label">{% trans "Parent Folder" %}</label>
<select name="parent" class="form-select">
<option value="">{{ _("Root Level (No Parent)")}}</option>
{% for folder_option in all_folders %}
{% if folder_option.id != folder.id %}
<option value="{{ folder_option.id }}"
{% if folder and folder.parent_id == folder_option.id %}selected{% endif %}
{% if parent and parent.id == folder_option.id %}selected{% endif %}>
{{ folder_option.level_display }}{{ folder_option.name }}
{% if folder_option.name_ar %}/ {{ folder_option.name_ar }}{% endif %}
</option>
{% endif %}
{% endfor %}
</select>
<small class="form-text text-muted">{{ _("Leave empty to create at root level")}}</small>
</div>
<div class="row">
<div class="col-md-4 mb-3">
<label class="form-label">{% trans "Icon" %}</label>
<input type="text" name="icon" class="form-control"
value="{% if folder %}{{ folder.icon }}{% endif %}"
placeholder="{% trans 'e.g., fa-folder, fa-file-pdf' %}">
<small class="form-text text-muted">{{ _("FontAwesome icon class")}}</small>
</div>
<div class="col-md-4 mb-3">
<label class="form-label">{% trans "Color" %}</label>
<input type="text" name="color" class="form-control"
value="{% if folder %}{{ folder.color }}{% endif %}"
placeholder="{% trans 'e.g., #007bff' %}">
<small class="form-text text-muted">{{ _("Hex color code")}}</small>
</div>
<div class="col-md-4 mb-3">
<label class="form-label">{% trans "Order" %}</label>
<input type="number" name="order" class="form-control"
value="{% if folder %}{{ folder.order }}{% else %}0{% endif %}"
min="0">
<small class="form-text text-muted">{{ _("Display order")}}</small>
</div>
</div>
</div>
<!-- Access Control -->
<div class="form-section">
<h5 class="form-section-title">
<i class="bi bi-shield-lock me-2"></i>{{ _("Access Control")}}
</h5>
<div class="mb-3">
<label class="form-label">{% trans "Access Roles" %}</label>
<div class="border p-3 rounded" style="max-height: 200px; overflow-y: auto;">
{% for choice in form.access_roles %}
<div class="form-check mb-2">
{{ choice.tag }}
<label class="form-check-label" for="{{ choice.id_for_label }}">
{{ choice.choice_label }}
</label>
</div>
{% endfor %}
</div>
<small class="form-text text-muted">
{{ _("Leave all unchecked to make folder accessible to all users")}}
</small>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="is_active" id="isActiveCheck"
{% if not folder or folder.is_active %}checked{% endif %}>
<label class="form-check-label" for="isActiveCheck">
{% trans "Active" %}
</label>
<small class="form-text text-muted d-block">
{{ _("Uncheck to hide this folder")}}
</small>
</div>
</div>
</div>
<!-- Sidebar -->
<div class="col-lg-4">
<!-- Information -->
<div class="alert alert-info">
<h6 class="alert-heading">
<i class="bi bi-info-circle me-2"></i>{{ _("Folder Organization")}}
</h6>
<p class="mb-0 small">
{{ _("Create folders to organize documents by category, department, or any structure that works for your team.")}}
</p>
<hr class="my-2">
<ul class="mb-0 mt-2 small">
<li>{{ _("Use meaningful names in both languages")}}</li>
<li>{{ _("Add descriptions for clarity")}}</li>
<li>{{ _("Set access roles to control visibility")}}</li>
<li>{{ _("Use icons and colors for visual organization")}}</li>
</ul>
</div>
<!-- Current Folder Info (if editing) -->
{% if folder %}
<div class="alert alert-secondary">
<h6 class="alert-heading">
<i class="bi bi-folder me-2"></i>{{ _("Current Folder")}}
</h6>
<ul class="mb-0 small">
<li><strong>{{ _("Created:") }} </strong>{{ folder.created_at|date:"M d, Y" }}</li>
<li><strong>{{ _("Last Modified:") }} </strong>{{ folder.updated_at|date:"M d, Y" }}</li>
<li><strong>{{ _("Documents:") }} </strong>{{ folder.documents.count }}</li>
<li><strong>{{ _("Subfolders:") }} </strong>{{ folder.subfolders.count }}</li>
</ul>
</div>
{% endif %}
<!-- Action Buttons -->
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary btn-lg">
<i class="bi bi-check-circle me-2"></i>
{% if folder %}{{ _("Update Folder")}}{% else %}{{ _("Create Folder")}}{% endif %}
</button>
{% if folder %}
<a href="{% url 'references:folder_view' folder.id %}" class="btn btn-outline-secondary">
<i class="bi bi-x-circle me-2"></i>{{ _("Cancel")}}
</a>
{% elif parent %}
<a href="{% url 'references:folder_view' parent.id %}" class="btn btn-outline-secondary">
<i class="bi bi-x-circle me-2"></i>{{ _("Cancel")}}
</a>
{% else %}
<a href="{% url 'references:dashboard' %}" class="btn btn-outline-secondary">
<i class="bi bi-x-circle me-2"></i>{{ _("Cancel")}}
</a>
{% endif %}
</div>
</div>
</div>
</form>
</div>
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('folderForm');
form.addEventListener('submit', function(e) {
if (!form.checkValidity()) {
e.preventDefault();
e.stopPropagation();
}
form.classList.add('was-validated');
});
});
</script>
{% endblock %}

View File

@ -0,0 +1,176 @@
{% extends "layouts/base.html" %}
{% load i18n %}
{% block title %}{% if current_folder %}{{ current_folder.name }}{% else %}{% trans "Folders" %}{% endif %} - {% trans "Reference Section" %}{% endblock %}
{% block content %}
<div class="container-fluid px-4">
<!-- Page Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-1">
<li class="breadcrumb-item">
<a href="{% url 'references:dashboard' %}">{% trans "Reference Section" %}</a>
</li>
{% for item in breadcrumb %}
<li class="breadcrumb-item{% if forloop.last %} active{% endif %}">
{% if not forloop.last %}
<a href="{% url 'references:folder_view' item.id %}">{{ item.name }}</a>
{% else %}
{{ item.name }}
{% endif %}
</li>
{% endfor %}
</ol>
</nav>
{% if current_folder %}
<h1 class="h3 mb-0">{{ current_folder.name }}</h1>
<p class="text-muted small mb-0">{{ current_folder.description }}</p>
{% else %}
<h1 class="h3 mb-0">{% trans "Folders" %}</h1>
{% endif %}
</div>
<div class="d-flex gap-2">
<a href="{% url 'references:search' %}" class="btn btn-outline-primary">
<i class="fas fa-search me-2"></i>{% trans "Search" %}
</a>
{% if current_folder %}
<a href="{% url 'references:folder_create_in_parent' parent_pk=current_folder.id %}" class="btn btn-primary">
<i class="fas fa-folder-plus me-2"></i>{% trans "New Folder" %}
</a>
{% else %}
<a href="{% url 'references:folder_create' %}" class="btn btn-primary">
<i class="fas fa-folder-plus me-2"></i>{% trans "New Folder" %}
</a>
{% endif %}
{% if current_folder %}
<a href="{% url 'references:document_create_in_folder' folder_pk=current_folder.id %}" class="btn btn-success">
<i class="fas fa-upload me-2"></i>{% trans "Upload Document" %}
</a>
{% else %}
<a href="{% url 'references:document_create' %}" class="btn btn-success">
<i class="fas fa-upload me-2"></i>{% trans "Upload Document" %}
</a>
{% endif %}
</div>
</div>
<!-- Subfolders -->
{% if subfolders %}
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">{% trans "Folders" %}</h5>
</div>
<div class="card-body">
<div class="row">
{% for folder in subfolders %}
<div class="col-md-4 col-lg-3 mb-3">
<a href="{% url 'references:folder_view' folder.id %}" class="card h-100 text-decoration-none text-dark folder-card">
<div class="card-body">
<div class="d-flex align-items-center mb-2">
<div class="flex-shrink-0">
{% if folder.icon %}
<i class="{{ folder.icon }} fa-2x text-primary"></i>
{% else %}
<i class="fas fa-folder fa-2x text-primary"></i>
{% endif %}
</div>
<div class="flex-grow-1 ms-3">
<h6 class="mb-0">{{ folder.name }}</h6>
<small class="text-muted">{{ folder.document_count }} {% trans "documents" %}</small>
</div>
</div>
{% if folder.description %}
<p class="small text-muted mb-0">{{ folder.description|truncatewords:8 }}</p>
{% endif %}
</div>
</a>
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
<!-- Documents -->
<div class="card">
<div class="card-header">
<h5 class="mb-0">{% trans "Documents" %}</h5>
</div>
<div class="card-body">
{% if documents %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Type" %}</th>
<th>{% trans "Size" %}</th>
<th>{% trans "Version" %}</th>
<th>{% trans "Downloads" %}</th>
<th>{% trans "Modified" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for document in documents %}
<tr>
<td>
<a href="{% url 'references:document_view' document.id %}" class="text-decoration-none">
<i class="{{ document.get_file_icon }} me-2"></i>
{{ document.title }}
</a>
</td>
<td>
<span class="badge bg-light text-dark">{{ document.file_type|upper }}</span>
</td>
<td>{{ document.get_file_size_display }}</td>
<td>
{% if document.is_latest_version %}
<span class="badge bg-success">{{ document.version }}</span>
{% else %}
<span class="badge bg-secondary">{{ document.version }}</span>
{% endif %}
</td>
<td>{{ document.download_count }}</td>
<td>{{ document.updated_at|date:"M d, Y" }}</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
<a href="{% url 'references:api_document_download' document.id %}" class="btn btn-outline-primary" title="{% trans "Download" %}">
<i class="fas fa-download"></i>
</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5">
<i class="fas fa-file-alt fa-3x mb-3 text-muted"></i>
<p class="text-muted mb-3">{% trans "No documents in this folder" %}</p>
{% if current_folder %}
<a href="{% url 'references:document_create_in_folder' folder_pk=current_folder.id %}" class="btn btn-primary">
<i class="fas fa-upload me-2"></i>{% trans "Upload Document" %}
</a>
{% else %}
<a href="{% url 'references:document_create' %}" class="btn btn-primary">
<i class="fas fa-upload me-2"></i>{% trans "Upload Document" %}
</a>
{% endif %}
</div>
{% endif %}
</div>
</div>
</div>
<style>
.folder-card:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
transform: translateY(-2px);
transition: all 0.2s ease;
}
</style>
{% endblock %}

View File

@ -0,0 +1,195 @@
{% extends "layouts/base.html" %}
{% load i18n %}
{% block title %}{% trans "Search Documents" %} - {% trans "Reference Section" %}{% endblock %}
{% block content %}
<div class="container-fluid px-4">
<!-- Page Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-1">
<li class="breadcrumb-item">
<a href="{% url 'references:dashboard' %}">{% trans "Reference Section" %}</a>
</li>
<li class="breadcrumb-item active">{% trans "Search" %}</li>
</ol>
</nav>
<h1 class="h3 mb-0">{% trans "Search Documents" %}</h1>
</div>
<div>
<a href="{% url 'references:dashboard' %}" class="btn btn-outline-primary">
<i class="fas fa-arrow-left me-2"></i>{% trans "Back to Dashboard" %}
</a>
</div>
</div>
<!-- Search Form -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">{% trans "Filter Documents" %}</h5>
</div>
<div class="card-body">
<form method="get" class="row g-3">
<div class="col-md-4">
<label for="{{ form.search.id_for_label }}" class="form-label">{{ form.search.label }}</label>
{{ form.search }}
</div>
<div class="col-md-3">
<label for="{{ form.folder.id_for_label }}" class="form-label">{{ form.folder.label }}</label>
{{ form.folder }}
</div>
<div class="col-md-2">
<label for="{{ form.file_type.id_for_label }}" class="form-label">{{ form.file_type.label }}</label>
{{ form.file_type }}
</div>
<div class="col-md-3">
<label for="{{ form.tags.id_for_label }}" class="form-label">{{ form.tags.label }}</label>
{{ form.tags }}
</div>
<div class="col-12">
<button type="submit" class="btn btn-primary">
<i class="fas fa-search me-2"></i>{% trans "Search" %}
</button>
<a href="{% url 'references:search' %}" class="btn btn-outline-secondary">
<i class="fas fa-times me-2"></i>{% trans "Clear" %}
</a>
</div>
</form>
</div>
</div>
<!-- Results -->
<div class="card">
<div class="card-header">
<div class="d-flex justify-content-between align-items-center">
<h5 class="mb-0">
{% trans "Results" %}
{% if page_obj %}
<span class="badge bg-primary ms-2">{{ page_obj.paginator.count }}</span>
{% endif %}
</h5>
</div>
</div>
<div class="card-body">
{% if documents %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Folder" %}</th>
<th>{% trans "Type" %}</th>
<th>{% trans "Size" %}</th>
<th>{% trans "Version" %}</th>
<th>{% trans "Downloads" %}</th>
<th>{% trans "Modified" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for document in documents %}
<tr>
<td>
<a href="{% url 'references:document_view' document.id %}" class="text-decoration-none">
<i class="{{ document.get_file_icon }} me-2"></i>
{{ document.title }}
</a>
{% if document.description %}
<br><small class="text-muted">{{ document.description|truncatechars:50 }}</small>
{% endif %}
</td>
<td>
{% if document.folder %}
<a href="{% url 'references:folder_view' document.folder.id %}" class="text-decoration-none">
{{ document.folder.name }}
</a>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
<span class="badge bg-light text-dark">{{ document.file_type|upper }}</span>
</td>
<td>{{ document.get_file_size_display }}</td>
<td>
{% if document.is_latest_version %}
<span class="badge bg-success">{{ document.version }}</span>
{% else %}
<span class="badge bg-secondary">{{ document.version }}</span>
{% endif %}
</td>
<td>{{ document.download_count }}</td>
<td>{{ document.updated_at|date:"M d, Y" }}</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
<a href="{% url 'references:api_document_download' document.id %}" class="btn btn-outline-primary" title="{% trans "Download" %}">
<i class="fas fa-download"></i>
</a>
<a href="{% url 'references:document_view' document.id %}" class="btn btn-outline-secondary" title="{% trans "View" %}">
<i class="fas fa-eye"></i>
</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if page_obj.has_other_pages %}
<nav aria-label="Page navigation" class="mt-3">
<ul class="pagination justify-content-center">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page=1{% for key, value in request.GET.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}">
{% trans "First" %}
</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% for key, value in request.GET.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}">
{% trans "Previous" %}
</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 }}{% for key, value in request.GET.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}">{{ 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 }}{% for key, value in request.GET.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}">
{% trans "Next" %}
</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}{% for key, value in request.GET.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}">
{% trans "Last" %}
</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
<div class="text-center py-5">
<i class="fas fa-search fa-3x mb-3 text-muted"></i>
<p class="text-muted mb-3">{% trans "No documents found matching your search criteria" %}</p>
<a href="{% url 'references:search' %}" class="btn btn-outline-primary">
<i class="fas fa-times me-2"></i>{% trans "Clear Filters" %}
</a>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,142 @@
{% extends 'layouts/base.html' %}
{% load i18n %}
{% block title %}{% trans "Upload Evidence" %}{% endblock %}
{% block content %}
<div class="container-fluid px-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-0">{% trans "Upload Evidence Attachment" %}</h1>
<p class="text-muted mb-0">{{ compliance.standard.code }} - {{ compliance.standard.title }}</p>
</div>
<a href="{% url 'standards:standard_compliance_update' compliance_id=compliance.id %}"
class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-2"></i>{% trans "Back to Assessment" %}
</a>
</div>
<div class="row">
<div class="col-lg-8">
<div class="card mb-4">
<div class="card-header">
<h5 class="card-title mb-0">{% trans "Upload New Attachment" %}</h5>
</div>
<div class="card-body">
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="mb-3">
<label for="{{ form.file.id_for_label }}" class="form-label">
{% trans "File" %}
</label>
{{ form.file }}
{% if form.file.errors %}
<div class="text-danger">{{ form.file.errors }}</div>
{% endif %}
<small class="form-text text-muted">
{% trans "Accepted formats: PDF, DOC, DOCX, XLS, XLSX, JPG, PNG" %}
</small>
</div>
<div class="mb-3">
<label for="{{ form.description.id_for_label }}" class="form-label">
{% trans "Description" %}
</label>
{{ form.description }}
{% if form.description.errors %}
<div class="text-danger">{{ form.description.errors }}</div>
{% endif %}
</div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-upload me-2"></i>{% trans "Upload" %}
</button>
<a href="{% url 'standards:standard_compliance_update' compliance_id=compliance.id %}"
class="btn btn-outline-secondary ms-2">
{% trans "Cancel" %}
</a>
</form>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">{% trans "Compliance Details" %}</h5>
</div>
<div class="card-body">
<table class="table table-sm">
<tr>
<th width="40%">{% trans "Standard" %}</th>
<td>{{ compliance.standard.code }}</td>
</tr>
<tr>
<th>{% trans "Title" %}</th>
<td>{{ compliance.standard.title }}</td>
</tr>
<tr>
<th>{% trans "Department" %}</th>
<td>{{ compliance.department.name }}</td>
</tr>
<tr>
<th>{% trans "Status" %}</th>
<td>
<span class="badge {% if compliance.status == 'met' %}bg-success{% elif compliance.status == 'partially_met' %}bg-warning{% elif compliance.status == 'not_met' %}bg-danger{% else %}bg-secondary{% endif %}">
{{ compliance.get_status_display }}
</span>
</td>
</tr>
</table>
</div>
</div>
</div>
</div>
<!-- Existing Attachments -->
{% if compliance.attachments.all %}
<div class="card mt-4">
<div class="card-header">
<h5 class="card-title mb-0">{% trans "Existing Attachments" %}</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>{% trans "File Name" %}</th>
<th>{% trans "Description" %}</th>
<th>{% trans "Uploaded By" %}</th>
<th>{% trans "Date" %}</th>
<th>{% trans "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for attachment in compliance.attachments.all %}
<tr>
<td>
<i class="fas fa-file me-2"></i>{{ attachment.filename }}
</td>
<td>{{ attachment.description|default:"-" }}</td>
<td>{{ attachment.uploaded_by.get_full_name }}</td>
<td>{{ attachment.created_at|date:"Y-m-d H:i" }}</td>
<td>
<a href="{{ attachment.file.url }}" target="_blank"
class="btn btn-sm btn-outline-primary">
<i class="fas fa-download"></i>
</a>
<a href="{% url 'standards:attachment_delete' pk=attachment.id %}"
class="btn btn-sm btn-outline-danger">
<i class="fas fa-trash"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
</div>
{% endblock %}

View File

@ -0,0 +1,164 @@
{% extends 'layouts/base.html' %}
{% load i18n %}
{% block title %}{% trans "Update Compliance" %}{% endblock %}
{% block content %}
<div class="container-fluid px-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-0">{% trans "Update Compliance Assessment" %}</h1>
<p class="text-muted mb-0">{{ compliance.standard.code }} - {{ compliance.standard.title }}</p>
</div>
<a href="{% url 'standards:department_standards' pk=compliance.department.id %}"
class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-2"></i>{% trans "Back to Standards" %}
</a>
</div>
<div class="row">
<div class="col-lg-8">
<!-- Standard Details -->
<div class="card mb-4">
<div class="card-header">
<h5 class="card-title mb-0">{% trans "Standard Details" %}</h5>
</div>
<div class="card-body">
<table class="table table-sm">
<tr>
<th width="30%">{% trans "Code" %}</th>
<td>{{ compliance.standard.code }}</td>
</tr>
<tr>
<th>{% trans "Title" %}</th>
<td>{{ compliance.standard.title }}</td>
</tr>
<tr>
<th>{% trans "Source" %}</th>
<td>{{ compliance.standard.source.name }}</td>
</tr>
<tr>
<th>{% trans "Category" %}</th>
<td>{{ compliance.standard.category.name }}</td>
</tr>
<tr>
<th>{% trans "Description" %}</th>
<td>{{ compliance.standard.description|linebreaks }}</td>
</tr>
</table>
</div>
</div>
<!-- Compliance Form -->
<div class="card mb-4">
<div class="card-header">
<h5 class="card-title mb-0">{% trans "Compliance Assessment" %}</h5>
</div>
<div class="card-body">
<form method="post">
{% csrf_token %}
<div class="mb-3">
<label for="{{ form.status.id_for_label }}" class="form-label">
{% trans "Status" %}
</label>
{{ form.status }}
{% if form.status.errors %}
<div class="text-danger">{{ form.status.errors }}</div>
{% endif %}
</div>
<div class="mb-3">
<label for="{{ form.last_assessed_date.id_for_label }}" class="form-label">
{% trans "Last Assessed Date" %}
</label>
{{ form.last_assessed_date }}
{% if form.last_assessed_date.errors %}
<div class="text-danger">{{ form.last_assessed_date.errors }}</div>
{% endif %}
</div>
<div class="mb-3">
<label for="{{ form.assessor.id_for_label }}" class="form-label">
{% trans "Assessor" %}
</label>
{{ form.assessor }}
{% if form.assessor.errors %}
<div class="text-danger">{{ form.assessor.errors }}</div>
{% endif %}
</div>
<div class="mb-3">
<label for="{{ form.notes.id_for_label }}" class="form-label">
{% trans "Assessment Notes" %}
</label>
{{ form.notes }}
{% if form.notes.errors %}
<div class="text-danger">{{ form.notes.errors }}</div>
{% endif %}
</div>
<div class="mb-3">
<label for="{{ form.evidence_summary.id_for_label }}" class="form-label">
{% trans "Evidence Summary" %}
</label>
{{ form.evidence_summary }}
{% if form.evidence_summary.errors %}
<div class="text-danger">{{ form.evidence_summary.errors }}</div>
{% endif %}
</div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-2"></i>{% trans "Save Assessment" %}
</button>
</form>
</div>
</div>
</div>
<div class="col-lg-4">
<!-- Department Info -->
<div class="card mb-4">
<div class="card-header">
<h5 class="card-title mb-0">{% trans "Department" %}</h5>
</div>
<div class="card-body">
<p><strong>{{ compliance.department.name }}</strong></p>
{% if compliance.department.hospital %}
<p class="text-muted mb-0">{{ compliance.department.hospital.name }}</p>
{% endif %}
</div>
</div>
<!-- Evidence Attachments -->
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">{% trans "Evidence Attachments" %}</h5>
<a href="{% url 'standards:attachment_upload' compliance_id=compliance.id %}"
class="btn btn-sm btn-success">
<i class="fas fa-upload me-1"></i>{% trans "Upload" %}
</a>
</div>
<div class="card-body">
{% if compliance.attachments.all %}
<ul class="list-group">
{% for attachment in compliance.attachments.all %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<div>
<i class="fas fa-file me-2"></i>
{{ attachment.filename }}
</div>
<small class="text-muted">
{{ attachment.uploaded_at|date:"Y-m-d" }}
</small>
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-muted text-center mb-0">{% trans "No attachments" %}</p>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,156 @@
{% extends 'layouts/base.html' %}
{% load i18n %}
{% block title %}{% trans "Standards Compliance Dashboard" %}{% endblock %}
{% block content %}
<div class="container-fluid px-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-0">{% trans "Standards Compliance Dashboard" %}</h1>
<p class="text-muted mb-0">{{ hospital.name }}</p>
</div>
<div>
<a href="{% url 'standards:search' %}" class="btn btn-primary">
<i class="fas fa-search me-2"></i>{% trans "Search Standards" %}
</a>
</div>
</div>
<!-- Statistics Cards -->
<div class="row mb-4">
<div class="col-md-2 mb-3">
<div class="card bg-info text-white h-100">
<div class="card-body">
<h6 class="card-title">{% trans "Total Standards" %}</h6>
<h2 class="mb-0">{{ stats.total_standards }}</h2>
</div>
</div>
</div>
<div class="col-md-2 mb-3">
<div class="card bg-success text-white h-100">
<div class="card-body">
<h6 class="card-title">{% trans "Met" %}</h6>
<h2 class="mb-0">{{ stats.met }}</h2>
</div>
</div>
</div>
<div class="col-md-2 mb-3">
<div class="card bg-warning text-dark h-100">
<div class="card-body">
<h6 class="card-title">{% trans "Partially Met" %}</h6>
<h2 class="mb-0">{{ stats.partially_met }}</h2>
</div>
</div>
</div>
<div class="col-md-2 mb-3">
<div class="card bg-danger text-white h-100">
<div class="card-body">
<h6 class="card-title">{% trans "Not Met" %}</h6>
<h2 class="mb-0">{{ stats.not_met }}</h2>
</div>
</div>
</div>
<div class="col-md-2 mb-3">
<div class="card bg-secondary text-white h-100">
<div class="card-body">
<h6 class="card-title">{% trans "Not Assessed" %}</h6>
<h2 class="mb-0">{{ stats.not_assessed }}</h2>
</div>
</div>
</div>
<div class="col-md-2 mb-3">
<div class="card bg-primary text-white h-100">
<div class="card-body">
<h6 class="card-title">{% trans "Departments" %}</h6>
<h2 class="mb-0">{{ stats.total_departments }}</h2>
</div>
</div>
</div>
</div>
<!-- Departments List -->
<div class="card mb-4">
<div class="card-header">
<h5 class="card-title mb-0">{% trans "Departments" %}</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>{% trans "Department" %}</th>
<th>{% trans "Status" %}</th>
<th>{% trans "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for department in departments %}
<tr>
<td>{{ department.name }}</td>
<td>
<span class="badge bg-info">
<i class="fas fa-folder-open me-1"></i>
{% trans "View Standards" %}
</span>
</td>
<td>
<a href="{% url 'standards:department_standards' pk=department.id %}"
class="btn btn-sm btn-primary">
<i class="fas fa-list me-1"></i>{% trans "View" %}
</a>
</td>
</tr>
{% empty %}
<tr>
<td colspan="3" class="text-center text-muted">
{% trans "No departments found" %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Recent Updates -->
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">{% trans "Recent Compliance Updates" %}</h5>
</div>
<div class="card-body">
{% if recent_updates %}
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>{% trans "Standard" %}</th>
<th>{% trans "Department" %}</th>
<th>{% trans "Status" %}</th>
<th>{% trans "Last Updated" %}</th>
</tr>
</thead>
<tbody>
{% for update in recent_updates %}
<tr>
<td>{{ update.standard.code }}</td>
<td>{{ update.department.name }}</td>
<td>
<span class="badge {% if update.status == 'met' %}bg-success{% elif update.status == 'partially_met' %}bg-warning{% elif update.status == 'not_met' %}bg-danger{% else %}bg-secondary{% endif %}">
{{ update.get_status_display }}
</span>
</td>
<td>{{ update.updated_at|date:"Y-m-d H:i" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted text-center">{% trans "No recent updates" %}</p>
{% endif %}
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,296 @@
{% extends 'layouts/base.html' %}
{% load i18n %}
{% load standards_filters %}
{% block title %}{% trans "Department Standards" %} - {{ department.name }}{% endblock %}
{% block content %}
<div class="container-fluid px-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-0">{{ department.name }}</h1>
<p class="text-muted mb-0">{% trans "Standards Compliance" %}</p>
</div>
<div class="d-flex gap-2">
{% if is_px_admin %}
<a href="{% url 'standards:standard_create' department_id=department.id %}" class="btn btn-primary">
<i class="fas fa-plus me-2"></i>{% trans "Add Standard" %}
</a>
{% endif %}
<a href="{% url 'standards:dashboard' %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-2"></i>{% trans "Back to Dashboard" %}
</a>
</div>
</div>
<!-- Search -->
<div class="card mb-4">
<div class="card-body">
<div class="row">
<div class="col-md-12">
<input type="text" class="form-control" id="searchInput"
placeholder="{% trans 'Search standards...' %}">
</div>
</div>
</div>
</div>
<!-- Standards Table -->
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">{% trans "Standards List" %}</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover" id="standardsTable">
<thead>
<tr>
<th>{% trans "Code" %}</th>
<th>{% trans "Title" %}</th>
<th>{% trans "Status" %}</th>
<th>{% trans "Evidence" %}</th>
<th>{% trans "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for item in standards_data %}
<tr data-standard-id="{{ item.standard.id }}"
data-compliance-id="{% if item.compliance %}{{ item.compliance.id }}{% endif %}">
<td><strong>{{ item.standard.code }}</strong></td>
<td>
<a href="{% url 'standards:standard_detail' pk=item.standard.id %}">
{{ item.standard.title }}
</a>
</td>
<td>
{% if item.compliance %}
<span class="badge {% if item.compliance.status == 'met' %}bg-success{% elif item.compliance.status == 'partially_met' %}bg-warning{% elif item.compliance.status == 'not_met' %}bg-danger{% else %}bg-secondary{% endif %}">
{{ item.compliance.get_status_display }}
</span>
{% else %}
<span class="badge bg-secondary">{% trans "Not Assessed" %}</span>
{% endif %}
</td>
<td>
<span class="badge bg-info">
<i class="fas fa-paperclip me-1"></i>
{{ item.attachment_count }}
</span>
</td>
<td>
{% if item.compliance %}
<button class="btn btn-sm btn-primary"
onclick="openAssessModal('{{ item.compliance.id }}')">
<i class="fas fa-edit me-1"></i>{% trans "Assess" %}
</button>
{% else %}
<button class="btn btn-sm btn-success"
onclick="createAndAssess('{{ item.standard.id }}')">
<i class="fas fa-plus me-1"></i>{% trans "Assess" %}
</button>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="5" class="text-center text-muted">
{% trans "No standards found" %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Compliance Assessment Modal -->
<div class="modal fade" id="assessmentModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{% trans "Compliance Assessment" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form id="assessmentForm">
<div class="modal-body">
<input type="hidden" id="complianceId" name="compliance_id">
<div class="mb-3">
<label for="status" class="form-label">
{% trans "Compliance Status" %} <span class="text-danger">*</span>
</label>
<select class="form-select" id="status" name="status" required>
<option value="not_assessed">{% trans "Not Assessed" %}</option>
<option value="met">{% trans "Met" %}</option>
<option value="partially_met">{% trans "Partially Met" %}</option>
<option value="not_met">{% trans "Not Met" %}</option>
</select>
</div>
<div class="mb-3">
<label for="last_assessed_date" class="form-label">
{% trans "Assessment Date" %}
</label>
<input type="date" class="form-control" id="last_assessed_date" name="last_assessed_date">
<small class="form-text text-muted">
{% trans "Auto-filled with today's date" %}
</small>
</div>
<div class="mb-3">
<label for="assessor" class="form-label">{% trans "Assessor" %}</label>
<input type="text" class="form-control" id="assessor" name="assessor" readonly>
</div>
<div class="mb-3">
<label for="notes" class="form-label">{% trans "Notes" %}</label>
<textarea class="form-control" id="notes" name="notes" rows="3"
placeholder="{% trans 'Add any notes about the assessment...' %}"></textarea>
</div>
<div class="mb-3">
<label for="evidence_summary" class="form-label">{% trans "Evidence Summary" %}</label>
<textarea class="form-control" id="evidence_summary" name="evidence_summary" rows="3"
placeholder="{% trans 'Summarize the evidence supporting this assessment...' %}"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
{% trans "Cancel" %}
</button>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-2"></i>{% trans "Save Assessment" %}
</button>
</div>
</form>
</div>
</div>
</div>
<script>
let modalInstance = null;
document.addEventListener('DOMContentLoaded', function() {
// Initialize Bootstrap modal
const modalElement = document.getElementById('assessmentModal');
if (modalElement) {
modalInstance = new bootstrap.Modal(modalElement);
}
// Set today's date
const today = new Date().toISOString().split('T')[0];
document.getElementById('last_assessed_date').value = today;
document.getElementById('assessor').value = '{{ user.get_full_name|default:user.username }}';
// Filter functionality
const searchInput = document.getElementById('searchInput');
const table = document.getElementById('standardsTable');
function filterTable() {
const searchText = searchInput.value.toLowerCase();
const rows = table.querySelectorAll('tbody tr');
rows.forEach(row => {
const text = row.textContent.toLowerCase();
const matchesSearch = text.includes(searchText);
if (matchesSearch) {
row.style.display = '';
} else {
row.style.display = 'none';
}
});
}
searchInput.addEventListener('input', filterTable);
// Form submission
const form = document.getElementById('assessmentForm');
form.addEventListener('submit', function(e) {
e.preventDefault();
submitAssessment();
});
});
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
function createAndAssess(standardId) {
// Create compliance record first
fetch(`/standards/api/compliance/create/`, {
method: 'POST',
headers: {
'X-CSRFToken': getCookie('csrftoken'),
'Content-Type': 'application/json',
},
body: JSON.stringify({
standard_id: standardId,
department_id: '{{ department.id }}'
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Open modal with new compliance ID
openAssessModal(data.compliance_id);
} else {
alert('{% trans "Error creating compliance record" %}: ' + (data.error || ''));
}
})
.catch(error => {
alert('{% trans "Error creating compliance record" %}: ' + error);
});
}
function openAssessModal(complianceId) {
document.getElementById('complianceId').value = complianceId;
modalInstance.show();
}
function submitAssessment() {
const form = document.getElementById('assessmentForm');
const formData = new FormData(form);
fetch(`/standards/api/compliance/update/`, {
method: 'POST',
headers: {
'X-CSRFToken': getCookie('csrftoken'),
'Content-Type': 'application/json',
},
body: JSON.stringify({
compliance_id: formData.get('compliance_id'),
status: formData.get('status'),
notes: formData.get('notes'),
evidence_summary: formData.get('evidence_summary'),
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
modalInstance.hide();
// Reload page to show updated status
location.reload();
} else {
alert('{% trans "Error updating compliance" %}: ' + (data.error || ''));
}
})
.catch(error => {
alert('{% trans "Error updating compliance" %}: ' + error);
});
}
</script>
{% endblock %}

View File

@ -0,0 +1,129 @@
{% extends 'layouts/base.html' %}
{% load i18n %}
{% block title %}{% trans "Search Standards" %}{% endblock %}
{% block content %}
<div class="container-fluid px-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-0">{% trans "Search Standards" %}</h1>
<p class="text-muted mb-0">{{ hospital.name }}</p>
</div>
<a href="{% url 'standards:dashboard' %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-2"></i>{% trans "Back to Dashboard" %}
</a>
</div>
<!-- Search Form -->
<div class="card mb-4">
<div class="card-body">
<form method="get" class="row g-3">
<div class="col-md-6">
<label for="q" class="form-label">{% trans "Search" %}</label>
<input type="text" class="form-control" name="q" id="q"
value="{{ query }}" placeholder="{% trans 'Search by code, title, or description...' %}">
</div>
<div class="col-md-3">
<label for="source" class="form-label">{% trans "Source" %}</label>
<select class="form-select" name="source" id="source">
<option value="">{% trans "All Sources" %}</option>
{% for source in sources %}
<option value="{{ source.id }}" {% if source_filter == source.id|stringformat:"s" %}selected{% endif %}>
{{ source.name }}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-3">
<label for="category" class="form-label">{% trans "Category" %}</label>
<select class="form-select" name="category" id="category">
<option value="">{% trans "All Categories" %}</option>
{% for category in categories %}
<option value="{{ category.id }}" {% if category_filter == category.id|stringformat:"s" %}selected{% endif %}>
{{ category.name }}
</option>
{% endfor %}
</select>
</div>
<div class="col-12">
<button type="submit" class="btn btn-primary">
<i class="fas fa-search me-2"></i>{% trans "Search" %}
</button>
<a href="{% url 'standards:search' %}" class="btn btn-outline-secondary ms-2">
{% trans "Clear" %}
</a>
</div>
</form>
</div>
</div>
<!-- Results -->
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">
{% if query or source_filter or category_filter %}
{% trans "Search Results" %} ({{ standards.count }})
{% else %}
{% trans "All Standards" %} ({{ standards.count }})
{% endif %}
</h5>
</div>
<div class="card-body">
{% if standards %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>{% trans "Code" %}</th>
<th>{% trans "Title" %}</th>
<th>{% trans "Source" %}</th>
<th>{% trans "Category" %}</th>
<th>{% trans "Department" %}</th>
<th>{% trans "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for standard in standards %}
<tr>
<td><strong>{{ standard.code }}</strong></td>
<td>
<a href="{% url 'standards:standard_detail' pk=standard.id %}">
{{ standard.title }}
</a>
</td>
<td>{{ standard.source.name }}</td>
<td>{{ standard.category.name }}</td>
<td>
{% if standard.department %}
{{ standard.department.name }}
{% else %}
<em>{% trans "All Departments" %}</em>
{% endif %}
</td>
<td>
<a href="{% url 'standards:standard_detail' pk=standard.id %}"
class="btn btn-sm btn-primary">
<i class="fas fa-eye me-1"></i>{% trans "View" %}
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5">
<i class="fas fa-search fa-3x text-muted mb-3"></i>
<p class="text-muted">{% trans "No standards found" %}</p>
{% if query or source_filter or category_filter %}
<a href="{% url 'standards:search' %}" class="btn btn-outline-primary">
{% trans "Clear Filters" %}
</a>
{% endif %}
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,170 @@
{% extends 'layouts/base.html' %}
{% load i18n %}
{% block title %}{{ standard.code }} - {% trans "Standard Details" %}{% endblock %}
{% block content %}
<div class="container-fluid px-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-0">{{ standard.code }}</h1>
<p class="text-muted mb-0">{{ standard.title }}</p>
</div>
<a href="{% url 'standards:dashboard' %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-2"></i>{% trans "Back to Dashboard" %}
</a>
</div>
<!-- Standard Information -->
<div class="row mb-4">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">{% trans "Standard Information" %}</h5>
</div>
<div class="card-body">
<table class="table table-sm">
<tr>
<th width="30%">{% trans "Source" %}</th>
<td>{{ standard.source.name }}</td>
</tr>
<tr>
<th>{% trans "Category" %}</th>
<td>{{ standard.category.name }}</td>
</tr>
<tr>
<th>{% trans "Department" %}</th>
<td>
{% if standard.department %}
{{ standard.department.name }}
{% else %}
<em>{% trans "All Departments" %}</em>
{% endif %}
</td>
</tr>
<tr>
<th>{% trans "Effective Date" %}</th>
<td>
{% if standard.effective_date %}
{{ standard.effective_date|date:"Y-m-d" }}
{% else %}
<em>{% trans "Not specified" %}</em>
{% endif %}
</td>
</tr>
<tr>
<th>{% trans "Review Date" %}</th>
<td>
{% if standard.review_date %}
{{ standard.review_date|date:"Y-m-d" }}
{% else %}
<em>{% trans "Not specified" %}</em>
{% endif %}
</td>
</tr>
</table>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">{% trans "Compliance Summary" %}</h5>
</div>
<div class="card-body">
<div class="text-center">
<h2>{{ compliance_records.count }}</h2>
<p class="text-muted">{% trans "Assessments" %}</p>
</div>
<div class="mt-3">
<span class="badge bg-success me-1">
{{ compliance_records|count_by:"status:met" }} {% trans "Met" %}
</span>
<span class="badge bg-warning me-1">
{{ compliance_records|count_by:"status:partially_met" }} {% trans "Partially Met" %}
</span>
<span class="badge bg-danger me-1">
{{ compliance_records|count_by:"status:not_met" }} {% trans "Not Met" %}
</span>
<span class="badge bg-secondary">
{{ compliance_records|count_by:"status:not_assessed" }} {% trans "Not Assessed" %}
</span>
</div>
</div>
</div>
</div>
</div>
<!-- Description -->
<div class="card mb-4">
<div class="card-header">
<h5 class="card-title mb-0">{% trans "Description" %}</h5>
</div>
<div class="card-body">
{{ standard.description|linebreaks }}
</div>
</div>
<!-- Compliance Records -->
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">{% trans "Compliance by Department" %}</h5>
</div>
<div class="card-body">
{% if compliance_records %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>{% trans "Department" %}</th>
<th>{% trans "Status" %}</th>
<th>{% trans "Last Assessed" %}</th>
<th>{% trans "Assessor" %}</th>
<th>{% trans "Evidence" %}</th>
</tr>
</thead>
<tbody>
{% for record in compliance_records %}
<tr>
<td>
<a href="{% url 'standards:department_standards' pk=record.department.id %}">
{{ record.department.name }}
</a>
</td>
<td>
<span class="badge {% if record.status == 'met' %}bg-success{% elif record.status == 'partially_met' %}bg-warning{% elif record.status == 'not_met' %}bg-danger{% else %}bg-secondary{% endif %}">
{{ record.get_status_display }}
</span>
</td>
<td>
{% if record.last_assessed_date %}
{{ record.last_assessed_date|date:"Y-m-d" }}
{% else %}
<em>{% trans "Not assessed" %}</em>
{% endif %}
</td>
<td>
{% if record.assessor %}
{{ record.assessor.get_full_name }}
{% else %}
<em>-</em>
{% endif %}
</td>
<td>
<span class="badge bg-info">
<i class="fas fa-paperclip me-1"></i>
{{ record.attachments.count }}
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted text-center">{% trans "No compliance assessments yet" %}</p>
{% endif %}
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,193 @@
{% extends 'layouts/base.html' %}
{% load i18n %}
{% block title %}{% trans "Create Standard" %}{% endblock %}
{% block content %}
<div class="container-fluid px-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-0">{% trans "Create New Standard" %}</h1>
<p class="text-muted mb-0">{% trans "Add a new compliance standard" %}</p>
</div>
<div>
{% if department_id %}
<a href="{% url 'standards:department_standards' pk=department_id %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-2"></i>{% trans "Back to Department Standards" %}
</a>
{% else %}
<a href="{% url 'standards:dashboard' %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-2"></i>{% trans "Back to Dashboard" %}
</a>
{% endif %}
</div>
</div>
<div class="row">
<div class="col-12 col-lg-8">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">{% trans "Standard Information" %}</h5>
</div>
<div class="card-body">
<form method="post">
{% csrf_token %}
<div class="mb-3">
<label for="{{ form.code.id_for_label }}" class="form-label">
{{ form.code.label }} <span class="text-danger">*</span>
</label>
{{ form.code }}
{% if form.code.help_text %}
<small class="form-text text-muted">{{ form.code.help_text }}</small>
{% endif %}
{% if form.code.errors %}
<div class="text-danger">{{ form.code.errors }}</div>
{% endif %}
</div>
<div class="mb-3">
<label for="{{ form.title.id_for_label }}" class="form-label">
{{ form.title.label }} <span class="text-danger">*</span>
</label>
{{ form.title }}
{% if form.title.errors %}
<div class="text-danger">{{ form.title.errors }}</div>
{% endif %}
</div>
<div class="mb-3">
<label for="{{ form.title_ar.id_for_label }}" class="form-label">
{{ form.title_ar.label }}
</label>
{{ form.title_ar }}
{% if form.title_ar.errors %}
<div class="text-danger">{{ form.title_ar.errors }}</div>
{% endif %}
</div>
<div class="mb-3">
<label for="{{ form.description.id_for_label }}" class="form-label">
{{ form.description.label }} <span class="text-danger">*</span>
</label>
{{ form.description }}
{% if form.description.errors %}
<div class="text-danger">{{ form.description.errors }}</div>
{% endif %}
</div>
<div class="mb-3">
<label for="{{ form.department.id_for_label }}" class="form-label">
{{ form.department.label }}
</label>
{{ form.department }}
<small class="form-text text-muted">
{% trans "Leave empty to apply to all departments" %}
</small>
{% if form.department.errors %}
<div class="text-danger">{{ form.department.errors }}</div>
{% endif %}
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="{{ form.effective_date.id_for_label }}" class="form-label">
{{ form.effective_date.label }}
</label>
{{ form.effective_date }}
{% if form.effective_date.errors %}
<div class="text-danger">{{ form.effective_date.errors }}</div>
{% endif %}
</div>
<div class="col-md-6 mb-3">
<label for="{{ form.review_date.id_for_label }}" class="form-label">
{{ form.review_date.label }}
</label>
{{ form.review_date }}
{% if form.review_date.errors %}
<div class="text-danger">{{ form.review_date.errors }}</div>
{% endif %}
</div>
</div>
<div class="mb-3">
<div class="form-check">
{{ form.is_active }}
<label for="{{ form.is_active.id_for_label }}" class="form-check-label">
{{ form.is_active.label }}
</label>
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-2"></i>{% trans "Create Standard" %}
</button>
{% if department_id %}
<a href="{% url 'standards:department_standards' pk=department_id %}" class="btn btn-secondary">
{% trans "Cancel" %}
</a>
{% else %}
<a href="{% url 'standards:dashboard' %}" class="btn btn-secondary">
{% trans "Cancel" %}
</a>
{% endif %}
</div>
</form>
</div>
</div>
</div>
<div class="col-12 col-lg-4">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">{% trans "Help" %}</h5>
</div>
<div class="card-body">
<h6>{% trans "Standard Code Format" %}</h6>
<p class="small text-muted">
{% trans "Use a unique code to identify this standard" %}<br>
<strong>{% trans "Examples:" %}</strong><br>
- STD-001<br>
- QM-05<br>
- PS-12
</p>
<h6 class="mt-3">{% trans "Department Assignment" %}</h6>
<p class="small text-muted">
{% trans "Leave the department field empty if this standard applies to all departments. Select a specific department only if the standard is department-specific." %}
</p>
<h6 class="mt-3">{% trans "Dates" %}</h6>
<p class="small text-muted">
{% trans "Effective date: When the standard becomes mandatory" %}<br>
{% trans "Review date: When the standard should be reviewed for updates" %}
</p>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Initialize form fields with Bootstrap classes
const formInputs = document.querySelectorAll('input[type="text"], input[type="date"], select, textarea');
formInputs.forEach(input => {
if (!input.classList.contains('form-control')) {
input.classList.add('form-control');
}
});
// Initialize checkboxes with Bootstrap classes
const checkboxes = document.querySelectorAll('input[type="checkbox"]');
checkboxes.forEach(checkbox => {
if (!checkbox.classList.contains('form-check-input')) {
checkbox.classList.add('form-check-input');
}
});
});
</script>
{% endblock %}