chore: commit local changes
This commit is contained in:
1 parent
c1336ad5b3
commit
e6e0832c8a
15 files changed
+291
-24
No files matched your search
@@ -71,6 +71,14 @@ class LocalAuthProvider(AuthProvider):
|
||||
"""
|
||||
return await self._repo.find_user_by_email_local_part(local_part)
|
||||
|
||||
async def find_users_by_email_local_part_like(self, substring: str, *, limit: int = 500) -> list[User]:
|
||||
"""Fuzzy-match users by a substring of their email local part.
|
||||
|
||||
Backs the admin LLM-metrics username filter — returns every account
|
||||
whose username contains ``substring`` (case-insensitive).
|
||||
"""
|
||||
return await self._repo.find_users_by_email_local_part_like(substring, limit=limit)
|
||||
|
||||
async def create_user(self, email: str, password: str | None = None, system_role: str = "user", needs_setup: bool = False) -> User:
|
||||
"""Create a new local user.
|
||||
|
||||
|
||||
@@ -81,6 +81,24 @@ class UserRepository(ABC):
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
async def find_users_by_email_local_part_like(self, substring: str, *, limit: int = 500) -> list["User"]:
|
||||
"""Fuzzy-match users whose email local part *contains* ``substring``.
|
||||
|
||||
Backs the admin LLM-metrics username filter (按用户名模糊搜索). Unlike
|
||||
:meth:`find_user_by_email_local_part` (exact prefix → single account),
|
||||
this returns every matching account. Case-insensitive substring match
|
||||
against the part before ``@``. Non-overriding repositories return an
|
||||
empty list.
|
||||
|
||||
Args:
|
||||
substring: Case-insensitive substring to match within the local part.
|
||||
limit: Maximum number of users to return.
|
||||
|
||||
Returns:
|
||||
Matching users (may be empty).
|
||||
"""
|
||||
return []
|
||||
|
||||
@abstractmethod
|
||||
async def update_user(self, user: User) -> User:
|
||||
"""Update an existing user.
|
||||
|
||||
@@ -109,6 +109,33 @@ class SQLiteUserRepository(UserRepository):
|
||||
row = result.scalar_one_or_none()
|
||||
return self._row_to_user(row) if row is not None else None
|
||||
|
||||
async def find_users_by_email_local_part_like(self, substring: str, *, limit: int = 500) -> list[User]:
|
||||
term = (substring or "").strip().lower()
|
||||
if not term:
|
||||
return []
|
||||
# Escape SQL LIKE wildcards so a literal ``_``/``%`` in the query does
|
||||
# not turn into a wildcard. ``\`` is the chosen escape char.
|
||||
escaped = term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
pattern = f"%{escaped}%"
|
||||
# Prefilter in SQL on the whole email (portable across sqlite/postgres/
|
||||
# mysql), then keep only rows whose *local part* contains the term so a
|
||||
# domain-only hit (e.g. searching "gmail") doesn't pull unrelated users.
|
||||
stmt = (
|
||||
select(UserRow)
|
||||
.where(func.lower(UserRow.email).like(pattern, escape="\\"))
|
||||
.order_by(UserRow.created_at.asc())
|
||||
.limit(max(1, limit))
|
||||
)
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
rows = result.scalars().all()
|
||||
out: list[User] = []
|
||||
for row in rows:
|
||||
local = (row.email or "").split("@", 1)[0].lower()
|
||||
if term in local:
|
||||
out.append(self._row_to_user(row))
|
||||
return out
|
||||
|
||||
async def update_user(self, user: User) -> User:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(UserRow, str(user.id))
|
||||
|
||||
@@ -22,7 +22,7 @@ from app.gateway.auth import (
|
||||
from app.gateway.auth.config import get_auth_config
|
||||
from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse
|
||||
from app.gateway.csrf_middleware import is_secure_request
|
||||
from app.gateway.deps import get_agent_store, get_current_user_from_request, get_local_provider, get_skill_store
|
||||
from app.gateway.deps import get_agent_store, get_current_user_from_request, get_local_provider, get_run_store, get_skill_store
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -749,6 +749,7 @@ class UserListItem(BaseModel):
|
||||
system_role: Literal["admin", "user"]
|
||||
skill_count: int
|
||||
agent_count: int
|
||||
qa_count: int = 0 # 问答次数: number of runs (one run = one question)
|
||||
|
||||
|
||||
@router.get("/users", response_model=list[UserListItem])
|
||||
@@ -757,11 +758,19 @@ async def list_all_users(
|
||||
skill_store: SkillStore = Depends(get_skill_store),
|
||||
agent_store: AgentStore = Depends(get_agent_store),
|
||||
):
|
||||
"""List all users with their skill and agent counts. Admin only."""
|
||||
"""List all users with their skill, agent and Q&A counts. Admin only."""
|
||||
user = await get_current_user_from_request(request)
|
||||
if user.system_role != "admin":
|
||||
raise HTTPException(status_code=403, detail="Admin only")
|
||||
users = await get_local_provider().list_users()
|
||||
# One GROUP BY query for everyone's 问答次数, so the list stays a single
|
||||
# aggregate read instead of a per-user count. Best-effort: if the run store
|
||||
# is unavailable the column simply renders as 0 rather than failing the list.
|
||||
try:
|
||||
qa_counts = await get_run_store(request).count_by_user()
|
||||
except Exception:
|
||||
logger.warning("Failed to load per-user run counts for user list", exc_info=True)
|
||||
qa_counts = {}
|
||||
result = []
|
||||
for u in users:
|
||||
uid = str(u.id)
|
||||
@@ -772,6 +781,7 @@ async def list_all_users(
|
||||
system_role=u.system_role,
|
||||
skill_count=await skill_store.count_owned(uid),
|
||||
agent_count=await agent_store.count_owned(uid),
|
||||
qa_count=qa_counts.get(uid, 0),
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -121,6 +121,26 @@ async def _resolve_user_name_to_id(name: str | None) -> str | None:
|
||||
return str(user.id) if user is not None else None
|
||||
|
||||
|
||||
async def _resolve_user_names_to_ids(name: str | None) -> list[str]:
|
||||
"""Fuzzy-resolve a username fragment to every matching account's UUID.
|
||||
|
||||
Powers the 按用户名模糊搜索 filter: typing part of a username matches all
|
||||
users whose email local part contains it. Returns an empty list when the
|
||||
fragment matches nobody, so the caller can short-circuit to zero rows.
|
||||
"""
|
||||
if not name or not name.strip():
|
||||
return []
|
||||
try:
|
||||
provider = get_local_provider()
|
||||
except Exception:
|
||||
return []
|
||||
try:
|
||||
users = await provider.find_users_by_email_local_part_like(name.strip())
|
||||
except Exception:
|
||||
return []
|
||||
return [str(u.id) for u in users]
|
||||
|
||||
|
||||
async def _attach_usernames(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Resolve each row's ``user_id`` UUID to a friendly ``user_name``.
|
||||
|
||||
@@ -161,9 +181,11 @@ def _build_query(
|
||||
until: str | None,
|
||||
limit: int,
|
||||
offset: int,
|
||||
user_ids: list[str] | None = None,
|
||||
) -> LlmMetricsQuery:
|
||||
return LlmMetricsQuery(
|
||||
user_id=user_id or None,
|
||||
user_ids=user_ids or None,
|
||||
model_name=model_name or None,
|
||||
status=status or None,
|
||||
since=_parse_dt(since),
|
||||
@@ -187,14 +209,15 @@ async def list_llm_metrics(
|
||||
) -> LlmMetricsListResponse:
|
||||
await _require_admin(request)
|
||||
store = _require_store(request)
|
||||
user_ids: list[str] | None = None
|
||||
if not user_id and user_name:
|
||||
resolved = await _resolve_user_name_to_id(user_name)
|
||||
if resolved is None:
|
||||
# Username unknown → no rows; short-circuit instead of a global scan.
|
||||
user_ids = await _resolve_user_names_to_ids(user_name)
|
||||
if not user_ids:
|
||||
# No username matches → no rows; short-circuit the global scan.
|
||||
return LlmMetricsListResponse(items=[], total=0, limit=limit, offset=offset)
|
||||
user_id = resolved
|
||||
query = _build_query(
|
||||
user_id=user_id,
|
||||
user_ids=user_ids,
|
||||
model_name=model_name,
|
||||
status=status,
|
||||
since=since,
|
||||
@@ -236,14 +259,13 @@ async def export_llm_metrics(
|
||||
"""Stream the full filtered metric set as an ``.xlsx`` file."""
|
||||
await _require_admin(request)
|
||||
store = _require_store(request)
|
||||
user_ids: list[str] | None = None
|
||||
if not user_id and user_name:
|
||||
resolved = await _resolve_user_name_to_id(user_name)
|
||||
if resolved is None:
|
||||
# No matching user — return an empty xlsx so the user still gets a
|
||||
# file (matching the list view's empty state).
|
||||
user_id = "__no_such_user__"
|
||||
else:
|
||||
user_id = resolved
|
||||
user_ids = await _resolve_user_names_to_ids(user_name)
|
||||
if not user_ids:
|
||||
# No matching user — sentinel id that matches nothing, so the export
|
||||
# still produces an empty xlsx (matching the list view's empty state).
|
||||
user_ids = ["__no_such_user__"]
|
||||
|
||||
try:
|
||||
from openpyxl import Workbook
|
||||
@@ -252,6 +274,7 @@ async def export_llm_metrics(
|
||||
|
||||
query = _build_query(
|
||||
user_id=user_id,
|
||||
user_ids=user_ids,
|
||||
model_name=model_name,
|
||||
status=status,
|
||||
since=since,
|
||||
|
||||
Reference in new issue
Block a user