chore: sync workspace changes
This commit is contained in:
@@ -3,7 +3,9 @@ from fastapi import APIRouter
|
||||
from .v1.admin import router as admin_router
|
||||
from .v1.admin_files import router as admin_files_router
|
||||
from .v1.auth import router as auth_router
|
||||
from .v1.chat import router as chat_router
|
||||
from .v1.requirements import router as requirements_router
|
||||
from .v1.todos import router as todos_router
|
||||
from .v1.users import router as users_router
|
||||
from .v1.ws import router as ws_router
|
||||
|
||||
@@ -13,6 +15,8 @@ api_router.include_router(users_router)
|
||||
api_router.include_router(admin_router)
|
||||
api_router.include_router(admin_files_router)
|
||||
api_router.include_router(requirements_router)
|
||||
api_router.include_router(todos_router)
|
||||
api_router.include_router(chat_router)
|
||||
api_router.include_router(ws_router)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ...core.database import get_db
|
||||
from ...core.dependencies import CurrentUser, require_permission
|
||||
from ...schemas.chat import (
|
||||
ChatMessageCreateRequest,
|
||||
ChatMessageListResponse,
|
||||
ChatSendResponse,
|
||||
ChatSessionCreateRequest,
|
||||
ChatSessionListResponse,
|
||||
ChatSessionPublic,
|
||||
)
|
||||
from ...services.chat_service import create_session, list_messages, list_sessions, send_message
|
||||
|
||||
router = APIRouter(prefix="/chat", tags=["chat"])
|
||||
|
||||
|
||||
@router.get("/sessions", response_model=ChatSessionListResponse)
|
||||
def get_chat_sessions(
|
||||
current_user: CurrentUser = Depends(require_permission("chat.use")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ChatSessionListResponse:
|
||||
return list_sessions(db, actor=current_user.user)
|
||||
|
||||
|
||||
@router.post("/sessions", response_model=ChatSessionPublic)
|
||||
def create_chat_session(
|
||||
payload: ChatSessionCreateRequest,
|
||||
current_user: CurrentUser = Depends(require_permission("chat.use")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ChatSessionPublic:
|
||||
return create_session(db, payload, actor=current_user.user)
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}/messages", response_model=ChatMessageListResponse)
|
||||
def get_chat_messages(
|
||||
session_id: str,
|
||||
limit: int = Query(default=200, ge=1, le=500),
|
||||
current_user: CurrentUser = Depends(require_permission("chat.use")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ChatMessageListResponse:
|
||||
return list_messages(db, session_id=session_id, actor=current_user.user, limit=limit)
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/messages", response_model=ChatSendResponse)
|
||||
def send_chat_message(
|
||||
session_id: str,
|
||||
payload: ChatMessageCreateRequest,
|
||||
current_user: CurrentUser = Depends(require_permission("chat.use")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ChatSendResponse:
|
||||
return send_message(db, session_id=session_id, payload=payload, actor=current_user.user)
|
||||
@@ -0,0 +1,91 @@
|
||||
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_permission
|
||||
from ...schemas.todo import (
|
||||
TodoCreateRequest,
|
||||
TodoListResponse,
|
||||
TodoSummary,
|
||||
TodoTransitionRequest,
|
||||
TodoUpdateRequest,
|
||||
)
|
||||
from ...services.todo_service import (
|
||||
create_todo,
|
||||
delete_todo,
|
||||
get_todo_by_id,
|
||||
list_todos,
|
||||
serialize_todo,
|
||||
transition_todo,
|
||||
update_todo,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/todos", tags=["todos"])
|
||||
|
||||
|
||||
@router.get("", response_model=TodoListResponse)
|
||||
def get_todo_list(
|
||||
keyword: str | None = Query(default=None),
|
||||
status_filter: str | None = Query(default=None, alias="status"),
|
||||
priority: str | None = Query(default=None),
|
||||
assignee_user_id: str | None = Query(default=None),
|
||||
_: CurrentUser = Depends(require_permission("todo.read")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> TodoListResponse:
|
||||
return list_todos(
|
||||
db,
|
||||
keyword=keyword,
|
||||
status_filter=status_filter,
|
||||
priority=priority,
|
||||
assignee_user_id=assignee_user_id,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=TodoSummary)
|
||||
def create_todo_endpoint(
|
||||
payload: TodoCreateRequest,
|
||||
current_user: CurrentUser = Depends(require_any_permission("todo.create", "todo.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> TodoSummary:
|
||||
return create_todo(db, payload, actor=current_user.user)
|
||||
|
||||
|
||||
@router.get("/{todo_id}", response_model=TodoSummary)
|
||||
def get_todo_detail(
|
||||
todo_id: str,
|
||||
_: CurrentUser = Depends(require_permission("todo.read")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> TodoSummary:
|
||||
todo = get_todo_by_id(db, todo_id)
|
||||
if not todo:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Todo not found")
|
||||
return serialize_todo(todo)
|
||||
|
||||
|
||||
@router.patch("/{todo_id}", response_model=TodoSummary)
|
||||
def update_todo_endpoint(
|
||||
todo_id: str,
|
||||
payload: TodoUpdateRequest,
|
||||
current_user: CurrentUser = Depends(require_any_permission("todo.process", "todo.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> TodoSummary:
|
||||
return update_todo(db, todo_id, payload, actor=current_user.user)
|
||||
|
||||
|
||||
@router.post("/{todo_id}/transition", response_model=TodoSummary)
|
||||
def transition_todo_endpoint(
|
||||
todo_id: str,
|
||||
payload: TodoTransitionRequest,
|
||||
current_user: CurrentUser = Depends(require_any_permission("todo.process", "todo.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> TodoSummary:
|
||||
return transition_todo(db, todo_id, payload, actor=current_user.user)
|
||||
|
||||
|
||||
@router.delete("/{todo_id}")
|
||||
def delete_todo_endpoint(
|
||||
todo_id: str,
|
||||
current_user: CurrentUser = Depends(require_any_permission("todo.manage", "todo.process")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, bool]:
|
||||
return delete_todo(db, todo_id, actor=current_user.user)
|
||||
+52
-1
@@ -3,10 +3,21 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from ...core.database import get_db
|
||||
from ...core.dependencies import CurrentUser, get_current_user, require_permission
|
||||
from ...schemas.user import UserListResponse, UserPublic, UserRoleUpdateRequest, UserUpdateRequest
|
||||
from ...schemas.auth import MessageResponse
|
||||
from ...schemas.user import (
|
||||
UserCreateRequest,
|
||||
UserListResponse,
|
||||
UserPasswordResetRequest,
|
||||
UserPublic,
|
||||
UserRoleUpdateRequest,
|
||||
UserUpdateRequest,
|
||||
)
|
||||
from ...services.user_service import (
|
||||
create_user,
|
||||
delete_user,
|
||||
get_user_by_id,
|
||||
list_users,
|
||||
reset_user_password,
|
||||
serialize_user,
|
||||
set_user_roles,
|
||||
update_user,
|
||||
@@ -15,6 +26,21 @@ from ...services.user_service import (
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
|
||||
@router.post("", response_model=UserPublic)
|
||||
def create_user_account(
|
||||
payload: UserCreateRequest,
|
||||
_: CurrentUser = Depends(require_permission("user.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> UserPublic:
|
||||
created = create_user(db, payload)
|
||||
if not created:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="User id/email/username already exists or default role missing",
|
||||
)
|
||||
return created
|
||||
|
||||
|
||||
@router.get("", response_model=UserListResponse)
|
||||
def list_all_users(
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
@@ -60,6 +86,31 @@ def update_user_profile(
|
||||
return updated
|
||||
|
||||
|
||||
@router.post("/{user_id}/password", response_model=UserPublic)
|
||||
def reset_password(
|
||||
user_id: str,
|
||||
payload: UserPasswordResetRequest,
|
||||
_: CurrentUser = Depends(require_permission("user.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> UserPublic:
|
||||
updated = reset_user_password(db, user_id, payload)
|
||||
if not updated:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
return updated
|
||||
|
||||
|
||||
@router.delete("/{user_id}", response_model=MessageResponse)
|
||||
def remove_user(
|
||||
user_id: str,
|
||||
_: CurrentUser = Depends(require_permission("user.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> MessageResponse:
|
||||
deleted = delete_user(db, user_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
return MessageResponse(message="User deleted")
|
||||
|
||||
|
||||
@router.post("/{user_id}/roles", response_model=UserPublic)
|
||||
def assign_roles(
|
||||
user_id: str,
|
||||
|
||||
+47
-1
@@ -1,4 +1,5 @@
|
||||
from functools import lru_cache
|
||||
import json
|
||||
import re
|
||||
from typing import Literal
|
||||
|
||||
@@ -26,6 +27,11 @@ class Settings(BaseSettings):
|
||||
refresh_cookie_secure: bool = False
|
||||
refresh_cookie_samesite: Literal["lax", "strict", "none"] = "lax"
|
||||
|
||||
llm_provider_api_keys: str = ""
|
||||
llm_request_timeout_seconds: int = 60
|
||||
chat_context_message_limit: int = 12
|
||||
chat_default_system_prompt: str = "You are a helpful assistant."
|
||||
|
||||
initial_admin_email: str | None = None
|
||||
initial_admin_username: str = "admin"
|
||||
initial_admin_password: str | None = None
|
||||
@@ -37,7 +43,12 @@ class Settings(BaseSettings):
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
@field_validator("access_token_expire_minutes", "refresh_token_expire_days")
|
||||
@field_validator(
|
||||
"access_token_expire_minutes",
|
||||
"refresh_token_expire_days",
|
||||
"llm_request_timeout_seconds",
|
||||
"chat_context_message_limit",
|
||||
)
|
||||
@classmethod
|
||||
def validate_positive_numbers(cls, value: int) -> int:
|
||||
if value <= 0:
|
||||
@@ -80,6 +91,41 @@ class Settings(BaseSettings):
|
||||
return None
|
||||
return "|".join(f"(?:{part})" for part in regex_parts)
|
||||
|
||||
@property
|
||||
def llm_provider_key_map(self) -> dict[str, str]:
|
||||
raw = self.llm_provider_api_keys.strip()
|
||||
if not raw:
|
||||
return {}
|
||||
|
||||
if raw.startswith("{"):
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
if not isinstance(data, dict):
|
||||
return {}
|
||||
normalized: dict[str, str] = {}
|
||||
for provider, value in data.items():
|
||||
if not isinstance(provider, str) or not isinstance(value, str):
|
||||
continue
|
||||
provider_key = provider.strip().lower()
|
||||
secret = value.strip()
|
||||
if provider_key and secret:
|
||||
normalized[provider_key] = secret
|
||||
return normalized
|
||||
|
||||
mapping: dict[str, str] = {}
|
||||
for token in re.split(r"[,\n;]+", raw):
|
||||
pair = token.strip()
|
||||
if not pair or "=" not in pair:
|
||||
continue
|
||||
provider, value = pair.split("=", 1)
|
||||
provider_key = provider.strip().lower()
|
||||
secret = value.strip()
|
||||
if provider_key and secret:
|
||||
mapping[provider_key] = secret
|
||||
return mapping
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
|
||||
@@ -39,7 +39,18 @@ def get_db() -> Generator[Session, None, None]:
|
||||
|
||||
def init_db() -> None:
|
||||
# Import models so metadata includes every table before create_all.
|
||||
from ..models import audit_log, auth_session, file_storage, menu, model_registry, rbac, requirement, user # noqa: F401
|
||||
from ..models import (
|
||||
audit_log,
|
||||
auth_session,
|
||||
chat,
|
||||
file_storage,
|
||||
menu,
|
||||
model_registry,
|
||||
rbac,
|
||||
requirement,
|
||||
todo,
|
||||
user,
|
||||
) # noqa: F401
|
||||
from ..services.seed_service import seed_defaults
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
@@ -4,15 +4,17 @@ Import all model modules during package initialization so SQLAlchemy can
|
||||
resolve string-based relationships regardless of route/service import order.
|
||||
"""
|
||||
|
||||
from . import audit_log, auth_session, file_storage, menu, model_registry, rbac, requirement, user
|
||||
from . import audit_log, auth_session, chat, file_storage, menu, model_registry, rbac, requirement, todo, user
|
||||
|
||||
__all__ = [
|
||||
"audit_log",
|
||||
"auth_session",
|
||||
"chat",
|
||||
"file_storage",
|
||||
"menu",
|
||||
"model_registry",
|
||||
"rbac",
|
||||
"requirement",
|
||||
"todo",
|
||||
"user",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from ..core.database import Base
|
||||
from .base import utcnow
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .user import User
|
||||
|
||||
|
||||
class ChatSession(Base):
|
||||
__tablename__ = "chat_sessions"
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
primary_key=True,
|
||||
default=lambda: str(uuid4()),
|
||||
)
|
||||
owner_user_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
index=True,
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(200), default="新会话")
|
||||
system_prompt: Mapped[str] = mapped_column(Text(), default="")
|
||||
model_code: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||
last_message_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=utcnow,
|
||||
onupdate=utcnow,
|
||||
)
|
||||
|
||||
owner: Mapped[User] = relationship("User", lazy="selectin")
|
||||
messages: Mapped[list[ChatMessage]] = relationship(
|
||||
"ChatMessage",
|
||||
back_populates="session",
|
||||
lazy="selectin",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="ChatMessage.created_at.asc(), ChatMessage.id.asc()",
|
||||
)
|
||||
|
||||
|
||||
class ChatMessage(Base):
|
||||
__tablename__ = "chat_messages"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
session_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("chat_sessions.id", ondelete="CASCADE"),
|
||||
index=True,
|
||||
)
|
||||
author_user_id: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("users.id", ondelete="SET NULL"),
|
||||
index=True,
|
||||
)
|
||||
role: Mapped[str] = mapped_column(String(16), index=True)
|
||||
content: Mapped[str] = mapped_column(Text())
|
||||
is_error: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
|
||||
model_code: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||
provider: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||
provider_model: Mapped[str | None] = mapped_column(String(128), index=True)
|
||||
prompt_tokens: Mapped[int | None] = mapped_column(Integer)
|
||||
completion_tokens: Mapped[int | None] = mapped_column(Integer)
|
||||
total_tokens: Mapped[int | None] = mapped_column(Integer)
|
||||
latency_ms: Mapped[int | None] = mapped_column(Integer)
|
||||
error_message: Mapped[str | None] = mapped_column(Text())
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
|
||||
|
||||
session: Mapped[ChatSession] = relationship("ChatSession", back_populates="messages", lazy="selectin")
|
||||
author: Mapped[User | None] = relationship("User", lazy="selectin")
|
||||
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from ..core.database import Base
|
||||
from .base import utcnow
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .user import User
|
||||
|
||||
|
||||
class Todo(Base):
|
||||
__tablename__ = "todos"
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
primary_key=True,
|
||||
default=lambda: str(uuid4()),
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(200), index=True)
|
||||
description: Mapped[str] = mapped_column(Text(), default="")
|
||||
status: Mapped[str] = mapped_column(String(32), default="TODO", index=True)
|
||||
priority: Mapped[str] = mapped_column(String(16), default="medium", index=True)
|
||||
assignee_user_id: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("users.id", ondelete="SET NULL"),
|
||||
index=True,
|
||||
)
|
||||
creator_user_id: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("users.id", ondelete="SET NULL"),
|
||||
index=True,
|
||||
)
|
||||
due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=utcnow,
|
||||
onupdate=utcnow,
|
||||
)
|
||||
|
||||
creator: Mapped[User | None] = relationship(
|
||||
"User",
|
||||
foreign_keys=[creator_user_id],
|
||||
lazy="selectin",
|
||||
)
|
||||
assignee: Mapped[User | None] = relationship(
|
||||
"User",
|
||||
foreign_keys=[assignee_user_id],
|
||||
lazy="selectin",
|
||||
)
|
||||
@@ -0,0 +1,62 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
ChatRole = Literal["system", "user", "assistant"]
|
||||
|
||||
|
||||
class ChatSessionCreateRequest(BaseModel):
|
||||
title: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
system_prompt: str | None = Field(default=None, max_length=4000)
|
||||
|
||||
|
||||
class ChatSessionPublic(BaseModel):
|
||||
id: str
|
||||
owner_user_id: str
|
||||
title: str
|
||||
system_prompt: str
|
||||
model_code: str | None = None
|
||||
last_message_at: datetime | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ChatSessionListResponse(BaseModel):
|
||||
items: list[ChatSessionPublic]
|
||||
total: int
|
||||
|
||||
|
||||
class ChatMessageCreateRequest(BaseModel):
|
||||
content: str = Field(min_length=1, max_length=20000)
|
||||
|
||||
|
||||
class ChatMessagePublic(BaseModel):
|
||||
id: int
|
||||
session_id: str
|
||||
author_user_id: str | None = None
|
||||
role: ChatRole
|
||||
content: str
|
||||
is_error: bool
|
||||
model_code: str | None = None
|
||||
provider: str | None = None
|
||||
provider_model: str | None = None
|
||||
prompt_tokens: int | None = None
|
||||
completion_tokens: int | None = None
|
||||
total_tokens: int | None = None
|
||||
latency_ms: int | None = None
|
||||
error_message: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ChatMessageListResponse(BaseModel):
|
||||
items: list[ChatMessagePublic]
|
||||
total: int
|
||||
|
||||
|
||||
class ChatSendResponse(BaseModel):
|
||||
session: ChatSessionPublic
|
||||
user_message: ChatMessagePublic
|
||||
assistant_message: ChatMessagePublic
|
||||
@@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .user import UserPublic
|
||||
|
||||
TodoStatus = Literal["TODO", "IN_PROGRESS", "DONE"]
|
||||
TodoPriority = Literal["low", "medium", "high", "urgent"]
|
||||
|
||||
|
||||
class TodoSummary(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
description: str
|
||||
status: TodoStatus
|
||||
priority: TodoPriority
|
||||
assignee_user_id: str | None = None
|
||||
creator_user_id: str | None = None
|
||||
due_at: datetime | None = None
|
||||
completed_at: datetime | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
creator: UserPublic | None = None
|
||||
assignee: UserPublic | None = None
|
||||
|
||||
|
||||
class TodoListResponse(BaseModel):
|
||||
items: list[TodoSummary]
|
||||
total: int
|
||||
|
||||
|
||||
class TodoCreateRequest(BaseModel):
|
||||
title: str = Field(min_length=2, max_length=200)
|
||||
description: str = Field(default="", max_length=20000)
|
||||
status: TodoStatus = "TODO"
|
||||
priority: TodoPriority = "medium"
|
||||
assignee_user_id: str | None = Field(default=None, max_length=36)
|
||||
due_at: datetime | None = None
|
||||
|
||||
|
||||
class TodoUpdateRequest(BaseModel):
|
||||
title: str | None = Field(default=None, min_length=2, max_length=200)
|
||||
description: str | None = Field(default=None, max_length=20000)
|
||||
priority: TodoPriority | None = None
|
||||
assignee_user_id: str | None = Field(default=None, max_length=36)
|
||||
due_at: datetime | None = None
|
||||
|
||||
|
||||
class TodoTransitionRequest(BaseModel):
|
||||
status: TodoStatus
|
||||
note: str | None = Field(default=None, max_length=2000)
|
||||
+20
-1
@@ -1,7 +1,7 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
from pydantic import BaseModel, EmailStr, Field, field_validator
|
||||
|
||||
|
||||
class UserPublic(BaseModel):
|
||||
@@ -27,3 +27,22 @@ class UserUpdateRequest(BaseModel):
|
||||
|
||||
class UserRoleUpdateRequest(BaseModel):
|
||||
role_codes: list[str] = Field(min_length=1)
|
||||
|
||||
|
||||
class UserPasswordResetRequest(BaseModel):
|
||||
new_password: str = Field(min_length=8, max_length=128)
|
||||
|
||||
|
||||
class UserCreateRequest(BaseModel):
|
||||
user_id: str = Field(min_length=3, max_length=64)
|
||||
email: EmailStr
|
||||
username: str = Field(min_length=3, max_length=64)
|
||||
password: str = Field(min_length=8, max_length=128)
|
||||
|
||||
@field_validator("user_id")
|
||||
@classmethod
|
||||
def validate_user_id(cls, value: str) -> str:
|
||||
normalized = value.strip()
|
||||
if not normalized:
|
||||
raise ValueError("user_id cannot be empty")
|
||||
return normalized
|
||||
|
||||
@@ -303,7 +303,7 @@ def update_menu(db: Session, menu_id: int, payload: MenuUpdateRequest) -> MenuPu
|
||||
|
||||
def delete_menu(db: Session, menu_id: int) -> bool:
|
||||
menu = get_menu_by_id(db, menu_id)
|
||||
if not menu or menu.code in {"dashboard", "admin.users", "admin.roles", "admin.menus", "admin.files", "admin.requirements", "admin.models"}:
|
||||
if not menu or menu.code in {"dashboard", "admin.users", "admin.roles", "admin.menus", "admin.files", "admin.requirements", "admin.chat", "admin.models"}:
|
||||
return False
|
||||
child_exists = db.scalar(select(Menu.id).where(Menu.parent_id == menu_id))
|
||||
if child_exists is not None:
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..core.config import get_settings
|
||||
from ..models.base import utcnow
|
||||
from ..models.chat import ChatMessage, ChatSession
|
||||
from ..models.user import User
|
||||
from ..schemas.chat import (
|
||||
ChatMessageCreateRequest,
|
||||
ChatMessageListResponse,
|
||||
ChatMessagePublic,
|
||||
ChatSendResponse,
|
||||
ChatSessionCreateRequest,
|
||||
ChatSessionListResponse,
|
||||
ChatSessionPublic,
|
||||
)
|
||||
from .llm_gateway import create_assistant_reply
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def list_sessions(db: Session, *, actor: User) -> ChatSessionListResponse:
|
||||
sessions = db.execute(
|
||||
select(ChatSession)
|
||||
.where(ChatSession.owner_user_id == actor.id)
|
||||
.order_by(ChatSession.updated_at.desc(), ChatSession.created_at.desc())
|
||||
).scalars().all()
|
||||
return ChatSessionListResponse(items=[serialize_session(item) for item in sessions], total=len(sessions))
|
||||
|
||||
|
||||
def create_session(
|
||||
db: Session,
|
||||
payload: ChatSessionCreateRequest,
|
||||
*,
|
||||
actor: User,
|
||||
) -> ChatSessionPublic:
|
||||
title = (payload.title or "").strip() or "新会话"
|
||||
system_prompt = (payload.system_prompt or "").strip() or settings.chat_default_system_prompt
|
||||
session = ChatSession(
|
||||
owner_user_id=actor.id,
|
||||
title=title,
|
||||
system_prompt=system_prompt,
|
||||
)
|
||||
db.add(session)
|
||||
db.commit()
|
||||
|
||||
saved = get_owned_session(db, session.id, actor_id=actor.id)
|
||||
if not saved:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Chat session save failed")
|
||||
return serialize_session(saved)
|
||||
|
||||
|
||||
def list_messages(
|
||||
db: Session,
|
||||
*,
|
||||
session_id: str,
|
||||
actor: User,
|
||||
limit: int = 200,
|
||||
) -> ChatMessageListResponse:
|
||||
session = get_owned_session(db, session_id, actor_id=actor.id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chat session not found")
|
||||
|
||||
safe_limit = max(1, min(limit, 500))
|
||||
messages = db.execute(
|
||||
select(ChatMessage)
|
||||
.where(ChatMessage.session_id == session.id)
|
||||
.order_by(ChatMessage.created_at.asc(), ChatMessage.id.asc())
|
||||
.limit(safe_limit)
|
||||
).scalars().all()
|
||||
return ChatMessageListResponse(items=[serialize_message(item) for item in messages], total=len(messages))
|
||||
|
||||
|
||||
def send_message(
|
||||
db: Session,
|
||||
*,
|
||||
session_id: str,
|
||||
payload: ChatMessageCreateRequest,
|
||||
actor: User,
|
||||
) -> ChatSendResponse:
|
||||
session = get_owned_session(db, session_id, actor_id=actor.id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chat session not found")
|
||||
|
||||
normalized_content = payload.content.strip()
|
||||
if not normalized_content:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Message content cannot be empty")
|
||||
|
||||
user_message = ChatMessage(
|
||||
session_id=session.id,
|
||||
author_user_id=actor.id,
|
||||
role="user",
|
||||
content=normalized_content,
|
||||
)
|
||||
db.add(user_message)
|
||||
db.flush()
|
||||
|
||||
context_messages = _load_context_messages(db, session_id=session.id, exclude_message_id=user_message.id)
|
||||
|
||||
assistant_message: ChatMessage
|
||||
try:
|
||||
result = create_assistant_reply(
|
||||
db,
|
||||
user_message=normalized_content,
|
||||
context_messages=context_messages,
|
||||
system_prompt=session.system_prompt or settings.chat_default_system_prompt,
|
||||
)
|
||||
assistant_message = ChatMessage(
|
||||
session_id=session.id,
|
||||
role="assistant",
|
||||
content=result.content,
|
||||
model_code=result.model_code,
|
||||
provider=result.provider,
|
||||
provider_model=result.provider_model,
|
||||
prompt_tokens=result.prompt_tokens,
|
||||
completion_tokens=result.completion_tokens,
|
||||
total_tokens=result.total_tokens,
|
||||
latency_ms=result.latency_ms,
|
||||
is_error=False,
|
||||
)
|
||||
session.model_code = result.model_code
|
||||
except HTTPException as exc:
|
||||
assistant_message = ChatMessage(
|
||||
session_id=session.id,
|
||||
role="assistant",
|
||||
content=f"模型调用失败:{exc.detail}",
|
||||
is_error=True,
|
||||
error_message=str(exc.detail),
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - defensive fallback
|
||||
assistant_message = ChatMessage(
|
||||
session_id=session.id,
|
||||
role="assistant",
|
||||
content="模型调用失败:unexpected_error",
|
||||
is_error=True,
|
||||
error_message=str(exc),
|
||||
)
|
||||
|
||||
db.add(assistant_message)
|
||||
|
||||
# Set a meaningful title after first user turn.
|
||||
if session.title in {"新会话", "New Chat"}:
|
||||
session.title = _derive_title(normalized_content)
|
||||
|
||||
now = utcnow()
|
||||
session.last_message_at = now
|
||||
session.updated_at = now
|
||||
db.commit()
|
||||
|
||||
saved_session = get_owned_session(db, session.id, actor_id=actor.id)
|
||||
saved_user_message = db.execute(select(ChatMessage).where(ChatMessage.id == user_message.id)).scalar_one_or_none()
|
||||
saved_assistant_message = db.execute(select(ChatMessage).where(ChatMessage.id == assistant_message.id)).scalar_one_or_none()
|
||||
if not saved_session or not saved_user_message or not saved_assistant_message:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Chat message save failed")
|
||||
|
||||
return ChatSendResponse(
|
||||
session=serialize_session(saved_session),
|
||||
user_message=serialize_message(saved_user_message),
|
||||
assistant_message=serialize_message(saved_assistant_message),
|
||||
)
|
||||
|
||||
|
||||
def get_owned_session(db: Session, session_id: str, *, actor_id: str) -> ChatSession | None:
|
||||
return db.execute(
|
||||
select(ChatSession).where(
|
||||
ChatSession.id == session_id,
|
||||
ChatSession.owner_user_id == actor_id,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def serialize_session(session: ChatSession) -> ChatSessionPublic:
|
||||
return ChatSessionPublic(
|
||||
id=session.id,
|
||||
owner_user_id=session.owner_user_id,
|
||||
title=session.title,
|
||||
system_prompt=session.system_prompt,
|
||||
model_code=session.model_code,
|
||||
last_message_at=session.last_message_at,
|
||||
created_at=session.created_at,
|
||||
updated_at=session.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def serialize_message(message: ChatMessage) -> ChatMessagePublic:
|
||||
return ChatMessagePublic(
|
||||
id=message.id,
|
||||
session_id=message.session_id,
|
||||
author_user_id=message.author_user_id,
|
||||
role=message.role,
|
||||
content=message.content,
|
||||
is_error=message.is_error,
|
||||
model_code=message.model_code,
|
||||
provider=message.provider,
|
||||
provider_model=message.provider_model,
|
||||
prompt_tokens=message.prompt_tokens,
|
||||
completion_tokens=message.completion_tokens,
|
||||
total_tokens=message.total_tokens,
|
||||
latency_ms=message.latency_ms,
|
||||
error_message=message.error_message,
|
||||
created_at=message.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _load_context_messages(
|
||||
db: Session,
|
||||
*,
|
||||
session_id: str,
|
||||
exclude_message_id: int | None = None,
|
||||
) -> list[tuple[str, str]]:
|
||||
limit = max(1, settings.chat_context_message_limit)
|
||||
messages = db.execute(
|
||||
select(ChatMessage)
|
||||
.where(ChatMessage.session_id == session_id)
|
||||
.order_by(ChatMessage.created_at.desc(), ChatMessage.id.desc())
|
||||
.limit(limit)
|
||||
).scalars().all()
|
||||
messages.reverse()
|
||||
result: list[tuple[str, str]] = []
|
||||
for item in messages:
|
||||
if item.role not in {"user", "assistant"}:
|
||||
continue
|
||||
if exclude_message_id is not None and item.id == exclude_message_id:
|
||||
continue
|
||||
result.append((item.role, item.content))
|
||||
return result
|
||||
|
||||
|
||||
def _derive_title(content: str) -> str:
|
||||
compact = " ".join(content.split())
|
||||
if len(compact) <= 32:
|
||||
return compact or "新会话"
|
||||
return f"{compact[:32]}..."
|
||||
@@ -0,0 +1,237 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..core.config import get_settings
|
||||
from ..models.model_registry import ModelApiKey, ModelRegistry, ModelRouteRule
|
||||
|
||||
settings = get_settings()
|
||||
CHAT_CAPABILITY_ROUTE_KEY = "chat.default"
|
||||
GLOBAL_ROUTE_KEY = "__global__"
|
||||
DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1"
|
||||
|
||||
|
||||
@dataclass
|
||||
class LlmCompletionResult:
|
||||
content: str
|
||||
model_code: str
|
||||
provider: str
|
||||
provider_model: str
|
||||
prompt_tokens: int | None
|
||||
completion_tokens: int | None
|
||||
total_tokens: int | None
|
||||
latency_ms: int
|
||||
|
||||
|
||||
def create_assistant_reply(
|
||||
db: Session,
|
||||
*,
|
||||
user_message: str,
|
||||
context_messages: list[tuple[str, str]],
|
||||
system_prompt: str,
|
||||
) -> LlmCompletionResult:
|
||||
model = _resolve_chat_model(db)
|
||||
provider_key = _resolve_provider_key(model.provider)
|
||||
endpoint = _build_endpoint(model.base_url)
|
||||
payload = {
|
||||
"model": model.provider_model,
|
||||
"messages": _build_messages(
|
||||
system_prompt=system_prompt,
|
||||
context_messages=context_messages,
|
||||
user_message=user_message,
|
||||
),
|
||||
}
|
||||
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
with httpx.Client(timeout=settings.llm_request_timeout_seconds) as client:
|
||||
response = client.post(
|
||||
endpoint,
|
||||
headers={
|
||||
"Authorization": f"Bearer {provider_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json=payload,
|
||||
)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise HTTPException(status_code=status.HTTP_504_GATEWAY_TIMEOUT, detail="LLM request timeout") from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"LLM request failed: {exc.__class__.__name__}") from exc
|
||||
|
||||
latency_ms = int((time.perf_counter() - started) * 1000)
|
||||
if response.status_code >= 400:
|
||||
detail = _extract_http_error_detail(response)
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"LLM response error: {detail}")
|
||||
|
||||
body = response.json()
|
||||
content = _extract_content(body)
|
||||
if not content:
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="LLM returned empty content")
|
||||
|
||||
usage = body.get("usage") if isinstance(body, dict) else None
|
||||
prompt_tokens = _to_int(usage.get("prompt_tokens")) if isinstance(usage, dict) else None
|
||||
completion_tokens = _to_int(usage.get("completion_tokens")) if isinstance(usage, dict) else None
|
||||
total_tokens = _to_int(usage.get("total_tokens")) if isinstance(usage, dict) else None
|
||||
|
||||
return LlmCompletionResult(
|
||||
content=content,
|
||||
model_code=model.code,
|
||||
provider=model.provider,
|
||||
provider_model=model.provider_model,
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
total_tokens=total_tokens,
|
||||
latency_ms=latency_ms,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_chat_model(db: Session) -> ModelRegistry:
|
||||
capability_model = _resolve_model_from_route(db, route_type="CAPABILITY", route_key=CHAT_CAPABILITY_ROUTE_KEY)
|
||||
if capability_model:
|
||||
return capability_model
|
||||
|
||||
global_model = _resolve_model_from_route(db, route_type="GLOBAL", route_key=GLOBAL_ROUTE_KEY)
|
||||
if global_model:
|
||||
return global_model
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No enabled model route for chat (CAPABILITY:chat.default or GLOBAL)",
|
||||
)
|
||||
|
||||
|
||||
def _resolve_model_from_route(
|
||||
db: Session,
|
||||
*,
|
||||
route_type: str,
|
||||
route_key: str,
|
||||
) -> ModelRegistry | None:
|
||||
rows = db.execute(
|
||||
select(ModelRouteRule, ModelRegistry)
|
||||
.join(ModelRegistry, ModelRouteRule.target_model_code == ModelRegistry.code)
|
||||
.where(
|
||||
ModelRouteRule.route_type == route_type,
|
||||
ModelRouteRule.route_key == route_key,
|
||||
ModelRouteRule.enabled.is_(True),
|
||||
ModelRegistry.status == "ENABLED",
|
||||
)
|
||||
.order_by(ModelRouteRule.priority.asc(), ModelRouteRule.id.asc())
|
||||
).all()
|
||||
if not rows:
|
||||
return None
|
||||
|
||||
for _, model in rows:
|
||||
active_key_exists = db.scalar(
|
||||
select(ModelApiKey.id).where(
|
||||
ModelApiKey.model_id == model.id,
|
||||
ModelApiKey.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
if active_key_exists is not None:
|
||||
return model
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_provider_key(provider: str) -> str:
|
||||
key = settings.llm_provider_key_map.get(provider.strip().lower())
|
||||
if key:
|
||||
return key
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Missing provider key for {provider}. Configure LLM_PROVIDER_API_KEYS.",
|
||||
)
|
||||
|
||||
|
||||
def _build_messages(
|
||||
*,
|
||||
system_prompt: str,
|
||||
context_messages: list[tuple[str, str]],
|
||||
user_message: str,
|
||||
) -> list[dict[str, str]]:
|
||||
messages: list[dict[str, str]] = []
|
||||
normalized_system_prompt = system_prompt.strip()
|
||||
if normalized_system_prompt:
|
||||
messages.append({"role": "system", "content": normalized_system_prompt})
|
||||
|
||||
for role, content in context_messages:
|
||||
if role not in {"user", "assistant"}:
|
||||
continue
|
||||
normalized_content = content.strip()
|
||||
if not normalized_content:
|
||||
continue
|
||||
messages.append({"role": role, "content": normalized_content})
|
||||
|
||||
messages.append({"role": "user", "content": user_message.strip()})
|
||||
return messages
|
||||
|
||||
|
||||
def _build_endpoint(base_url: str | None) -> str:
|
||||
normalized = (base_url or "").strip().rstrip("/")
|
||||
if not normalized:
|
||||
return f"{DEFAULT_OPENAI_BASE_URL}/chat/completions"
|
||||
if normalized.endswith("/chat/completions"):
|
||||
return normalized
|
||||
return f"{normalized}/chat/completions"
|
||||
|
||||
|
||||
def _extract_content(body: object) -> str:
|
||||
if not isinstance(body, dict):
|
||||
return ""
|
||||
|
||||
choices = body.get("choices")
|
||||
if not isinstance(choices, list) or not choices:
|
||||
return ""
|
||||
first = choices[0]
|
||||
if not isinstance(first, dict):
|
||||
return ""
|
||||
message = first.get("message")
|
||||
if not isinstance(message, dict):
|
||||
return ""
|
||||
|
||||
content = message.get("content")
|
||||
if isinstance(content, str):
|
||||
return content.strip()
|
||||
if isinstance(content, list):
|
||||
texts: list[str] = []
|
||||
for item in content:
|
||||
if isinstance(item, dict):
|
||||
text = item.get("text")
|
||||
if isinstance(text, str) and text.strip():
|
||||
texts.append(text.strip())
|
||||
return "\n".join(texts).strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_http_error_detail(response: httpx.Response) -> str:
|
||||
try:
|
||||
payload = response.json()
|
||||
except json.JSONDecodeError:
|
||||
return f"HTTP {response.status_code}"
|
||||
if isinstance(payload, dict):
|
||||
detail = payload.get("error")
|
||||
if isinstance(detail, dict):
|
||||
message = detail.get("message")
|
||||
if isinstance(message, str) and message.strip():
|
||||
return message.strip()
|
||||
message = payload.get("message")
|
||||
if isinstance(message, str) and message.strip():
|
||||
return message.strip()
|
||||
detail_field = payload.get("detail")
|
||||
if isinstance(detail_field, str) and detail_field.strip():
|
||||
return detail_field.strip()
|
||||
return f"HTTP {response.status_code}"
|
||||
|
||||
|
||||
def _to_int(value: object) -> int | None:
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
if isinstance(value, str) and value.isdigit():
|
||||
return int(value)
|
||||
return None
|
||||
@@ -22,10 +22,15 @@ DEFAULT_PERMISSIONS: dict[str, str] = {
|
||||
"model.manage": "Manage model registry, routes, keys, and health checks",
|
||||
"file.read": "Read file mounts and indexed entries",
|
||||
"file.manage": "Manage file operations and storage sync",
|
||||
"chat.use": "Use AI chat feature",
|
||||
"requirement.read": "Read requirements",
|
||||
"requirement.create": "Create requirements",
|
||||
"requirement.process": "Process requirements",
|
||||
"requirement.manage": "Manage all requirements",
|
||||
"todo.read": "Read todos",
|
||||
"todo.create": "Create todos",
|
||||
"todo.process": "Process todos",
|
||||
"todo.manage": "Manage all todos",
|
||||
}
|
||||
|
||||
DEFAULT_ROLES: dict[str, dict[str, object]] = {
|
||||
@@ -43,10 +48,15 @@ DEFAULT_ROLES: dict[str, dict[str, object]] = {
|
||||
"model.manage",
|
||||
"file.read",
|
||||
"file.manage",
|
||||
"chat.use",
|
||||
"requirement.read",
|
||||
"requirement.create",
|
||||
"requirement.process",
|
||||
"requirement.manage",
|
||||
"todo.read",
|
||||
"todo.create",
|
||||
"todo.process",
|
||||
"todo.manage",
|
||||
],
|
||||
},
|
||||
"user": {
|
||||
@@ -134,6 +144,32 @@ DEFAULT_MENUS: list[dict[str, object]] = [
|
||||
"cacheable": False,
|
||||
"permission_code": "requirement.read",
|
||||
},
|
||||
{
|
||||
"code": "admin.todos",
|
||||
"name": "待办管理",
|
||||
"path": "/admin/todos",
|
||||
"icon": "ListTodo",
|
||||
"parent_code": None,
|
||||
"type": "menu",
|
||||
"sort_order": 52,
|
||||
"status": "enabled",
|
||||
"visible": True,
|
||||
"cacheable": False,
|
||||
"permission_code": "todo.read",
|
||||
},
|
||||
{
|
||||
"code": "admin.chat",
|
||||
"name": "AI 聊天",
|
||||
"path": "/admin/chat",
|
||||
"icon": "MessagesSquare",
|
||||
"parent_code": None,
|
||||
"type": "menu",
|
||||
"sort_order": 57,
|
||||
"status": "enabled",
|
||||
"visible": True,
|
||||
"cacheable": False,
|
||||
"permission_code": "chat.use",
|
||||
},
|
||||
{
|
||||
"code": "admin.models",
|
||||
"name": "模型管理",
|
||||
@@ -150,7 +186,7 @@ DEFAULT_MENUS: list[dict[str, object]] = [
|
||||
]
|
||||
|
||||
ROLE_MENU_BINDINGS: dict[str, list[str]] = {
|
||||
"admin": ["dashboard", "admin.users", "admin.roles", "admin.menus", "admin.files", "admin.requirements", "admin.models"],
|
||||
"admin": ["dashboard", "admin.users", "admin.roles", "admin.menus", "admin.files", "admin.requirements", "admin.todos", "admin.chat", "admin.models"],
|
||||
"user": ["dashboard"],
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from ..models.base import utcnow
|
||||
from ..models.todo import Todo
|
||||
from ..models.user import User
|
||||
from ..schemas.todo import (
|
||||
TodoCreateRequest,
|
||||
TodoListResponse,
|
||||
TodoSummary,
|
||||
TodoTransitionRequest,
|
||||
TodoUpdateRequest,
|
||||
)
|
||||
from .push_service import publish_topic
|
||||
from .user_service import serialize_user
|
||||
|
||||
TODO_LOAD_OPTIONS = (
|
||||
selectinload(Todo.creator).selectinload(User.roles),
|
||||
selectinload(Todo.assignee).selectinload(User.roles),
|
||||
)
|
||||
TOPIC_NAME = "todos"
|
||||
ALLOWED_TRANSITIONS: dict[str, set[str]] = {
|
||||
"TODO": {"IN_PROGRESS", "DONE"},
|
||||
"IN_PROGRESS": {"TODO", "DONE"},
|
||||
"DONE": {"TODO", "IN_PROGRESS"},
|
||||
}
|
||||
|
||||
|
||||
def _todo_stmt():
|
||||
return select(Todo).options(*TODO_LOAD_OPTIONS)
|
||||
|
||||
|
||||
def list_todos(
|
||||
db: Session,
|
||||
*,
|
||||
keyword: str | None,
|
||||
status_filter: str | None,
|
||||
priority: str | None,
|
||||
assignee_user_id: str | None,
|
||||
) -> TodoListResponse:
|
||||
stmt = _todo_stmt()
|
||||
if keyword:
|
||||
like = f"%{keyword.strip()}%"
|
||||
stmt = stmt.where(or_(Todo.title.ilike(like), Todo.description.ilike(like)))
|
||||
if status_filter:
|
||||
stmt = stmt.where(Todo.status == status_filter)
|
||||
if priority:
|
||||
stmt = stmt.where(Todo.priority == priority)
|
||||
if assignee_user_id:
|
||||
stmt = stmt.where(Todo.assignee_user_id == assignee_user_id)
|
||||
|
||||
todos = db.execute(stmt.order_by(Todo.updated_at.desc())).scalars().all()
|
||||
return TodoListResponse(items=[serialize_todo(item) for item in todos], total=len(todos))
|
||||
|
||||
|
||||
def get_todo_by_id(db: Session, todo_id: str) -> Todo | None:
|
||||
return db.execute(_todo_stmt().where(Todo.id == todo_id)).scalar_one_or_none()
|
||||
|
||||
|
||||
def create_todo(
|
||||
db: Session,
|
||||
payload: TodoCreateRequest,
|
||||
*,
|
||||
actor: User,
|
||||
) -> TodoSummary:
|
||||
assignee = _load_user_if_exists(db, payload.assignee_user_id)
|
||||
if payload.assignee_user_id and not assignee:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Assignee not found")
|
||||
|
||||
todo = Todo(
|
||||
title=payload.title.strip(),
|
||||
description=payload.description.strip(),
|
||||
status=payload.status,
|
||||
priority=payload.priority,
|
||||
assignee_user_id=assignee.id if assignee else None,
|
||||
creator_user_id=actor.id,
|
||||
due_at=payload.due_at,
|
||||
completed_at=utcnow() if payload.status == "DONE" else None,
|
||||
)
|
||||
db.add(todo)
|
||||
db.commit()
|
||||
|
||||
saved = get_todo_by_id(db, todo.id)
|
||||
if not saved:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Todo save failed")
|
||||
_publish_todo_change("todos.created", saved, action="created")
|
||||
return serialize_todo(saved)
|
||||
|
||||
|
||||
def update_todo(
|
||||
db: Session,
|
||||
todo_id: str,
|
||||
payload: TodoUpdateRequest,
|
||||
*,
|
||||
actor: User,
|
||||
) -> TodoSummary:
|
||||
todo = get_todo_by_id(db, todo_id)
|
||||
if not todo:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Todo not found")
|
||||
|
||||
update_data = payload.model_dump(exclude_unset=True)
|
||||
if "assignee_user_id" in update_data:
|
||||
assignee = _load_user_if_exists(db, update_data["assignee_user_id"])
|
||||
if update_data["assignee_user_id"] and not assignee:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Assignee not found")
|
||||
todo.assignee_user_id = assignee.id if assignee else None
|
||||
|
||||
for field in ["title", "description", "priority", "due_at"]:
|
||||
if field in update_data:
|
||||
value = update_data[field]
|
||||
setattr(todo, field, _normalize_str(value) if isinstance(value, str) else value)
|
||||
|
||||
db.commit()
|
||||
|
||||
saved = get_todo_by_id(db, todo.id)
|
||||
if not saved:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Todo load failed")
|
||||
_publish_todo_change("todos.updated", saved, action="updated")
|
||||
return serialize_todo(saved)
|
||||
|
||||
|
||||
def transition_todo(
|
||||
db: Session,
|
||||
todo_id: str,
|
||||
payload: TodoTransitionRequest,
|
||||
*,
|
||||
actor: User,
|
||||
) -> TodoSummary:
|
||||
todo = get_todo_by_id(db, todo_id)
|
||||
if not todo:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Todo not found")
|
||||
|
||||
current_status = todo.status
|
||||
target_status = payload.status
|
||||
if current_status == target_status:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Status is unchanged")
|
||||
if target_status not in ALLOWED_TRANSITIONS.get(current_status, set()):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Transition not allowed: {current_status} -> {target_status}",
|
||||
)
|
||||
|
||||
todo.status = target_status
|
||||
todo.completed_at = utcnow() if target_status == "DONE" else None
|
||||
db.commit()
|
||||
|
||||
saved = get_todo_by_id(db, todo.id)
|
||||
if not saved:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Todo load failed")
|
||||
_publish_todo_change("todos.transitioned", saved, action="transitioned")
|
||||
return serialize_todo(saved)
|
||||
|
||||
|
||||
def delete_todo(db: Session, todo_id: str, *, actor: User) -> dict[str, bool]:
|
||||
todo = get_todo_by_id(db, todo_id)
|
||||
if not todo:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Todo not found")
|
||||
|
||||
deleted_id = todo.id
|
||||
db.delete(todo)
|
||||
db.commit()
|
||||
|
||||
_fire_and_forget(
|
||||
publish_topic(
|
||||
TOPIC_NAME,
|
||||
name="todos.deleted",
|
||||
payload={"action": "deleted", "todo_id": deleted_id, "actor_user_id": actor.id},
|
||||
requires_refetch=["/api/v1/todos"],
|
||||
dedupe_key=f"todos:deleted:{deleted_id}",
|
||||
)
|
||||
)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
def serialize_todo(todo: Todo) -> TodoSummary:
|
||||
return TodoSummary(
|
||||
id=todo.id,
|
||||
title=todo.title,
|
||||
description=todo.description,
|
||||
status=todo.status,
|
||||
priority=todo.priority,
|
||||
assignee_user_id=todo.assignee_user_id,
|
||||
creator_user_id=todo.creator_user_id,
|
||||
due_at=todo.due_at,
|
||||
completed_at=todo.completed_at,
|
||||
created_at=todo.created_at,
|
||||
updated_at=todo.updated_at,
|
||||
creator=serialize_user(todo.creator) if todo.creator else None,
|
||||
assignee=serialize_user(todo.assignee) if todo.assignee else None,
|
||||
)
|
||||
|
||||
|
||||
def _publish_todo_change(event_name: str, todo: Todo, *, action: str) -> None:
|
||||
payload = {
|
||||
"action": action,
|
||||
"todo_id": todo.id,
|
||||
"status": todo.status,
|
||||
"priority": todo.priority,
|
||||
"assignee_user_id": todo.assignee_user_id,
|
||||
}
|
||||
_fire_and_forget(
|
||||
publish_topic(
|
||||
TOPIC_NAME,
|
||||
name=event_name,
|
||||
payload=payload,
|
||||
requires_refetch=[
|
||||
"/api/v1/todos",
|
||||
f"/api/v1/todos/{todo.id}",
|
||||
],
|
||||
dedupe_key=f"todos:{action}:{todo.id}",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _load_user_if_exists(db: Session, user_id: str | None) -> User | None:
|
||||
if not user_id:
|
||||
return None
|
||||
return db.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
|
||||
|
||||
|
||||
def _normalize_str(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = value.strip()
|
||||
return normalized or None
|
||||
|
||||
|
||||
def _fire_and_forget(coro: object) -> None:
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return
|
||||
loop.create_task(coro)
|
||||
@@ -23,6 +23,7 @@ TOPIC_RULES: dict[str, TopicRule] = {
|
||||
"admin.menus": TopicRule(any_permission_codes={"menu.read", "menu.manage"}),
|
||||
"admin.files": TopicRule(any_permission_codes={"file.read", "file.manage"}),
|
||||
"requirements": TopicRule(any_permission_codes={"requirement.read", "requirement.process", "requirement.manage"}),
|
||||
"todos": TopicRule(any_permission_codes={"todo.read", "todo.process", "todo.manage"}),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -9,8 +9,15 @@ from ..models.auth_session import AuthSession
|
||||
from ..models.base import utcnow
|
||||
from ..models.rbac import Role
|
||||
from ..models.user import User
|
||||
from ..schemas.user import UserListResponse, UserPublic, UserRoleUpdateRequest, UserUpdateRequest
|
||||
from .push_service import publish_to_user, publish_topic
|
||||
from ..schemas.user import (
|
||||
UserCreateRequest,
|
||||
UserListResponse,
|
||||
UserPasswordResetRequest,
|
||||
UserPublic,
|
||||
UserRoleUpdateRequest,
|
||||
UserUpdateRequest,
|
||||
)
|
||||
from ..core.security import hash_password
|
||||
from .ws_manager import ws_connection_manager
|
||||
|
||||
|
||||
@@ -40,6 +47,114 @@ def get_user_by_email(db: Session, email: str) -> User | None:
|
||||
return db.execute(stmt).unique().scalar_one_or_none()
|
||||
|
||||
|
||||
def get_user_by_username(db: Session, username: str) -> User | None:
|
||||
stmt = _user_with_rbac_stmt().where(User.username == username)
|
||||
return db.execute(stmt).unique().scalar_one_or_none()
|
||||
|
||||
|
||||
def create_user(
|
||||
db: Session,
|
||||
payload: UserCreateRequest,
|
||||
) -> UserPublic | None:
|
||||
user_id = payload.user_id.strip()
|
||||
|
||||
duplicate = db.scalar(
|
||||
select(User.id).where(
|
||||
(User.id == user_id) | (User.email == payload.email.lower()) | (User.username == payload.username)
|
||||
)
|
||||
)
|
||||
if duplicate:
|
||||
return None
|
||||
|
||||
role = db.scalar(select(Role).where(Role.code == "user"))
|
||||
if not role:
|
||||
return None
|
||||
|
||||
user = User(
|
||||
id=user_id,
|
||||
email=payload.email.lower(),
|
||||
username=payload.username,
|
||||
password_hash=hash_password(payload.password),
|
||||
status="active",
|
||||
)
|
||||
user.roles.append(role)
|
||||
|
||||
db.add(user)
|
||||
db.commit()
|
||||
|
||||
created = get_user_by_id(db, user_id)
|
||||
if created:
|
||||
queue_user_auth_refresh(created)
|
||||
_fire_and_forget(
|
||||
publish_topic(
|
||||
"admin.users",
|
||||
name="users.changed",
|
||||
payload={"action": "created", "user_id": created.id},
|
||||
requires_refetch=["/api/v1/users"],
|
||||
dedupe_key=f"users:created:{created.id}",
|
||||
)
|
||||
)
|
||||
return serialize_user(created) if created else None
|
||||
|
||||
|
||||
def delete_user(db: Session, user_id: str) -> bool:
|
||||
user = get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return False
|
||||
|
||||
revoke_active_sessions_for_user(db, user_id)
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
|
||||
_fire_and_forget(
|
||||
publish_topic(
|
||||
"admin.users",
|
||||
name="users.changed",
|
||||
payload={"action": "deleted", "user_id": user_id},
|
||||
requires_refetch=["/api/v1/users"],
|
||||
dedupe_key=f"users:deleted:{user_id}",
|
||||
)
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def reset_user_password(
|
||||
db: Session,
|
||||
user_id: str,
|
||||
payload: UserPasswordResetRequest,
|
||||
) -> UserPublic | None:
|
||||
user = get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return None
|
||||
|
||||
user.password_hash = hash_password(payload.new_password)
|
||||
revoke_active_sessions_for_user(db, user_id)
|
||||
db.commit()
|
||||
|
||||
updated = get_user_by_id(db, user_id)
|
||||
if updated:
|
||||
_fire_and_forget(
|
||||
publish_to_user(
|
||||
updated.id,
|
||||
topic="auth",
|
||||
name="auth.password_reset",
|
||||
payload={"user_id": updated.id},
|
||||
requires_refetch=["/api/v1/auth/me"],
|
||||
dedupe_key=f"auth:password_reset:{updated.id}",
|
||||
)
|
||||
)
|
||||
_fire_and_forget(
|
||||
publish_topic(
|
||||
"admin.users",
|
||||
name="users.changed",
|
||||
payload={"action": "password_reset", "user_id": updated.id},
|
||||
requires_refetch=["/api/v1/users"],
|
||||
dedupe_key=f"users:password_reset:{updated.id}",
|
||||
)
|
||||
)
|
||||
return serialize_user(updated) if updated else None
|
||||
|
||||
|
||||
def update_user(
|
||||
db: Session,
|
||||
user_id: str,
|
||||
|
||||
@@ -12,3 +12,4 @@ argon2-cffi==23.1.0
|
||||
email-validator==2.3.0
|
||||
python-multipart==0.0.20
|
||||
boto3==1.40.59
|
||||
httpx==0.28.1
|
||||
|
||||
Reference in New Issue
Block a user