test/custom-admin-ui #3
+12
-1
@@ -1,5 +1,5 @@
|
||||
import datetime
|
||||
from typing import Optional, Union
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -61,3 +61,14 @@ class FileMetadataData(BaseModel):
|
||||
id: int
|
||||
note: Optional[str] = None
|
||||
tags: Optional[list[str]] = None
|
||||
|
||||
|
||||
class FileViewPresetData(BaseModel):
|
||||
id: Optional[str] = None
|
||||
name: str
|
||||
filters: Optional[dict[str, Any]] = None
|
||||
params: Optional[dict[str, Any]] = None
|
||||
|
||||
|
||||
class FileViewPresetDeleteData(BaseModel):
|
||||
id: str
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import hashlib
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
@@ -16,9 +17,13 @@ from core.utils import get_now, hash_password, is_password_hashed
|
||||
|
||||
class FileService:
|
||||
FILE_METADATA_KEY_PREFIX = "admin_file_metadata:"
|
||||
FILE_VIEW_PRESETS_KEY = "admin_file_view_presets"
|
||||
MAX_METADATA_NOTE_LENGTH = 2000
|
||||
MAX_METADATA_TAGS = 12
|
||||
MAX_METADATA_TAG_LENGTH = 24
|
||||
MAX_VIEW_PRESETS = 24
|
||||
MAX_VIEW_PRESET_NAME_LENGTH = 32
|
||||
MAX_VIEW_PRESET_KEYWORD_LENGTH = 80
|
||||
|
||||
POLICY_ACTIONS = {
|
||||
"extend_24h",
|
||||
@@ -38,6 +43,28 @@ class FileService:
|
||||
"usedcount",
|
||||
"code",
|
||||
}
|
||||
VIEW_PRESET_STATUS_VALUES = {"all", "active", "expired"}
|
||||
VIEW_PRESET_TYPE_VALUES = {"all", "file", "text", "chunked"}
|
||||
VIEW_PRESET_HEALTH_VALUES = {
|
||||
"all",
|
||||
"attention",
|
||||
"danger",
|
||||
"warning",
|
||||
"healthy",
|
||||
"expired",
|
||||
"expiring_soon",
|
||||
"storage_issue",
|
||||
"never_retrieved",
|
||||
"permanent",
|
||||
}
|
||||
VIEW_PRESET_SORT_FIELDS = {
|
||||
"created_at",
|
||||
"expired_at",
|
||||
"name",
|
||||
"size",
|
||||
"used_count",
|
||||
"code",
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
self.file_storage: FileStorageInterface = storages[settings.file_storage]()
|
||||
@@ -178,6 +205,77 @@ class FileService:
|
||||
)
|
||||
return await self.get_file_detail(file_id)
|
||||
|
||||
async def list_file_view_presets(self) -> dict[str, Any]:
|
||||
presets = await self._get_file_view_presets()
|
||||
return {
|
||||
"presets": presets,
|
||||
"items": presets,
|
||||
"total": len(presets),
|
||||
}
|
||||
|
||||
async def save_file_view_preset(
|
||||
self,
|
||||
preset_id: Optional[str],
|
||||
name: str,
|
||||
filters: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
presets = await self._get_file_view_presets()
|
||||
normalized_name = self._normalize_file_view_preset_name(name)
|
||||
normalized_filters = self._normalize_file_view_preset_filters(filters)
|
||||
now = await get_now()
|
||||
updated_at = now.isoformat()
|
||||
|
||||
target_index = next(
|
||||
(index for index, preset in enumerate(presets) if preset["id"] == preset_id),
|
||||
-1,
|
||||
)
|
||||
if target_index >= 0:
|
||||
preset = presets[target_index]
|
||||
next_preset = {
|
||||
**preset,
|
||||
"name": normalized_name,
|
||||
"filters": normalized_filters,
|
||||
"params": normalized_filters,
|
||||
"updatedAt": updated_at,
|
||||
"updated_at": updated_at,
|
||||
}
|
||||
presets[target_index] = next_preset
|
||||
else:
|
||||
if len(presets) >= self.MAX_VIEW_PRESETS:
|
||||
raise HTTPException(status_code=400, detail="视图预设数量已达上限")
|
||||
next_preset = {
|
||||
"id": preset_id or self._build_file_view_preset_id(normalized_name, now),
|
||||
"name": normalized_name,
|
||||
"filters": normalized_filters,
|
||||
"params": normalized_filters,
|
||||
"createdAt": updated_at,
|
||||
"created_at": updated_at,
|
||||
"updatedAt": updated_at,
|
||||
"updated_at": updated_at,
|
||||
}
|
||||
presets.append(next_preset)
|
||||
|
||||
await self._save_file_view_presets(presets)
|
||||
return next_preset
|
||||
|
||||
async def delete_file_view_preset(self, preset_id: str) -> dict[str, Any]:
|
||||
preset_id = str(preset_id).strip()
|
||||
if not preset_id:
|
||||
raise HTTPException(status_code=400, detail="请选择要删除的视图预设")
|
||||
|
||||
presets = await self._get_file_view_presets()
|
||||
next_presets = [preset for preset in presets if preset["id"] != preset_id]
|
||||
if len(next_presets) == len(presets):
|
||||
raise HTTPException(status_code=404, detail="视图预设不存在")
|
||||
|
||||
await self._save_file_view_presets(next_presets)
|
||||
return {
|
||||
"deleted": preset_id,
|
||||
"deletedPresetId": preset_id,
|
||||
"deleted_preset_id": preset_id,
|
||||
"total": len(next_presets),
|
||||
}
|
||||
|
||||
async def apply_files_policy_action(
|
||||
self,
|
||||
file_ids: list[int],
|
||||
@@ -533,6 +631,120 @@ class FileService:
|
||||
"updated_at": updated_at,
|
||||
}
|
||||
|
||||
async def _get_file_view_presets(self) -> list[dict[str, Any]]:
|
||||
record = await KeyValue.filter(key=self.FILE_VIEW_PRESETS_KEY).first()
|
||||
raw_presets = record.value if record else []
|
||||
if isinstance(raw_presets, dict):
|
||||
raw_presets = raw_presets.get("presets") or raw_presets.get("items") or []
|
||||
if not isinstance(raw_presets, list):
|
||||
return []
|
||||
|
||||
presets = []
|
||||
seen_ids = set()
|
||||
for raw_preset in raw_presets:
|
||||
try:
|
||||
preset = self._normalize_file_view_preset(raw_preset)
|
||||
except HTTPException:
|
||||
continue
|
||||
if not preset or preset["id"] in seen_ids:
|
||||
continue
|
||||
seen_ids.add(preset["id"])
|
||||
presets.append(preset)
|
||||
if len(presets) >= self.MAX_VIEW_PRESETS:
|
||||
break
|
||||
return presets
|
||||
|
||||
async def _save_file_view_presets(self, presets: list[dict[str, Any]]) -> None:
|
||||
await KeyValue.update_or_create(
|
||||
key=self.FILE_VIEW_PRESETS_KEY,
|
||||
defaults={"value": {"presets": presets}},
|
||||
)
|
||||
|
||||
def _normalize_file_view_preset(self, preset: Any) -> Optional[dict[str, Any]]:
|
||||
if not isinstance(preset, dict):
|
||||
return None
|
||||
|
||||
preset_id = str(preset.get("id") or "").strip()
|
||||
raw_name = str(preset.get("name") or "").strip()
|
||||
if not raw_name:
|
||||
return None
|
||||
name = self._normalize_file_view_preset_name(raw_name)
|
||||
if not preset_id:
|
||||
preset_id = self._build_file_view_preset_id(name)
|
||||
|
||||
filters = preset.get("filters") or preset.get("params") or {}
|
||||
normalized_filters = self._normalize_file_view_preset_filters(filters)
|
||||
created_at = preset.get("createdAt") or preset.get("created_at")
|
||||
updated_at = preset.get("updatedAt") or preset.get("updated_at")
|
||||
|
||||
return {
|
||||
"id": preset_id,
|
||||
"name": name,
|
||||
"filters": normalized_filters,
|
||||
"params": normalized_filters,
|
||||
"createdAt": created_at,
|
||||
"created_at": created_at,
|
||||
"updatedAt": updated_at,
|
||||
"updated_at": updated_at,
|
||||
}
|
||||
|
||||
def _normalize_file_view_preset_name(self, name: Any) -> str:
|
||||
normalized_name = str(name or "").strip()
|
||||
if not normalized_name:
|
||||
raise HTTPException(status_code=400, detail="请输入视图名称")
|
||||
return normalized_name[: self.MAX_VIEW_PRESET_NAME_LENGTH]
|
||||
|
||||
def _normalize_file_view_preset_filters(self, filters: Any) -> dict[str, Any]:
|
||||
if not isinstance(filters, dict):
|
||||
filters = {}
|
||||
|
||||
sort_by = str(filters.get("sortBy") or filters.get("sort_by") or "created_at")
|
||||
sort_by = sort_by.replace("-", "_").strip().lower()
|
||||
if sort_by not in self.VIEW_PRESET_SORT_FIELDS:
|
||||
sort_by = "created_at"
|
||||
|
||||
sort_order = str(filters.get("sortOrder") or filters.get("sort_order") or "desc")
|
||||
sort_order = sort_order.strip().lower()
|
||||
if sort_order not in {"asc", "desc"}:
|
||||
sort_order = "desc"
|
||||
|
||||
size = filters.get("size", 10)
|
||||
try:
|
||||
size = int(size)
|
||||
except (TypeError, ValueError):
|
||||
size = 10
|
||||
|
||||
return {
|
||||
"keyword": str(filters.get("keyword") or "").strip()[
|
||||
: self.MAX_VIEW_PRESET_KEYWORD_LENGTH
|
||||
],
|
||||
"status": self._normalize_file_view_preset_choice(
|
||||
filters.get("status"), self.VIEW_PRESET_STATUS_VALUES
|
||||
),
|
||||
"type": self._normalize_file_view_preset_choice(
|
||||
filters.get("type"), self.VIEW_PRESET_TYPE_VALUES
|
||||
),
|
||||
"health": self._normalize_file_view_preset_choice(
|
||||
filters.get("health"), self.VIEW_PRESET_HEALTH_VALUES
|
||||
),
|
||||
"sortBy": sort_by,
|
||||
"sortOrder": sort_order,
|
||||
"size": min(max(size, 1), 100),
|
||||
}
|
||||
|
||||
def _normalize_file_view_preset_choice(self, value: Any, allowed_values: set[str]) -> str:
|
||||
normalized_value = str(value or "all").strip().lower()
|
||||
if normalized_value not in allowed_values:
|
||||
return "all"
|
||||
return normalized_value
|
||||
|
||||
def _build_file_view_preset_id(
|
||||
self, name: str, timestamp: Optional[datetime] = None
|
||||
) -> str:
|
||||
seed = int(timestamp.timestamp() * 1000) if timestamp else "saved"
|
||||
digest = hashlib.sha1(name.encode("utf-8")).hexdigest()[:8]
|
||||
return f"view_{seed}_{digest}"
|
||||
|
||||
def _build_file_status_insights(
|
||||
self,
|
||||
file_code: FileCodes,
|
||||
|
||||
@@ -21,6 +21,8 @@ from apps.admin.schemas import (
|
||||
BatchFilePolicyActionData,
|
||||
FilePolicyActionData,
|
||||
FileMetadataData,
|
||||
FileViewPresetData,
|
||||
FileViewPresetDeleteData,
|
||||
ShareItem,
|
||||
DeleteItem,
|
||||
LoginData,
|
||||
@@ -380,6 +382,67 @@ async def file_metadata_post(
|
||||
return await update_file_metadata(data, file_service)
|
||||
|
||||
|
||||
@admin_api.get("/file/view-presets")
|
||||
async def file_view_presets(
|
||||
file_service: FileService = Depends(get_file_service),
|
||||
):
|
||||
result = await file_service.list_file_view_presets()
|
||||
return APIResponse(detail=result)
|
||||
|
||||
|
||||
async def save_file_view_preset(
|
||||
data: FileViewPresetData,
|
||||
file_service: FileService,
|
||||
):
|
||||
filters = data.filters if data.filters is not None else data.params
|
||||
preset = await file_service.save_file_view_preset(
|
||||
preset_id=data.id,
|
||||
name=data.name,
|
||||
filters=filters or {},
|
||||
)
|
||||
return APIResponse(detail=preset)
|
||||
|
||||
|
||||
@admin_api.post("/file/view-presets")
|
||||
async def file_view_presets_save(
|
||||
data: FileViewPresetData,
|
||||
file_service: FileService = Depends(get_file_service),
|
||||
):
|
||||
return await save_file_view_preset(data, file_service)
|
||||
|
||||
|
||||
@admin_api.patch("/file/view-presets")
|
||||
async def file_view_presets_patch(
|
||||
data: FileViewPresetData,
|
||||
file_service: FileService = Depends(get_file_service),
|
||||
):
|
||||
return await save_file_view_preset(data, file_service)
|
||||
|
||||
|
||||
async def delete_file_view_preset(
|
||||
data: FileViewPresetDeleteData,
|
||||
file_service: FileService,
|
||||
):
|
||||
result = await file_service.delete_file_view_preset(data.id)
|
||||
return APIResponse(detail=result)
|
||||
|
||||
|
||||
@admin_api.delete("/file/view-presets")
|
||||
async def file_view_presets_delete(
|
||||
data: FileViewPresetDeleteData,
|
||||
file_service: FileService = Depends(get_file_service),
|
||||
):
|
||||
return await delete_file_view_preset(data, file_service)
|
||||
|
||||
|
||||
@admin_api.post("/file/view-presets/delete")
|
||||
async def file_view_presets_delete_post(
|
||||
data: FileViewPresetDeleteData,
|
||||
file_service: FileService = Depends(get_file_service),
|
||||
):
|
||||
return await delete_file_view_preset(data, file_service)
|
||||
|
||||
|
||||
@admin_api.get("/config/get")
|
||||
async def get_config(
|
||||
config_service: ConfigService = Depends(get_config_service),
|
||||
|
||||
Reference in New Issue
Block a user