davidtran999 commited on
Commit
4c3b646
·
verified ·
1 Parent(s): 68bd58a

Upload backend/hue_portal/core/admin_views.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. backend/hue_portal/core/admin_views.py +1168 -0
backend/hue_portal/core/admin_views.py ADDED
@@ -0,0 +1,1168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Admin API views for user management, activity monitoring, alerts, and import history.
3
+ All endpoints require admin role.
4
+ """
5
+ import hashlib
6
+ from datetime import timedelta, datetime, time, date
7
+ from django.contrib.auth import get_user_model
8
+ from django.core.cache import cache
9
+ from django.db.models import Q, Count
10
+ from django.db.models.functions import TruncDate
11
+ from django.utils import timezone
12
+ from rest_framework import permissions, status
13
+ from rest_framework.response import Response
14
+ from rest_framework.views import APIView
15
+ from rest_framework.pagination import PageNumberPagination
16
+ from rest_framework.parsers import MultiPartParser, FormParser
17
+
18
+ from .models import UserProfile, AuditLog, IngestionJob, SystemAlert, LegalDocument, LegalSection, LegalDocumentImage
19
+ from .serializers import AdminUserSerializer, IngestionJobSerializer, LegalDocumentSerializer
20
+ from .auth_views import _user_role
21
+
22
+ User = get_user_model()
23
+
24
+
25
+ class IsAdminPermission(permissions.BasePermission):
26
+ """Permission class to check if user is admin."""
27
+
28
+ def has_permission(self, request, view):
29
+ if not request.user or not request.user.is_authenticated:
30
+ return False
31
+ return _user_role(request.user) == UserProfile.Roles.ADMIN
32
+
33
+
34
+ class AdminUserListView(APIView):
35
+ """List all users with pagination, role filter, and server-side search. Admin only."""
36
+ permission_classes = [IsAdminPermission]
37
+
38
+ def _get_cache_version(self):
39
+ """Get current cache version for user list."""
40
+ version = cache.get("admin_users_cache_version", 1)
41
+ return version
42
+
43
+ def _invalidate_cache(self):
44
+ """Invalidate user list cache by incrementing version."""
45
+ current_version = cache.get("admin_users_cache_version", 1)
46
+ cache.set("admin_users_cache_version", current_version + 1, timeout=None)
47
+
48
+ def get(self, request):
49
+ role_filter = request.query_params.get("role")
50
+ search = request.query_params.get("search", "").strip()
51
+ page = int(request.query_params.get("page", 1))
52
+ page_size = int(request.query_params.get("page_size", 20))
53
+
54
+ # Build cache key with version
55
+ cache_version = self._get_cache_version()
56
+ cache_key_parts = [
57
+ "admin_users",
58
+ f"v{cache_version}",
59
+ role_filter or "all",
60
+ str(page),
61
+ str(page_size),
62
+ hashlib.md5(search.encode()).hexdigest()[:8] if search else "no_search",
63
+ ]
64
+ cache_key = "_".join(cache_key_parts)
65
+
66
+ # Try to get from cache
67
+ cached_result = cache.get(cache_key)
68
+ if cached_result is not None:
69
+ return Response(cached_result)
70
+
71
+ # Build queryset with optimized select_related and only()
72
+ queryset = User.objects.select_related("profile").only(
73
+ "id", "username", "email", "first_name", "last_name", "is_active", "date_joined"
74
+ ).order_by("-date_joined")
75
+
76
+ # Apply role filter
77
+ if role_filter:
78
+ queryset = queryset.filter(profile__role=role_filter)
79
+
80
+ # Apply search filter (username or email)
81
+ if search:
82
+ queryset = queryset.filter(
83
+ Q(username__icontains=search) | Q(email__icontains=search)
84
+ )
85
+
86
+ # Manual pagination
87
+ start = (page - 1) * page_size
88
+ end = start + page_size
89
+ users = queryset[start:end]
90
+
91
+ # Calculate total count (needed for pagination)
92
+ # We always need the count for pagination to work properly
93
+ total = queryset.count()
94
+
95
+ serializer = AdminUserSerializer(users, many=True)
96
+
97
+ response_data = {
98
+ "results": serializer.data,
99
+ "count": total,
100
+ "page": page,
101
+ "page_size": page_size,
102
+ }
103
+
104
+ # Cache the result for 30 seconds
105
+ cache.set(cache_key, response_data, 30)
106
+
107
+ return Response(response_data)
108
+
109
+
110
+ class AdminUserCreateView(APIView):
111
+ """Create a new user. Admin only."""
112
+ permission_classes = [IsAdminPermission]
113
+
114
+ def post(self, request):
115
+ from .serializers import RegisterSerializer
116
+
117
+ serializer = RegisterSerializer(data=request.data)
118
+ serializer.is_valid(raise_exception=True)
119
+ user = serializer.save()
120
+
121
+ # Invalidate cache for user list
122
+ AdminUserListView()._invalidate_cache()
123
+
124
+ return Response(AdminUserSerializer(user).data, status=status.HTTP_201_CREATED)
125
+
126
+
127
+ class AdminUserUpdateView(APIView):
128
+ """Update user role or is_active status. Admin only."""
129
+ permission_classes = [IsAdminPermission]
130
+
131
+ def patch(self, request, user_id):
132
+ try:
133
+ user = User.objects.get(id=user_id)
134
+ except User.DoesNotExist:
135
+ return Response({"detail": "Người dùng không tồn tại."}, status=status.HTTP_404_NOT_FOUND)
136
+
137
+ # Prevent admin from modifying themselves
138
+ if user.id == request.user.id:
139
+ return Response({"detail": "Bạn không thể thay đổi quyền của chính mình."}, status=status.HTTP_400_BAD_REQUEST)
140
+
141
+ profile, _ = UserProfile.objects.get_or_create(user=user)
142
+
143
+ # Update role if provided
144
+ if "role" in request.data:
145
+ new_role = request.data["role"]
146
+ if new_role not in [UserProfile.Roles.ADMIN, UserProfile.Roles.USER]:
147
+ return Response({"detail": "Role không hợp lệ."}, status=status.HTTP_400_BAD_REQUEST)
148
+ profile.role = new_role
149
+ profile.save()
150
+
151
+ # Update is_active if provided
152
+ if "is_active" in request.data:
153
+ user.is_active = request.data["is_active"]
154
+ user.save()
155
+
156
+ # Invalidate cache for user list
157
+ AdminUserListView()._invalidate_cache()
158
+
159
+ return Response(AdminUserSerializer(user).data)
160
+
161
+
162
+ class AdminUserResetPasswordView(APIView):
163
+ """Reset user password to a temporary password. Admin only."""
164
+ permission_classes = [IsAdminPermission]
165
+
166
+ def post(self, request, user_id):
167
+ try:
168
+ user = User.objects.get(id=user_id)
169
+ except User.DoesNotExist:
170
+ return Response({"detail": "Người dùng không tồn tại."}, status=status.HTTP_404_NOT_FOUND)
171
+
172
+ import secrets
173
+ import string
174
+
175
+ # Generate temporary password
176
+ alphabet = string.ascii_letters + string.digits + "!@#$%^&*"
177
+ temp_password = "".join(secrets.choice(alphabet) for _ in range(12))
178
+ user.set_password(temp_password)
179
+ user.save()
180
+
181
+ return Response({
182
+ "message": "Mật khẩu đã được reset.",
183
+ "temporary_password": temp_password, # In production, send via email instead
184
+ })
185
+
186
+
187
+ def parse_user_agent(user_agent: str) -> dict:
188
+ """Parse user agent string to extract device type and browser."""
189
+ if not user_agent:
190
+ return {"device_type": "unknown", "browser": "unknown"}
191
+
192
+ ua_lower = user_agent.lower()
193
+
194
+ # Detect device type
195
+ device_type = "desktop"
196
+ if "mobile" in ua_lower or "android" in ua_lower:
197
+ device_type = "mobile"
198
+ elif "tablet" in ua_lower or "ipad" in ua_lower:
199
+ device_type = "tablet"
200
+
201
+ # Detect browser
202
+ browser = "unknown"
203
+ if "chrome" in ua_lower and "edg" not in ua_lower:
204
+ browser = "Chrome"
205
+ elif "firefox" in ua_lower:
206
+ browser = "Firefox"
207
+ elif "safari" in ua_lower and "chrome" not in ua_lower:
208
+ browser = "Safari"
209
+ elif "edg" in ua_lower:
210
+ browser = "Edge"
211
+ elif "opera" in ua_lower or "opr" in ua_lower:
212
+ browser = "Opera"
213
+
214
+ return {"device_type": device_type, "browser": browser}
215
+
216
+
217
+ class AdminActivityLogsView(APIView):
218
+ """List activity logs with IP, device, browser info, pagination, search, and filters. Admin only."""
219
+ permission_classes = [IsAdminPermission]
220
+
221
+ def get(self, request):
222
+ # Pagination params
223
+ page = int(request.query_params.get("page", 1))
224
+ page_size = int(request.query_params.get("page_size", 10))
225
+
226
+ # Search param (search by IP or location)
227
+ search = request.query_params.get("search", "").strip()
228
+
229
+ # Filter params
230
+ device_type_filter = request.query_params.get("device_type")
231
+ status_filter = request.query_params.get("status")
232
+
233
+ # Timeframe (optional, defaults to all time if not specified)
234
+ timeframe = request.query_params.get("timeframe")
235
+ if timeframe:
236
+ if timeframe == "24h":
237
+ threshold = timezone.now() - timedelta(hours=24)
238
+ elif timeframe == "7d":
239
+ threshold = timezone.now() - timedelta(days=7)
240
+ elif timeframe == "30d":
241
+ threshold = timezone.now() - timedelta(days=30)
242
+ else:
243
+ threshold = None
244
+ else:
245
+ threshold = None
246
+
247
+ # Build queryset
248
+ queryset = AuditLog.objects.all().order_by("-created_at")
249
+
250
+ if threshold:
251
+ queryset = queryset.filter(created_at__gte=threshold)
252
+
253
+ if search:
254
+ # Search by IP address
255
+ queryset = queryset.filter(ip__icontains=search)
256
+
257
+ if device_type_filter:
258
+ # We'll filter after parsing user_agent (see below)
259
+ pass
260
+
261
+ if status_filter:
262
+ try:
263
+ status_int = int(status_filter)
264
+ queryset = queryset.filter(status=status_int)
265
+ except ValueError:
266
+ pass
267
+
268
+ # Get total count before pagination
269
+ total_count = queryset.count()
270
+
271
+ # Apply pagination
272
+ start = (page - 1) * page_size
273
+ end = start + page_size
274
+ logs = queryset[start:end]
275
+
276
+ results = []
277
+ for log in logs:
278
+ parsed = parse_user_agent(log.user_agent)
279
+ device_type = parsed["device_type"]
280
+
281
+ # Apply device_type filter if specified (after parsing)
282
+ if device_type_filter:
283
+ if device_type_filter.lower() == "desktop" and device_type != "desktop":
284
+ continue
285
+ elif device_type_filter.lower() in ["mobile", "tablet"] and device_type not in ["mobile", "tablet"]:
286
+ continue
287
+
288
+ # Get location from IP
289
+ location = get_ip_location(log.ip)
290
+
291
+ # Format device type for display
292
+ display_device_type = "Desktop"
293
+ if device_type == "mobile":
294
+ display_device_type = "Mobile"
295
+ elif device_type == "tablet":
296
+ display_device_type = "Tablet"
297
+
298
+ results.append({
299
+ "id": log.id,
300
+ "ip": str(log.ip) if log.ip else None,
301
+ "device_type": display_device_type,
302
+ "browser": parsed["browser"],
303
+ "location": location or "Unknown",
304
+ "timestamp": log.created_at.isoformat(),
305
+ "status": log.status,
306
+ "path": log.path,
307
+ "query": log.query or "",
308
+ })
309
+
310
+ return Response({
311
+ "results": results,
312
+ "count": total_count,
313
+ "page": page,
314
+ "page_size": page_size,
315
+ })
316
+
317
+
318
+ class AdminImportHistoryView(APIView):
319
+ """List recent ingestion jobs. Admin only."""
320
+ permission_classes = [IsAdminPermission]
321
+
322
+ def get(self, request):
323
+ status_filter = request.query_params.get("status")
324
+ limit = int(request.query_params.get("limit", 20))
325
+
326
+ queryset = IngestionJob.objects.select_related("document").all().order_by("-created_at")
327
+
328
+ if status_filter:
329
+ queryset = queryset.filter(status=status_filter)
330
+
331
+ jobs = queryset[:limit]
332
+ serializer = IngestionJobSerializer(jobs, many=True)
333
+ return Response({"results": serializer.data, "count": len(serializer.data)})
334
+
335
+
336
+ class AdminAlertsView(APIView):
337
+ """List system alerts (unresolved by default). Admin only."""
338
+ permission_classes = [IsAdminPermission]
339
+
340
+ def get(self, request):
341
+ alert_type = request.query_params.get("type")
342
+ limit = int(request.query_params.get("limit", 50))
343
+ unresolved_only = request.query_params.get("unresolved", "true").lower() == "true"
344
+
345
+ queryset = SystemAlert.objects.all().order_by("-created_at")
346
+
347
+ if unresolved_only:
348
+ queryset = queryset.filter(resolved_at__isnull=True)
349
+
350
+ if alert_type:
351
+ queryset = queryset.filter(alert_type=alert_type)
352
+
353
+ alerts = queryset[:limit]
354
+
355
+ results = []
356
+ for alert in alerts:
357
+ results.append({
358
+ "id": alert.id,
359
+ "alert_type": alert.alert_type,
360
+ "title": alert.title,
361
+ "message": alert.message,
362
+ "severity": alert.severity,
363
+ "created_at": alert.created_at.isoformat(),
364
+ "resolved_at": alert.resolved_at.isoformat() if alert.resolved_at else None,
365
+ "metadata": alert.metadata,
366
+ })
367
+
368
+ return Response({"results": results, "count": len(results)})
369
+
370
+
371
+ def format_time_ago(timestamp):
372
+ """Format timestamp to human-readable time ago string."""
373
+ now = timezone.now()
374
+ if timestamp.tzinfo is None:
375
+ timestamp = timezone.make_aware(timestamp)
376
+
377
+ diff = now - timestamp
378
+
379
+ if diff.days > 0:
380
+ if diff.days == 1:
381
+ return "1 day ago"
382
+ elif diff.days < 7:
383
+ return f"{diff.days} days ago"
384
+ elif diff.days < 30:
385
+ weeks = diff.days // 7
386
+ return f"{weeks} week{'s' if weeks > 1 else ''} ago"
387
+ else:
388
+ months = diff.days // 30
389
+ return f"{months} month{'s' if months > 1 else ''} ago"
390
+ elif diff.seconds >= 3600:
391
+ hours = diff.seconds // 3600
392
+ return f"{hours} hour{'s' if hours > 1 else ''} ago"
393
+ elif diff.seconds >= 60:
394
+ minutes = diff.seconds // 60
395
+ return f"{minutes} minute{'s' if minutes > 1 else ''} ago"
396
+ else:
397
+ return "just now"
398
+
399
+
400
+ class AdminDashboardStatsView(APIView):
401
+ """Get dashboard statistics (total documents, active users, pending approvals, system alerts). Admin only."""
402
+ permission_classes = [IsAdminPermission]
403
+
404
+ def get(self, request):
405
+ # Get current counts
406
+ total_documents = LegalDocument.objects.count()
407
+ active_users = User.objects.filter(is_active=True).count()
408
+ pending_approvals = IngestionJob.objects.filter(status=IngestionJob.STATUS_PENDING).count()
409
+ system_alerts = SystemAlert.objects.filter(resolved_at__isnull=True).count()
410
+
411
+ # Calculate percentage changes (comparing last 7 days to previous 7 days)
412
+ now = timezone.now()
413
+ last_7_days_start = now - timedelta(days=7)
414
+ previous_7_days_start = now - timedelta(days=14)
415
+
416
+ # Documents change
417
+ docs_last_7 = LegalDocument.objects.filter(created_at__gte=last_7_days_start).count()
418
+ docs_prev_7 = LegalDocument.objects.filter(
419
+ created_at__gte=previous_7_days_start,
420
+ created_at__lt=last_7_days_start
421
+ ).count()
422
+ total_documents_change = 0.0
423
+ if docs_prev_7 > 0:
424
+ total_documents_change = ((docs_last_7 - docs_prev_7) / docs_prev_7) * 100
425
+ elif docs_last_7 > 0:
426
+ total_documents_change = 100.0
427
+
428
+ # Active users change (users activated in last 7 days)
429
+ users_last_7 = User.objects.filter(
430
+ is_active=True,
431
+ date_joined__gte=last_7_days_start
432
+ ).count()
433
+ users_prev_7 = User.objects.filter(
434
+ is_active=True,
435
+ date_joined__gte=previous_7_days_start,
436
+ date_joined__lt=last_7_days_start
437
+ ).count()
438
+ active_users_change = 0.0
439
+ if users_prev_7 > 0:
440
+ active_users_change = ((users_last_7 - users_prev_7) / users_prev_7) * 100
441
+ elif users_last_7 > 0:
442
+ active_users_change = 100.0
443
+
444
+ # Pending approvals change
445
+ pending_last_7 = IngestionJob.objects.filter(
446
+ status=IngestionJob.STATUS_PENDING,
447
+ created_at__gte=last_7_days_start
448
+ ).count()
449
+ pending_prev_7 = IngestionJob.objects.filter(
450
+ status=IngestionJob.STATUS_PENDING,
451
+ created_at__gte=previous_7_days_start,
452
+ created_at__lt=last_7_days_start
453
+ ).count()
454
+ pending_approvals_change = 0.0
455
+ if pending_prev_7 > 0:
456
+ pending_approvals_change = ((pending_last_7 - pending_prev_7) / pending_prev_7) * 100
457
+ elif pending_last_7 > 0:
458
+ pending_approvals_change = 100.0
459
+
460
+ # System alerts change (negative means fewer alerts = good)
461
+ alerts_last_7 = SystemAlert.objects.filter(
462
+ resolved_at__isnull=True,
463
+ created_at__gte=last_7_days_start
464
+ ).count()
465
+ alerts_prev_7 = SystemAlert.objects.filter(
466
+ resolved_at__isnull=True,
467
+ created_at__gte=previous_7_days_start,
468
+ created_at__lt=last_7_days_start
469
+ ).count()
470
+ system_alerts_change = 0.0
471
+ if alerts_prev_7 > 0:
472
+ system_alerts_change = ((alerts_last_7 - alerts_prev_7) / alerts_prev_7) * 100
473
+ elif alerts_last_7 > 0:
474
+ system_alerts_change = 100.0
475
+ else:
476
+ # If no alerts in last period but had alerts before, it's a decrease
477
+ if alerts_prev_7 > 0:
478
+ system_alerts_change = -100.0
479
+
480
+ return Response({
481
+ "total_documents": total_documents,
482
+ "total_documents_change": round(total_documents_change, 1),
483
+ "active_users": active_users,
484
+ "active_users_change": round(active_users_change, 1),
485
+ "pending_approvals": pending_approvals,
486
+ "pending_approvals_change": round(pending_approvals_change, 1),
487
+ "system_alerts": system_alerts,
488
+ "system_alerts_change": round(system_alerts_change, 1),
489
+ })
490
+
491
+
492
+ class AdminDashboardDocumentsWeekView(APIView):
493
+ """Get documents processed this week data for bar chart. Admin only."""
494
+ permission_classes = [IsAdminPermission]
495
+
496
+ def get(self, request):
497
+ # Use local date + timezone-aware boundaries so stats align with UI expectations
498
+ today = timezone.localdate()
499
+ last_7_days_start = timezone.make_aware(
500
+ datetime.combine(today - timedelta(days=6), time.min),
501
+ timezone.get_current_timezone(),
502
+ )
503
+ previous_7_days_start = last_7_days_start - timedelta(days=7)
504
+
505
+ # Get completed ingestion jobs (documents actually processed) in last 7 days, grouped by finished_at day
506
+ ingestion_last_7 = (
507
+ IngestionJob.objects.filter(
508
+ status=IngestionJob.STATUS_COMPLETED,
509
+ finished_at__isnull=False,
510
+ finished_at__gte=last_7_days_start,
511
+ )
512
+ .annotate(date=TruncDate("finished_at", tzinfo=timezone.get_current_timezone()))
513
+ .values("date")
514
+ .annotate(count=Count("id"))
515
+ .order_by("date")
516
+ )
517
+
518
+ # Create a dict for easy lookup by exact date
519
+ from datetime import date as date_type
520
+
521
+ daily_counts_dict = {}
522
+ for item in ingestion_last_7:
523
+ day = item["date"]
524
+ if isinstance(day, date_type):
525
+ daily_counts_dict[day] = item["count"]
526
+
527
+ # Get totals for the same completed-ingestion dataset
528
+ total_last_7 = (
529
+ IngestionJob.objects.filter(
530
+ status=IngestionJob.STATUS_COMPLETED,
531
+ finished_at__isnull=False,
532
+ finished_at__gte=last_7_days_start,
533
+ ).count()
534
+ )
535
+ total_prev_7 = (
536
+ IngestionJob.objects.filter(
537
+ status=IngestionJob.STATUS_COMPLETED,
538
+ finished_at__isnull=False,
539
+ finished_at__gte=previous_7_days_start,
540
+ finished_at__lt=last_7_days_start,
541
+ ).count()
542
+ )
543
+
544
+ # Calculate percentage change
545
+ change_percent = 0.0
546
+ if total_prev_7 > 0:
547
+ change_percent = ((total_last_7 - total_prev_7) / total_prev_7) * 100
548
+ elif total_last_7 > 0:
549
+ change_percent = 100.0
550
+
551
+ # Build daily data array for the last 7 days (from 6 days ago to today)
552
+ daily_data = []
553
+ for i in range(6, -1, -1): # 6 days ago to today
554
+ day_date = today - timedelta(days=i)
555
+ day_name = day_date.strftime("%a") # Mon, Tue, etc.
556
+ count = daily_counts_dict.get(day_date, 0)
557
+ daily_data.append({"day": day_name, "count": count})
558
+
559
+ return Response({
560
+ "total": total_last_7,
561
+ "change_percent": round(change_percent, 1),
562
+ "daily_data": daily_data,
563
+ })
564
+
565
+
566
+ class AdminDashboardRecentActivityView(APIView):
567
+ """Get recent activity list combining document uploads, user role changes, alerts, and approvals. Admin only."""
568
+ permission_classes = [IsAdminPermission]
569
+
570
+ def get(self, request):
571
+ limit = int(request.query_params.get("limit", 10))
572
+ activities = []
573
+
574
+ # 1. Document uploads (from completed IngestionJobs)
575
+ uploads = IngestionJob.objects.filter(
576
+ status=IngestionJob.STATUS_COMPLETED
577
+ ).select_related('document').order_by('-created_at')[:limit]
578
+
579
+ for job in uploads:
580
+ filename = job.filename or "Unknown file"
581
+ # Try to get user from metadata or use system
582
+ user_name = job.metadata.get('uploaded_by', 'System')
583
+ activities.append({
584
+ "type": "document_upload",
585
+ "icon": "upload_file",
586
+ "title": "New document uploaded",
587
+ "description": f'"{filename}" by {user_name}',
588
+ "time_ago": format_time_ago(job.created_at),
589
+ "timestamp": job.created_at.isoformat(),
590
+ })
591
+
592
+ # 2. System alerts (unresolved)
593
+ alerts = SystemAlert.objects.filter(
594
+ resolved_at__isnull=True
595
+ ).order_by('-created_at')[:limit]
596
+
597
+ for alert in alerts:
598
+ activities.append({
599
+ "type": "system_alert",
600
+ "icon": "warning",
601
+ "title": "System Alert",
602
+ "description": alert.message,
603
+ "time_ago": format_time_ago(alert.created_at),
604
+ "timestamp": alert.created_at.isoformat(),
605
+ "severity": alert.severity,
606
+ })
607
+
608
+ # 3. Document approvals (completed jobs, can be same as uploads but we'll treat separately)
609
+ approvals = IngestionJob.objects.filter(
610
+ status=IngestionJob.STATUS_COMPLETED
611
+ ).select_related('document').order_by('-finished_at')[:limit]
612
+
613
+ for job in approvals:
614
+ if job.finished_at:
615
+ filename = job.filename or "Unknown file"
616
+ activities.append({
617
+ "type": "document_approval",
618
+ "icon": "check_circle",
619
+ "title": "Document approved",
620
+ "description": f'"{filename}"',
621
+ "time_ago": format_time_ago(job.finished_at),
622
+ "timestamp": job.finished_at.isoformat(),
623
+ })
624
+
625
+ # 4. User role changes (from AuditLog - we'll look for role change patterns)
626
+ # For now, we'll use a simple approach: check audit logs for user-related changes
627
+ # In a real system, you might have a separate UserRoleChange model
628
+ role_changes = AuditLog.objects.filter(
629
+ path__contains='/admin/users/',
630
+ status=200
631
+ ).order_by('-created_at')[:5]
632
+
633
+ for log in role_changes:
634
+ # Extract username from path if possible
635
+ path_parts = log.path.split('/')
636
+ if len(path_parts) > 3:
637
+ user_id = path_parts[-2] if path_parts[-2].isdigit() else None
638
+ if user_id:
639
+ try:
640
+ user = User.objects.get(id=user_id)
641
+ activities.append({
642
+ "type": "user_role_change",
643
+ "icon": "person_add",
644
+ "title": "User role changed",
645
+ "description": f"{user.username} role updated",
646
+ "time_ago": format_time_ago(log.created_at),
647
+ "timestamp": log.created_at.isoformat(),
648
+ })
649
+ except User.DoesNotExist:
650
+ pass
651
+
652
+ # 5. Recent login attempts (from AuditLog - successful logins)
653
+ recent_logins = AuditLog.objects.filter(
654
+ path__contains='/auth/login/',
655
+ status=200
656
+ ).order_by('-created_at')[:3]
657
+
658
+ for log in recent_logins:
659
+ activities.append({
660
+ "type": "user_login",
661
+ "icon": "login",
662
+ "title": "User login",
663
+ "description": f"Successful login from {log.ip or 'unknown IP'}",
664
+ "time_ago": format_time_ago(log.created_at),
665
+ "timestamp": log.created_at.isoformat(),
666
+ })
667
+
668
+ # 6. Recent document views/searches (from AuditLog - search and chat endpoints)
669
+ recent_searches = AuditLog.objects.filter(
670
+ Q(path__contains='/search/') | Q(path__contains='/chat/'),
671
+ status=200
672
+ ).order_by('-created_at')[:3]
673
+
674
+ for log in recent_searches:
675
+ activity_type = "document_search" if '/search/' in log.path else "chat_query"
676
+ activities.append({
677
+ "type": activity_type,
678
+ "icon": "search" if '/search/' in log.path else "chat",
679
+ "title": "Search query" if '/search/' in log.path else "Chat query",
680
+ "description": f"Query from {log.ip or 'unknown IP'}",
681
+ "time_ago": format_time_ago(log.created_at),
682
+ "timestamp": log.created_at.isoformat(),
683
+ })
684
+
685
+ # Sort all activities by timestamp (most recent first) and limit
686
+ activities.sort(key=lambda x: x['timestamp'], reverse=True)
687
+ activities = activities[:limit]
688
+
689
+ return Response({"results": activities})
690
+
691
+
692
+ def get_ip_location(ip_address):
693
+ """
694
+ Get location from IP address using ip-api.com (free tier).
695
+ Returns location string like "Hue, Vietnam" or None if unavailable.
696
+ Caches results to avoid rate limits.
697
+ """
698
+ if not ip_address:
699
+ return None
700
+
701
+ # Skip local/private IPs
702
+ ip_str = str(ip_address)
703
+ if ip_str.startswith(('127.', '192.168.', '10.', '172.16.', '172.17.', '172.18.', '172.19.', '172.20.', '172.21.', '172.22.', '172.23.', '172.24.', '172.25.', '172.26.', '172.27.', '172.28.', '172.29.', '172.30.', '172.31.')):
704
+ return None
705
+
706
+ # Check cache first
707
+ cache_key = f"ip_location_{ip_str}"
708
+ cached_location = cache.get(cache_key)
709
+ if cached_location is not None:
710
+ return cached_location
711
+
712
+ try:
713
+ import requests
714
+ # Use ip-api.com free tier (45 requests/minute)
715
+ response = requests.get(
716
+ f"http://ip-api.com/json/{ip_str}",
717
+ params={"fields": "status,message,city,country"},
718
+ timeout=2
719
+ )
720
+ if response.status_code == 200:
721
+ data = response.json()
722
+ if data.get("status") == "success":
723
+ city = data.get("city", "")
724
+ country = data.get("country", "")
725
+ if city and country:
726
+ location = f"{city}, {country}"
727
+ # Cache for 24 hours
728
+ cache.set(cache_key, location, 86400)
729
+ return location
730
+ except Exception:
731
+ # Silently fail - don't block the request
732
+ pass
733
+
734
+ return None
735
+
736
+
737
+ class AdminSystemLogsStatsView(APIView):
738
+ """Get System Logs statistics for 3 cards. Admin only."""
739
+ permission_classes = [IsAdminPermission]
740
+
741
+ def get(self, request):
742
+ now = timezone.now()
743
+ today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
744
+ last_24h_start = now - timedelta(hours=24)
745
+ previous_24h_start = last_24h_start - timedelta(hours=24)
746
+
747
+ # Active Users: unique IPs in last 24h
748
+ active_users_last_24h = AuditLog.objects.filter(
749
+ created_at__gte=last_24h_start,
750
+ ip__isnull=False
751
+ ).values('ip').distinct().count()
752
+
753
+ active_users_prev_24h = AuditLog.objects.filter(
754
+ created_at__gte=previous_24h_start,
755
+ created_at__lt=last_24h_start,
756
+ ip__isnull=False
757
+ ).values('ip').distinct().count()
758
+
759
+ active_users_change = 0.0
760
+ if active_users_prev_24h > 0:
761
+ active_users_change = ((active_users_last_24h - active_users_prev_24h) / active_users_prev_24h) * 100
762
+ elif active_users_last_24h > 0:
763
+ active_users_change = 100.0
764
+
765
+ # Total Devices 24h: unique device types in last 24h
766
+ # We need to parse user_agent for each log to get device type
767
+ logs_last_24h = AuditLog.objects.filter(created_at__gte=last_24h_start)
768
+ device_types_set = set()
769
+ for log in logs_last_24h[:1000]: # Limit to avoid too many queries
770
+ parsed = parse_user_agent(log.user_agent)
771
+ device_type = parsed["device_type"]
772
+ if device_type == "mobile" or device_type == "tablet":
773
+ device_types_set.add("Mobile & Tablet")
774
+ elif device_type == "desktop":
775
+ device_types_set.add("Desktop")
776
+ else:
777
+ device_types_set.add("Unknown")
778
+
779
+ total_devices_24h = len(device_types_set)
780
+
781
+ # For previous period, do similar calculation
782
+ logs_prev_24h = AuditLog.objects.filter(
783
+ created_at__gte=previous_24h_start,
784
+ created_at__lt=last_24h_start
785
+ )
786
+ device_types_prev_set = set()
787
+ for log in logs_prev_24h[:1000]:
788
+ parsed = parse_user_agent(log.user_agent)
789
+ device_type = parsed["device_type"]
790
+ if device_type == "mobile" or device_type == "tablet":
791
+ device_types_prev_set.add("Mobile & Tablet")
792
+ elif device_type == "desktop":
793
+ device_types_prev_set.add("Desktop")
794
+ else:
795
+ device_types_prev_set.add("Unknown")
796
+
797
+ total_devices_prev_24h = len(device_types_prev_set)
798
+
799
+ total_devices_change = 0.0
800
+ if total_devices_prev_24h > 0:
801
+ total_devices_change = ((total_devices_24h - total_devices_prev_24h) / total_devices_prev_24h) * 100
802
+ elif total_devices_24h > 0:
803
+ total_devices_change = 100.0
804
+
805
+ # Accesses Today: total requests today
806
+ accesses_today = AuditLog.objects.filter(created_at__gte=today_start).count()
807
+ yesterday_start = today_start - timedelta(days=1)
808
+ accesses_yesterday = AuditLog.objects.filter(
809
+ created_at__gte=yesterday_start,
810
+ created_at__lt=today_start
811
+ ).count()
812
+
813
+ accesses_today_change = 0.0
814
+ if accesses_yesterday > 0:
815
+ accesses_today_change = ((accesses_today - accesses_yesterday) / accesses_yesterday) * 100
816
+ elif accesses_today > 0:
817
+ accesses_today_change = 100.0
818
+
819
+ return Response({
820
+ "active_users": active_users_last_24h,
821
+ "active_users_change": round(active_users_change, 1),
822
+ "total_devices_24h": total_devices_24h,
823
+ "total_devices_change": round(total_devices_change, 1),
824
+ "accesses_today": accesses_today,
825
+ "accesses_today_change": round(accesses_today_change, 1),
826
+ })
827
+
828
+
829
+ class AdminSystemLogsDeviceStatsView(APIView):
830
+ """Get device type statistics for donut chart. Admin only."""
831
+ permission_classes = [IsAdminPermission]
832
+
833
+ def get(self, request):
834
+ now = timezone.now()
835
+ last_24h_start = now - timedelta(hours=24)
836
+
837
+ logs = AuditLog.objects.filter(created_at__gte=last_24h_start)
838
+
839
+ desktop_count = 0
840
+ mobile_tablet_count = 0
841
+
842
+ for log in logs:
843
+ parsed = parse_user_agent(log.user_agent)
844
+ device_type = parsed["device_type"]
845
+ if device_type == "mobile" or device_type == "tablet":
846
+ mobile_tablet_count += 1
847
+ elif device_type == "desktop":
848
+ desktop_count += 1
849
+
850
+ total = desktop_count + mobile_tablet_count
851
+
852
+ device_types = []
853
+ if desktop_count > 0:
854
+ device_types.append({
855
+ "type": "Desktop",
856
+ "count": desktop_count,
857
+ "percentage": round((desktop_count / total * 100) if total > 0 else 0, 1)
858
+ })
859
+ if mobile_tablet_count > 0:
860
+ device_types.append({
861
+ "type": "Mobile & Tablet",
862
+ "count": mobile_tablet_count,
863
+ "percentage": round((mobile_tablet_count / total * 100) if total > 0 else 0, 1)
864
+ })
865
+
866
+ return Response({
867
+ "total": total,
868
+ "device_types": device_types,
869
+ })
870
+
871
+
872
+ class AdminSystemLogsUsageOverTimeView(APIView):
873
+ """Get usage over time data for bar chart (7 days). Admin only."""
874
+ permission_classes = [IsAdminPermission]
875
+
876
+ def get(self, request):
877
+ now = timezone.now()
878
+ today = timezone.localdate()
879
+
880
+ # Calculate start of last 7 days (inclusive of today)
881
+ last_7_days_start = timezone.make_aware(datetime.combine(today - timedelta(days=6), time.min))
882
+
883
+ # Get logs created in last 7 days, grouped by day
884
+ logs_last_7 = AuditLog.objects.filter(
885
+ created_at__gte=last_7_days_start
886
+ ).annotate(
887
+ date=TruncDate('created_at', tzinfo=timezone.get_current_timezone())
888
+ ).values('date').annotate(
889
+ count=Count('id')
890
+ ).order_by('date')
891
+
892
+ daily_counts_dict = {item['date']: item['count'] for item in logs_last_7}
893
+
894
+ # Build daily data array for the last 7 days (from 6 days ago to today)
895
+ daily_data = []
896
+ for i in range(6, -1, -1): # 6 days ago to today
897
+ day_date = today - timedelta(days=i)
898
+ day_name = day_date.strftime('%a') # Get actual day name (Mon, Tue, etc.)
899
+ count = daily_counts_dict.get(day_date, 0)
900
+ daily_data.append({"day": day_name, "count": count})
901
+
902
+ return Response({
903
+ "daily_data": daily_data,
904
+ })
905
+
906
+
907
+ def get_document_status(doc: LegalDocument) -> str:
908
+ """Determine document status based on latest IngestionJob."""
909
+ latest_job = doc.ingestion_jobs.order_by('-created_at').first()
910
+ if latest_job and latest_job.status == IngestionJob.STATUS_COMPLETED:
911
+ return "active"
912
+ return "archived"
913
+
914
+
915
+ def get_document_category(doc: LegalDocument) -> str:
916
+ """Map doc_type to display category name."""
917
+ category_map = {
918
+ "decision": "Decision",
919
+ "circular": "Circular",
920
+ "guideline": "Guideline",
921
+ "plan": "Plan",
922
+ "other": "Other",
923
+ }
924
+ return category_map.get(doc.doc_type, doc.doc_type.title())
925
+
926
+
927
+ def get_file_type_display(mime_type: str) -> str:
928
+ """Map mime_type to display name."""
929
+ if "pdf" in mime_type.lower():
930
+ return "PDF"
931
+ elif "wordprocessingml" in mime_type.lower() or "msword" in mime_type.lower():
932
+ return "DOCX"
933
+ elif "spreadsheetml" in mime_type.lower():
934
+ return "XLSX"
935
+ elif "presentationml" in mime_type.lower():
936
+ return "PPTX"
937
+ else:
938
+ return "Other"
939
+
940
+
941
+ class AdminDocumentListView(APIView):
942
+ """List documents with pagination, search, and filters. Admin only."""
943
+ permission_classes = [IsAdminPermission]
944
+
945
+ def get(self, request):
946
+ # Pagination params
947
+ page = int(request.query_params.get("page", 1))
948
+ page_size = int(request.query_params.get("page_size", 10))
949
+
950
+ # Search param
951
+ search = request.query_params.get("search", "").strip()
952
+
953
+ # Filter params
954
+ category_filter = request.query_params.get("category") # doc_type
955
+ status_filter = request.query_params.get("status") # active/archived
956
+ file_type_filter = request.query_params.get("file_type") # PDF, DOCX, etc.
957
+ date_from = request.query_params.get("date_from")
958
+ date_to = request.query_params.get("date_to")
959
+
960
+ # Build queryset - ALWAYS query directly from database, NO CACHE
961
+ # This ensures frontend always gets the latest data from database
962
+ queryset = LegalDocument.objects.all().order_by("-created_at")
963
+
964
+ # Apply search filter
965
+ if search:
966
+ queryset = queryset.filter(
967
+ Q(title__icontains=search) |
968
+ Q(code__icontains=search) |
969
+ Q(summary__icontains=search)
970
+ )
971
+
972
+ # Apply category filter (doc_type)
973
+ if category_filter:
974
+ queryset = queryset.filter(doc_type=category_filter)
975
+
976
+ # Apply file type filter (mime_type)
977
+ if file_type_filter:
978
+ if file_type_filter.lower() == "pdf":
979
+ queryset = queryset.filter(mime_type__icontains="pdf")
980
+ elif file_type_filter.lower() == "docx":
981
+ queryset = queryset.filter(
982
+ Q(mime_type__icontains="wordprocessingml") |
983
+ Q(mime_type__icontains="msword")
984
+ )
985
+ elif file_type_filter.lower() == "other":
986
+ queryset = queryset.exclude(
987
+ Q(mime_type__icontains="pdf") |
988
+ Q(mime_type__icontains="wordprocessingml") |
989
+ Q(mime_type__icontains="msword")
990
+ )
991
+
992
+ # Apply date range filter
993
+ if date_from:
994
+ try:
995
+ from_date = datetime.strptime(date_from, "%Y-%m-%d").date()
996
+ queryset = queryset.filter(created_at__date__gte=from_date)
997
+ except ValueError:
998
+ pass
999
+
1000
+ if date_to:
1001
+ try:
1002
+ to_date = datetime.strptime(date_to, "%Y-%m-%d").date()
1003
+ queryset = queryset.filter(created_at__date__lte=to_date)
1004
+ except ValueError:
1005
+ pass
1006
+
1007
+ # Apply status filter (based on IngestionJob)
1008
+ if status_filter:
1009
+ if status_filter == "active":
1010
+ # Documents with at least one completed ingestion job
1011
+ queryset = queryset.filter(
1012
+ ingestion_jobs__status=IngestionJob.STATUS_COMPLETED
1013
+ ).distinct()
1014
+ elif status_filter == "archived":
1015
+ # Documents without completed ingestion jobs
1016
+ completed_doc_ids = LegalDocument.objects.filter(
1017
+ ingestion_jobs__status=IngestionJob.STATUS_COMPLETED
1018
+ ).values_list('id', flat=True).distinct()
1019
+ queryset = queryset.exclude(id__in=completed_doc_ids)
1020
+
1021
+ # Get total count before pagination
1022
+ total_count = queryset.count()
1023
+
1024
+ # Apply pagination
1025
+ start = (page - 1) * page_size
1026
+ end = start + page_size
1027
+ documents = queryset[start:end]
1028
+
1029
+ results = []
1030
+ for doc in documents:
1031
+ # Determine status
1032
+ status = get_document_status(doc)
1033
+
1034
+ # Get file type display
1035
+ file_type_display = get_file_type_display(doc.mime_type or "")
1036
+
1037
+ results.append({
1038
+ "id": doc.id,
1039
+ "code": doc.code,
1040
+ "title": doc.title,
1041
+ "doc_type": doc.doc_type,
1042
+ "category": get_document_category(doc),
1043
+ "date_uploaded": doc.created_at.isoformat(),
1044
+ "status": status,
1045
+ "file_type": doc.mime_type or "",
1046
+ "file_type_display": file_type_display,
1047
+ "file_size": doc.file_size,
1048
+ "page_count": doc.page_count,
1049
+ "created_at": doc.created_at.isoformat(),
1050
+ "updated_at": doc.updated_at.isoformat(),
1051
+ })
1052
+
1053
+ return Response({
1054
+ "results": results,
1055
+ "count": total_count,
1056
+ "page": page,
1057
+ "page_size": page_size,
1058
+ })
1059
+
1060
+
1061
+ class AdminDocumentDetailView(APIView):
1062
+ """Get, update, or delete document. Admin only."""
1063
+ permission_classes = [IsAdminPermission]
1064
+
1065
+ def get(self, request, doc_id):
1066
+ try:
1067
+ doc = LegalDocument.objects.get(id=doc_id)
1068
+ except LegalDocument.DoesNotExist:
1069
+ return Response({"detail": "Document not found."}, status=status.HTTP_404_NOT_FOUND)
1070
+
1071
+ serializer = LegalDocumentSerializer(doc, context={"request": request})
1072
+ data = serializer.data
1073
+
1074
+ # Add computed fields
1075
+ data["status"] = get_document_status(doc)
1076
+ data["category"] = get_document_category(doc)
1077
+ data["file_type_display"] = get_file_type_display(doc.mime_type or "")
1078
+
1079
+ return Response(data)
1080
+
1081
+ def patch(self, request, doc_id):
1082
+ try:
1083
+ doc = LegalDocument.objects.get(id=doc_id)
1084
+ except LegalDocument.DoesNotExist:
1085
+ return Response({"detail": "Document not found."}, status=status.HTTP_404_NOT_FOUND)
1086
+
1087
+ # Update allowed fields
1088
+ allowed_fields = ["title", "code", "doc_type", "summary", "issued_by", "issued_at", "source_url"]
1089
+ for field in allowed_fields:
1090
+ if field in request.data:
1091
+ setattr(doc, field, request.data[field])
1092
+
1093
+ doc.save()
1094
+
1095
+ serializer = LegalDocumentSerializer(doc, context={"request": request})
1096
+ data = serializer.data
1097
+ data["status"] = get_document_status(doc)
1098
+ data["category"] = get_document_category(doc)
1099
+ data["file_type_display"] = get_file_type_display(doc.mime_type or "")
1100
+
1101
+ return Response(data)
1102
+
1103
+ def delete(self, request, doc_id):
1104
+ try:
1105
+ doc = LegalDocument.objects.get(id=doc_id)
1106
+ except LegalDocument.DoesNotExist:
1107
+ return Response({"detail": "Document not found."}, status=status.HTTP_404_NOT_FOUND)
1108
+
1109
+ # Delete related objects
1110
+ LegalSection.objects.filter(document=doc).delete()
1111
+ LegalDocumentImage.objects.filter(document=doc).delete()
1112
+ IngestionJob.objects.filter(document=doc).delete()
1113
+
1114
+ # Delete the document
1115
+ doc.delete()
1116
+
1117
+ return Response({"message": "Document deleted successfully."}, status=status.HTTP_200_OK)
1118
+
1119
+
1120
+ class AdminDocumentImportView(APIView):
1121
+ """Import document. Admin only. Reuses legal_document_upload logic."""
1122
+ permission_classes = [IsAdminPermission]
1123
+ parser_classes = [MultiPartParser, FormParser]
1124
+
1125
+ def post(self, request):
1126
+ from .services import enqueue_ingestion_job
1127
+
1128
+ upload = request.FILES.get("file")
1129
+ if not upload:
1130
+ return Response({"error": "file is required"}, status=status.HTTP_400_BAD_REQUEST)
1131
+
1132
+ code = (request.data.get("code") or "").strip()
1133
+ if not code:
1134
+ return Response({"error": "code is required"}, status=status.HTTP_400_BAD_REQUEST)
1135
+
1136
+ metadata = {
1137
+ "code": code,
1138
+ "title": request.data.get("title") or code,
1139
+ "doc_type": request.data.get("doc_type", "other"),
1140
+ "summary": request.data.get("summary", ""),
1141
+ "issued_by": request.data.get("issued_by", ""),
1142
+ "issued_at": request.data.get("issued_at"),
1143
+ "source_url": request.data.get("source_url", ""),
1144
+ "mime_type": request.data.get("mime_type") or getattr(upload, "content_type", ""),
1145
+ "metadata": {},
1146
+ }
1147
+ extra_meta = request.data.get("metadata")
1148
+ if extra_meta:
1149
+ import json
1150
+ try:
1151
+ metadata["metadata"] = json.loads(extra_meta) if isinstance(extra_meta, str) else extra_meta
1152
+ except Exception:
1153
+ return Response({"error": "metadata must be valid JSON"}, status=status.HTTP_400_BAD_REQUEST)
1154
+
1155
+ try:
1156
+ job = enqueue_ingestion_job(
1157
+ file_obj=upload,
1158
+ filename=upload.name,
1159
+ metadata=metadata,
1160
+ )
1161
+ except ValueError as exc:
1162
+ return Response({"error": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
1163
+ except Exception as exc:
1164
+ return Response({"error": str(exc)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
1165
+
1166
+ serialized = IngestionJobSerializer(job, context={"request": request}).data
1167
+ return Response(serialized, status=status.HTTP_202_ACCEPTED)
1168
+