[feat]:[FL-206][新增维度管理功能]
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -5,6 +5,7 @@ from .v1.admin_files import router as admin_files_router
|
||||
from .v1.ai_chat import router as ai_chat_router
|
||||
from .v1.atp_assets import router as atp_assets_router
|
||||
from .v1.auth import router as auth_router
|
||||
from .v1.dimensions import router as dimensions_router
|
||||
from .v1.documents import router as documents_router
|
||||
from .v1.elevation import router as elevation_router
|
||||
from .v1.fault_recurrence import router as fault_recurrence_router
|
||||
@@ -29,6 +30,7 @@ v1_router.include_router(admin_router)
|
||||
v1_router.include_router(admin_files_router)
|
||||
v1_router.include_router(ai_chat_router)
|
||||
v1_router.include_router(atp_assets_router)
|
||||
v1_router.include_router(dimensions_router)
|
||||
v1_router.include_router(documents_router)
|
||||
v1_router.include_router(task_monitor_router)
|
||||
v1_router.include_router(scheduled_tasks_router)
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ...core.database import get_db
|
||||
from ...core.dependencies import CurrentUser, require_any_permission, require_enabled_menu_route, require_permission
|
||||
from ...schemas.dimension_item import (
|
||||
DimensionItemCreateRequest,
|
||||
DimensionItemListResponse,
|
||||
DimensionItemSummary,
|
||||
DimensionItemTreeNode,
|
||||
DimensionItemUpdateRequest,
|
||||
)
|
||||
from ...services.dimension_item_service import (
|
||||
create_dimension_item,
|
||||
delete_dimension_item,
|
||||
get_dimension_item_by_id,
|
||||
get_dimension_tree,
|
||||
list_dimension_items,
|
||||
serialize_dimension_item,
|
||||
update_dimension_item,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/dimensions", tags=["dimensions"], dependencies=[Depends(require_enabled_menu_route)])
|
||||
|
||||
|
||||
@router.get("", response_model=DimensionItemListResponse)
|
||||
def get_dimension_item_list(
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
keyword: str | None = Query(default=None),
|
||||
dimension_type: str | None = Query(default=None),
|
||||
enabled: bool | None = Query(default=None),
|
||||
_: CurrentUser = Depends(require_any_permission("dimension.read", "dimension.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> DimensionItemListResponse:
|
||||
return list_dimension_items(
|
||||
db,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
keyword=keyword,
|
||||
dimension_type=dimension_type,
|
||||
enabled=enabled,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tree", response_model=list[DimensionItemTreeNode])
|
||||
def get_dimension_item_tree(
|
||||
dimension_type: str | None = Query(default=None),
|
||||
_: CurrentUser = Depends(require_any_permission("dimension.read", "dimension.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> list[DimensionItemTreeNode]:
|
||||
return get_dimension_tree(db, dimension_type=dimension_type)
|
||||
|
||||
|
||||
@router.post("", response_model=DimensionItemSummary)
|
||||
def create_dimension_item_endpoint(
|
||||
payload: DimensionItemCreateRequest,
|
||||
current_user: CurrentUser = Depends(require_permission("dimension.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> DimensionItemSummary:
|
||||
created = create_dimension_item(db, payload, actor=current_user.user)
|
||||
if not created:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="维度项编码已存在或父节点不存在")
|
||||
return created
|
||||
|
||||
|
||||
@router.patch("/{item_id}", response_model=DimensionItemSummary)
|
||||
def update_dimension_item_endpoint(
|
||||
item_id: str,
|
||||
payload: DimensionItemUpdateRequest,
|
||||
current_user: CurrentUser = Depends(require_permission("dimension.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> DimensionItemSummary:
|
||||
updated = update_dimension_item(db, item_id, payload, actor=current_user.user)
|
||||
if not updated:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="维度项不存在或更新失败")
|
||||
return updated
|
||||
|
||||
|
||||
@router.delete("/{item_id}")
|
||||
def delete_dimension_item_endpoint(
|
||||
item_id: str,
|
||||
_: CurrentUser = Depends(require_permission("dimension.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, bool]:
|
||||
deleted = delete_dimension_item(db, item_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="维度项不存在或存在子节点")
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.get("/{item_id}", response_model=DimensionItemSummary)
|
||||
def get_dimension_item_detail(
|
||||
item_id: str,
|
||||
_: CurrentUser = Depends(require_any_permission("dimension.read", "dimension.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> DimensionItemSummary:
|
||||
item = get_dimension_item_by_id(db, item_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="维度项不存在")
|
||||
return serialize_dimension_item(item)
|
||||
@@ -560,6 +560,7 @@ def init_db() -> None:
|
||||
atp_asset,
|
||||
audit_log,
|
||||
auth_session,
|
||||
dimension_item,
|
||||
elevation,
|
||||
file_storage,
|
||||
fl_analysis,
|
||||
|
||||
@@ -4,13 +4,14 @@ Import all model modules during package initialization so SQLAlchemy can
|
||||
resolve string-based relationships regardless of route/service import order.
|
||||
"""
|
||||
|
||||
from . import ai_chat, atp_asset, audit_log, auth_session, document, elevation, file_storage, fl_analysis, lightning_event, lightning_sample, line, line_tower, menu, object_group, rbac, scheduled_task, system_message, system_param, tower_model, tower_profile, user, wine, worker_registry
|
||||
from . import ai_chat, atp_asset, audit_log, auth_session, dimension_item, document, elevation, file_storage, fl_analysis, lightning_event, lightning_sample, line, line_tower, menu, object_group, rbac, scheduled_task, system_message, system_param, tower_model, tower_profile, user, wine, worker_registry
|
||||
|
||||
__all__ = [
|
||||
"ai_chat",
|
||||
"atp_asset",
|
||||
"audit_log",
|
||||
"auth_session",
|
||||
"dimension_item",
|
||||
"document",
|
||||
"elevation",
|
||||
"file_storage",
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Index, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from ..core.database import Base
|
||||
from .base import utcnow
|
||||
|
||||
|
||||
class DimensionItem(Base):
|
||||
__tablename__ = "dimension_item"
|
||||
__table_args__ = (
|
||||
Index("idx_dimension_item_type", "dimension_type"),
|
||||
Index("idx_dimension_item_parent", "parent_id"),
|
||||
Index("idx_dimension_item_code", "code"),
|
||||
Index("idx_dimension_item_enabled", "is_enabled"),
|
||||
Index("idx_dimension_item_sort", "sort_order"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
primary_key=True,
|
||||
default=lambda: uuid4().hex,
|
||||
)
|
||||
dimension_type: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
code: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
parent_id: Mapped[str | None] = mapped_column(String(32), index=True)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
is_enabled: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0, index=True)
|
||||
|
||||
create_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
|
||||
create_user: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||
update_date: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=utcnow,
|
||||
onupdate=utcnow,
|
||||
)
|
||||
update_user: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||
@@ -0,0 +1,60 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class DimensionItemSummary(BaseModel):
|
||||
id: str
|
||||
dimension_type: str
|
||||
code: str
|
||||
name: str
|
||||
parent_id: str | None = None
|
||||
description: str | None = None
|
||||
is_enabled: bool = True
|
||||
sort_order: int = 0
|
||||
create_date: datetime
|
||||
create_user: str | None = None
|
||||
update_date: datetime
|
||||
update_user: str | None = None
|
||||
|
||||
|
||||
class DimensionItemTreeNode(BaseModel):
|
||||
id: str
|
||||
dimension_type: str
|
||||
code: str
|
||||
name: str
|
||||
parent_id: str | None = None
|
||||
description: str | None = None
|
||||
is_enabled: bool = True
|
||||
sort_order: int = 0
|
||||
create_date: datetime
|
||||
create_user: str | None = None
|
||||
update_date: datetime
|
||||
update_user: str | None = None
|
||||
children: list[DimensionItemTreeNode] = Field(default_factory=list)
|
||||
|
||||
|
||||
class DimensionItemListResponse(BaseModel):
|
||||
items: list[DimensionItemSummary]
|
||||
total: int
|
||||
|
||||
|
||||
class DimensionItemCreateRequest(BaseModel):
|
||||
dimension_type: str = Field(min_length=1, max_length=64)
|
||||
code: str = Field(min_length=1, max_length=128)
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
parent_id: str | None = None
|
||||
description: str | None = Field(default=None, max_length=2000)
|
||||
is_enabled: bool = True
|
||||
sort_order: int = Field(default=0, ge=0, le=1_000_000)
|
||||
|
||||
|
||||
class DimensionItemUpdateRequest(BaseModel):
|
||||
code: str | None = Field(default=None, min_length=1, max_length=128)
|
||||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
parent_id: str | None = None
|
||||
description: str | None = Field(default=None, max_length=2000)
|
||||
is_enabled: bool | None = None
|
||||
sort_order: int | None = Field(default=None, ge=0, le=1_000_000)
|
||||
@@ -0,0 +1,225 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models.base import utcnow
|
||||
from ..models.dimension_item import DimensionItem
|
||||
from ..models.user import User
|
||||
from ..schemas.dimension_item import (
|
||||
DimensionItemCreateRequest,
|
||||
DimensionItemListResponse,
|
||||
DimensionItemSummary,
|
||||
DimensionItemTreeNode,
|
||||
DimensionItemUpdateRequest,
|
||||
)
|
||||
from .push_service import publish_topic
|
||||
|
||||
DIMENSION_ITEM_TOPIC = "admin.dimension-items"
|
||||
|
||||
|
||||
def serialize_dimension_item(item: DimensionItem) -> DimensionItemSummary:
|
||||
return DimensionItemSummary(
|
||||
id=item.id,
|
||||
dimension_type=item.dimension_type,
|
||||
code=item.code,
|
||||
name=item.name,
|
||||
parent_id=item.parent_id,
|
||||
description=item.description,
|
||||
is_enabled=item.is_enabled,
|
||||
sort_order=item.sort_order,
|
||||
create_date=item.create_date,
|
||||
create_user=item.create_user,
|
||||
update_date=item.update_date,
|
||||
update_user=item.update_user,
|
||||
)
|
||||
|
||||
|
||||
def list_dimension_items(
|
||||
db: Session,
|
||||
*,
|
||||
limit: int,
|
||||
offset: int,
|
||||
keyword: str | None,
|
||||
dimension_type: str | None,
|
||||
enabled: bool | None,
|
||||
) -> DimensionItemListResponse:
|
||||
stmt = select(DimensionItem)
|
||||
total_stmt = select(func.count()).select_from(DimensionItem)
|
||||
|
||||
if dimension_type:
|
||||
stmt = stmt.where(DimensionItem.dimension_type == dimension_type)
|
||||
total_stmt = total_stmt.where(DimensionItem.dimension_type == dimension_type)
|
||||
|
||||
normalized_keyword = (keyword or "").strip()
|
||||
if normalized_keyword:
|
||||
like = f"%{normalized_keyword}%"
|
||||
predicate = or_(
|
||||
DimensionItem.code.ilike(like),
|
||||
DimensionItem.name.ilike(like),
|
||||
)
|
||||
stmt = stmt.where(predicate)
|
||||
total_stmt = total_stmt.where(predicate)
|
||||
|
||||
if enabled is not None:
|
||||
stmt = stmt.where(DimensionItem.is_enabled == enabled)
|
||||
total_stmt = total_stmt.where(DimensionItem.is_enabled == enabled)
|
||||
|
||||
total = int(db.scalar(total_stmt) or 0)
|
||||
stmt = stmt.order_by(DimensionItem.sort_order, DimensionItem.create_date.desc()).limit(limit).offset(offset)
|
||||
items = list(db.scalars(stmt).all())
|
||||
|
||||
return DimensionItemListResponse(
|
||||
items=[serialize_dimension_item(item) for item in items],
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
def get_dimension_tree(
|
||||
db: Session,
|
||||
dimension_type: str | None = None,
|
||||
) -> list[DimensionItemTreeNode]:
|
||||
stmt = select(DimensionItem).order_by(DimensionItem.sort_order, DimensionItem.create_date)
|
||||
|
||||
if dimension_type:
|
||||
stmt = stmt.where(DimensionItem.dimension_type == dimension_type)
|
||||
|
||||
items = list(db.scalars(stmt).all())
|
||||
|
||||
item_map: dict[str, DimensionItemTreeNode] = {}
|
||||
for item in items:
|
||||
item_map[item.id] = DimensionItemTreeNode(
|
||||
id=item.id,
|
||||
dimension_type=item.dimension_type,
|
||||
code=item.code,
|
||||
name=item.name,
|
||||
parent_id=item.parent_id,
|
||||
description=item.description,
|
||||
is_enabled=item.is_enabled,
|
||||
sort_order=item.sort_order,
|
||||
create_date=item.create_date,
|
||||
create_user=item.create_user,
|
||||
update_date=item.update_date,
|
||||
update_user=item.update_user,
|
||||
children=[],
|
||||
)
|
||||
|
||||
root_nodes: list[DimensionItemTreeNode] = []
|
||||
for node in item_map.values():
|
||||
if node.parent_id and node.parent_id in item_map:
|
||||
item_map[node.parent_id].children.append(node)
|
||||
else:
|
||||
root_nodes.append(node)
|
||||
|
||||
return root_nodes
|
||||
|
||||
|
||||
def get_dimension_item_by_id(db: Session, item_id: str) -> DimensionItem | None:
|
||||
return db.scalar(select(DimensionItem).where(DimensionItem.id == item_id))
|
||||
|
||||
|
||||
def create_dimension_item(
|
||||
db: Session,
|
||||
payload: DimensionItemCreateRequest,
|
||||
actor: User,
|
||||
) -> DimensionItemSummary | None:
|
||||
existing = db.scalar(
|
||||
select(DimensionItem).where(
|
||||
DimensionItem.dimension_type == payload.dimension_type,
|
||||
DimensionItem.code == payload.code,
|
||||
)
|
||||
)
|
||||
if existing:
|
||||
return None
|
||||
|
||||
if payload.parent_id:
|
||||
parent = get_dimension_item_by_id(db, payload.parent_id)
|
||||
if not parent:
|
||||
return None
|
||||
|
||||
item = DimensionItem(
|
||||
dimension_type=payload.dimension_type,
|
||||
code=payload.code,
|
||||
name=payload.name,
|
||||
parent_id=payload.parent_id,
|
||||
description=payload.description,
|
||||
is_enabled=payload.is_enabled,
|
||||
sort_order=payload.sort_order,
|
||||
create_user=actor.id,
|
||||
update_user=actor.id,
|
||||
)
|
||||
db.add(item)
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
|
||||
publish_topic(DIMENSION_ITEM_TOPIC)
|
||||
return serialize_dimension_item(item)
|
||||
|
||||
|
||||
def update_dimension_item(
|
||||
db: Session,
|
||||
item_id: str,
|
||||
payload: DimensionItemUpdateRequest,
|
||||
actor: User,
|
||||
) -> DimensionItemSummary | None:
|
||||
item = get_dimension_item_by_id(db, item_id)
|
||||
if not item:
|
||||
return None
|
||||
|
||||
if payload.code is not None:
|
||||
existing = db.scalar(
|
||||
select(DimensionItem).where(
|
||||
DimensionItem.dimension_type == item.dimension_type,
|
||||
DimensionItem.code == payload.code,
|
||||
DimensionItem.id != item_id,
|
||||
)
|
||||
)
|
||||
if existing:
|
||||
return None
|
||||
item.code = payload.code
|
||||
|
||||
if payload.name is not None:
|
||||
item.name = payload.name
|
||||
|
||||
if payload.parent_id is not None:
|
||||
if payload.parent_id:
|
||||
parent = get_dimension_item_by_id(db, payload.parent_id)
|
||||
if not parent:
|
||||
return None
|
||||
if payload.parent_id == item_id:
|
||||
return None
|
||||
item.parent_id = payload.parent_id
|
||||
|
||||
if payload.description is not None:
|
||||
item.description = payload.description
|
||||
|
||||
if payload.is_enabled is not None:
|
||||
item.is_enabled = payload.is_enabled
|
||||
|
||||
if payload.sort_order is not None:
|
||||
item.sort_order = payload.sort_order
|
||||
|
||||
item.update_date = utcnow()
|
||||
item.update_user = actor.id
|
||||
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
|
||||
publish_topic(DIMENSION_ITEM_TOPIC)
|
||||
return serialize_dimension_item(item)
|
||||
|
||||
|
||||
def delete_dimension_item(db: Session, item_id: str) -> bool:
|
||||
item = get_dimension_item_by_id(db, item_id)
|
||||
if not item:
|
||||
return False
|
||||
|
||||
children = db.scalars(select(DimensionItem).where(DimensionItem.parent_id == item_id)).all()
|
||||
if children:
|
||||
return False
|
||||
|
||||
db.delete(item)
|
||||
db.commit()
|
||||
|
||||
publish_topic(DIMENSION_ITEM_TOPIC)
|
||||
return True
|
||||
Reference in New Issue
Block a user