diff --git a/backend/api/views/audit_viewset.py b/backend/api/views/audit_viewset.py index 48bb8ac..61bd6b0 100644 --- a/backend/api/views/audit_viewset.py +++ b/backend/api/views/audit_viewset.py @@ -7,56 +7,79 @@ from datetime import timedelta from api.models import InferenceAuditLog from api.serializers.audit_serializer import InferenceAuditLogSerializer -from permissions.permission_classes import IsAdminOrManager # ใช้สิทธิ์เดียวกันกับ Model Registry +# นำเข้า Permission ที่จำเป็น +from permissions.permission_classes import IsAdminOrOperator, IsViewerOrHigher class AuditLogViewSet(viewsets.ReadOnlyModelViewSet): """ - API สำหรับการเข้าถึง Inference Audit Log และสถิติรวม + API สำหรับการเข้าถึง Inference Audit Log และสถิติรวม (รวมการดึง Summary ด้วย) """ queryset = InferenceAuditLog.objects.all() serializer_class = InferenceAuditLogSerializer - permission_classes = [permissions.IsAuthenticated] # อนุญาตให้เข้าถึงเมื่อล็อกอินแล้ว + permission_classes = [permissions.IsAuthenticated] - # ใช้ 'id' เป็นฟิลด์ค้นหา lookup_field = 'id' - - # ใช้ 'id' เป็นชื่อพารามิเตอร์ใน URL lookup_url_kwarg = 'id' def retrieve(self, request, *args, **kwargs): # บังคับให้ Lookup Key (pk) เป็น String kwargs[self.lookup_url_kwarg] = str(kwargs[self.lookup_url_kwarg]) - return super().retrieve(request, *args, **kwargs) def get_queryset(self): # คืน Log ล่าสุด 10 รายการ (สำหรับ Recent Events ใน Dashboard) - return self.queryset.select_related('user')[:10] + return self.queryset.select_related('user').order_by('-timestamp')[:10] # 📌 เพิ่ม order_by เพื่อให้ได้ล่าสุดจริง ๆ # ----------------------------------------------- - # Custom Action: ดึงสถิติรวมสำหรับ Dashboard - # Endpoint: GET /api/v1/audit/inference-summary/ + # Custom Action: ดึงสถิติรวม Dynamic (สำหรับ Viewer, Operator, Admin) + # Endpoint: GET /api/v1/audit/summary/ # ----------------------------------------------- - @action(detail=False, methods=['get'], url_path='inference-summary') + @action( + detail=False, + methods=['get'], + url_path='summary', + # อนุญาตให้เข้าถึงสำหรับทุก Role ใน Dashboard + permission_classes=[IsViewerOrHigher] + ) def get_summary(self, request): - one_day_ago = timezone.now() - timedelta(hours=24) + user = request.user - # 1. คำนวณสถิติรวม (Global Metrics) - metrics = self.queryset.filter(timestamp__gte=one_day_ago).aggregate( + # 1. ตรวจสอบ Role เพื่อกำหนดขอบเขตข้อมูลที่จะแสดง (Dynamic View) + # ตรวจสอบว่าผู้ใช้เป็น ADMIN หรือ OPERATOR หรือไม่ + is_high_privilege = user.role in ['ADMIN', 'OPERATOR'] + + # กำหนดขีดจำกัดและข้อมูลที่จะรวม + log_limit = 10 if is_high_privilege else 5 + include_latency = is_high_privilege # Latency เป็นข้อมูลเชิงเทคนิค + + one_day_ago = timezone.now() - timedelta(hours=24) + queryset = self.queryset.filter(timestamp__gte=one_day_ago) + + # 2. คำนวณสถิติพื้นฐาน (ใช้ร่วมกัน) + metrics = queryset.aggregate( total_runs=Count('id'), success_count=Count('id', filter=Q(is_success=True)), - avg_latency_ms=Avg('latency_ms') + # คำนวณ Avg Latency เฉพาะถ้าผู้ใช้มีสิทธิ์สูง + avg_latency_ms=Avg('latency_ms') if include_latency else None ) - # 2. คำนวณ Success Rate + # 3. คำนวณ Success Rate total = metrics.get('total_runs', 0) success = metrics.get('success_count', 0) success_rate = (success / total) * 100 if total > 0 else 0 - return Response({ + # 4. ดึง Log ล่าสุดตามสิทธิ์ + last_logs = self.queryset.select_related('user').order_by('-timestamp')[:log_limit] + + response_data = { "time_window": "24 hours", "total_runs": total, "success_rate": round(success_rate, 2), - "avg_latency_ms": round(metrics.get('avg_latency_ms', 0) or 0, 2), - "last_logs": InferenceAuditLogSerializer(self.get_queryset(), many=True).data - }) \ No newline at end of file + "last_logs": InferenceAuditLogSerializer(last_logs, many=True).data + } + + # 5. เพิ่ม Latency เข้าไปใน Response เฉพาะ Role ที่มีสิทธิ์สูง + if include_latency: + response_data['avg_latency_ms'] = round(metrics.get('avg_latency_ms', 0) or 0, 2) + + return Response(response_data) \ No newline at end of file diff --git a/backend/core/urls.py b/backend/core/urls.py index fef37d9..4056f04 100644 --- a/backend/core/urls.py +++ b/backend/core/urls.py @@ -1,5 +1,5 @@ """ -URL configuration for cremation_backend project. +URL configuration for backend project. The `urlpatterns` list routes URLs to views. For more information please see: https://docs.djangoproject.com/en/5.2/topics/http/urls/ diff --git a/backend/permissions/permission_classes.py b/backend/permissions/permission_classes.py index d67c4e6..a44a478 100644 --- a/backend/permissions/permission_classes.py +++ b/backend/permissions/permission_classes.py @@ -1,56 +1,57 @@ from rest_framework import permissions -from rest_framework.exceptions import PermissionDenied # ---------------------------------------------------- -# Base Class เพื่อใช้ Logic การตรวจสอบสิทธิ์ร่วมกัน +# Global Role Definition # ---------------------------------------------------- -class BaseRolePermission(permissions.BasePermission): +ROLES = { + "ADMIN": "ADMIN", + "OPERATOR": "OPERATOR", + "VIEWER": "VIEWER", +} + + +# ---------------------------------------------------- +# Base Role Permission (Clean Code + Robustness) +# ---------------------------------------------------- +class RolePermission(permissions.BasePermission): """ - Base class สำหรับตรวจสอบสิทธิ์ตาม is_superuser และ is_staff + Base class สำหรับตรวจสอบสิทธิ์ตามฟิลด์ role (ADMIN, OPERATOR, VIEWER) """ - def is_admin(self, user): - return user.is_authenticated and user.is_superuser - def is_manager(self, user): - # ผู้จัดการคือ is_staff แต่ไม่ใช่ superuser - return user.is_authenticated and user.is_staff and not user.is_superuser - - # หรือเพิ่มฟังก์ชัน is_model_owner(user) ถ้าจำเป็น - # ถ้าใช้ฟิลด์ 'role' ที่คุณเพิ่มใน Serializer ควรใช้: - # return user.is_authenticated and getattr(user, 'role', '').upper() == 'ADMIN' - - -# ---------------------------------------------------- -# 1. Permission: อนุญาตเฉพาะ Admin เท่านั้น -# ---------------------------------------------------- -class IsAdmin(BaseRolePermission): - message = "คุณไม่มีสิทธิ์เข้าถึงหน้านี้ (Admin Required)." + allowed_roles = [] def has_permission(self, request, view): - # Admin คือ is_superuser - return self.is_admin(request.user) + user = request.user + + if not user or not user.is_authenticated: + return False + + # กรณีที่ไม่มี attribute ชื่อ role ก็จะไม่ทำให้เกิด AttributeError + user_role = getattr(user, 'role', '').upper() + + return user_role in self.allowed_roles # ---------------------------------------------------- -# 2. Permission: อนุญาตเฉพาะ Admin หรือ Manager เท่านั้น +# Specific Permission Classes # ---------------------------------------------------- -class IsAdminOrManager(BaseRolePermission): - message = "คุณต้องมีสิทธิ์ Admin หรือ Manager." - - def has_permission(self, request, view): - # Admin หรือ Manager (is_staff) - return self.is_admin(request.user) or self.is_manager(request.user) +class IsAdmin(RolePermission): + message = "คุณไม่มีสิทธิ์เข้าถึงหน้านี้ (ADMIN Required)." + allowed_roles = [ROLES["ADMIN"]] -# ---------------------------------------------------- -# 3. Permission: อนุญาตเฉพาะผู้ใช้ที่ผ่านการยืนยันตัวตนแล้ว (IsAuthenticated) -# และเป็น Viewer ขึ้นไป -# ---------------------------------------------------- -# ไม่จำเป็นต้องสร้างคลาสนี้ถ้าใช้ DRF's IsAuthenticated โดยตรง -# แต่ถ้าต้องการเพิ่ม Logic อื่นๆ ในอนาคต ควรใช้คลาสนี้ -class IsAuthenticatedViewer(permissions.BasePermission): +class IsAdminOrOperator(RolePermission): + allowed_roles = [ROLES["ADMIN"], ROLES["OPERATOR"]] + + +class IsViewerOrHigher(RolePermission): + allowed_roles = [ + ROLES["ADMIN"], + ROLES["OPERATOR"], + ROLES["VIEWER"], + ] + + +class IsAuthenticatedAccess(permissions.IsAuthenticated): message = "คุณต้องเข้าสู่ระบบก่อน" - - def has_permission(self, request, view): - return request.user.is_authenticated \ No newline at end of file diff --git a/web/src/components/SidebarSubmenu.jsx b/web/src/components/SidebarSubmenu.jsx index 9b78ccb..b46f8e2 100644 --- a/web/src/components/SidebarSubmenu.jsx +++ b/web/src/components/SidebarSubmenu.jsx @@ -1,35 +1,37 @@ -// src/components/SidebarSubmenu.jsx (ปรับปรุงสำหรับ RBAC) - import ChevronDownIcon from '@heroicons/react/24/outline/ChevronDownIcon'; import { useEffect, useState } from 'react'; import { NavLink, useLocation } from 'react-router-dom'; import { useSelector } from 'react-redux'; -// Component นี้คาดหวังว่าฟังก์ชัน canAccess จะถูกส่งมาจาก MainLayout หรือสร้างขึ้นใหม่ - function SidebarSubmenu({ submenu, name, icon, closeMobileSidebar }) { const location = useLocation(); const [isExpanded, setIsExpanded] = useState(false); - const role = useSelector(state => state.auth.role); // ดึง Role จาก Redux + // ดึง Role จาก Redux + const userRole = useSelector(state => state.auth.role); - // ฟังก์ชันตรวจสอบสิทธิ์ (ควรมาจาก MainLayout แต่เราจะใช้ Logic พื้นฐาน) + // ฟังก์ชันตรวจสอบสิทธิ์ const canAccess = (requiredRole) => { - if (role === 'admin') return true; + // userRole ที่มาจาก Redux ควรเป็นตัวพิมพ์ใหญ่แล้ว if (!requiredRole) return true; - const allowedRoles = Array.isArray(requiredRole) ? requiredRole : [requiredRole]; - return allowedRoles.includes(role); + + const allowedRoles = Array.isArray(requiredRole) + ? requiredRole + : [requiredRole]; + + return allowedRoles.includes(userRole); }; // เปิด Submenu หาก Path ปัจจุบันอยู่ใน Submenu นั้น useEffect(() => { // ตรวจสอบว่ามีเมนูย่อยใดที่ผู้ใช้เข้าถึงได้และเป็น Path ปัจจุบันหรือไม่ const isActive = submenu.some( + // ใช้ userRole ใน canAccess m => canAccess(m.requiredRole) && m.path === location.pathname ); if (isActive) { setIsExpanded(true); } - }, [location.pathname, submenu, role]); // เพิ่ม role ใน dependency array + }, [location.pathname, submenu, userRole]); return (