Files
wehub-resource-sync fed8b2eed7
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:28:29 +08:00

239 lines
8.9 KiB
Python

"""Repository for the ``user_tools`` table.
Covers every operation the legacy Mongo code performs on
``user_tools_collection``:
1. ``find`` by user in tools/routes.py and base.py (list all / active)
2. ``find_one`` by id in tools/routes.py and sharing.py (get single)
3. ``insert_one`` in tools/routes.py and mcp.py (create)
4. ``update_one`` in tools/routes.py and mcp.py (update fields)
5. ``delete_one`` in tools/routes.py (delete)
6. ``find`` by user+status in stream_processor.py and tool_executor.py (active tools)
7. ``find_one`` by user+name in mcp.py (upsert check)
"""
from __future__ import annotations
import json
from typing import Any, Optional
from sqlalchemy import Connection, text
from application.storage.db.base_repository import looks_like_uuid, row_to_dict
_JSONB_COLUMNS = {"config", "config_requirements", "actions"}
_SCALAR_COLUMNS = {"name", "custom_name", "display_name", "description", "status"}
_ALLOWED_COLUMNS = _SCALAR_COLUMNS | _JSONB_COLUMNS
def _encode_jsonb(value: Any) -> Any:
"""Serialize a Python value for a JSONB bind parameter.
Accepts ``None``, already-encoded strings, or Python dict/list. Returns a
JSON string suitable for ``CAST(:x AS jsonb)``.
"""
if value is None:
return None
if isinstance(value, str):
return value
return json.dumps(value)
class UserToolsRepository:
"""Postgres-backed replacement for Mongo ``user_tools_collection``."""
def __init__(self, conn: Connection) -> None:
self._conn = conn
def create(
self,
user_id: str,
name: str,
*,
config: Optional[dict] = None,
custom_name: Optional[str] = None,
display_name: Optional[str] = None,
description: Optional[str] = None,
config_requirements: Optional[dict] = None,
actions: Optional[list] = None,
status: bool = True,
extra: Optional[dict] = None,
legacy_mongo_id: Optional[str] = None,
) -> dict:
"""Insert a new tool row. ``extra`` is merged into the config JSONB."""
cfg = config or {}
if extra:
cfg.update(extra)
result = self._conn.execute(
text(
"""
INSERT INTO user_tools (
user_id, name, custom_name, display_name, description,
config, config_requirements, actions, status, legacy_mongo_id
)
VALUES (
:user_id, :name, :custom_name, :display_name, :description,
CAST(:config AS jsonb),
CAST(:config_requirements AS jsonb),
CAST(:actions AS jsonb),
:status, :legacy_mongo_id
)
RETURNING *
"""
),
{
"user_id": user_id,
"name": name,
"custom_name": custom_name,
"display_name": display_name,
"description": description,
"config": json.dumps(cfg),
"config_requirements": _encode_jsonb(config_requirements or {}),
"actions": _encode_jsonb(actions or []),
"status": status,
"legacy_mongo_id": legacy_mongo_id,
},
)
return row_to_dict(result.fetchone())
def get(self, tool_id: str, user_id: str) -> Optional[dict]:
result = self._conn.execute(
text("SELECT * FROM user_tools WHERE id = CAST(:id AS uuid) AND user_id = :user_id"),
{"id": tool_id, "user_id": user_id},
)
row = result.fetchone()
return row_to_dict(row) if row is not None else None
def get_by_legacy_id(
self, legacy_mongo_id: str, user_id: Optional[str] = None,
) -> Optional[dict]:
"""Fetch a user_tool by the original Mongo ObjectId string."""
legacy_mongo_id = str(legacy_mongo_id) if legacy_mongo_id is not None else None
sql = "SELECT * FROM user_tools WHERE legacy_mongo_id = :legacy_id"
params: dict = {"legacy_id": legacy_mongo_id}
if user_id is not None:
sql += " AND user_id = :user_id"
params["user_id"] = user_id
result = self._conn.execute(text(sql), params)
row = result.fetchone()
return row_to_dict(row) if row is not None else None
def get_any(self, tool_id: str, user_id: str) -> Optional[dict]:
"""Resolve a user_tool by PG UUID or legacy Mongo ObjectId string.
Cutover helper: route handlers may receive either shape from
older clients. Always returns a row scoped to ``user_id``.
"""
if looks_like_uuid(tool_id):
row = self.get(tool_id, user_id)
if row is not None:
return row
return self.get_by_legacy_id(tool_id, user_id)
def get_by_id(self, tool_id: str) -> Optional[dict]:
"""Fetch a user_tool by id with NO ownership scoping.
Used ONLY after a team-grant authorization check (team sharing). Callers
MUST strip ``encrypted_credentials`` before returning a non-owner's tool.
"""
if not looks_like_uuid(tool_id):
return None
result = self._conn.execute(
text("SELECT * FROM user_tools WHERE id = CAST(:id AS uuid)"),
{"id": tool_id},
)
row = result.fetchone()
return row_to_dict(row) if row is not None else None
def list_by_ids(self, tool_ids) -> list[dict]:
"""Fetch user_tools whose id is in ``tool_ids`` (team-shared listing path)."""
ids = [str(t) for t in tool_ids if looks_like_uuid(str(t))]
if not ids:
return []
result = self._conn.execute(
text("SELECT * FROM user_tools WHERE id = ANY(CAST(:ids AS uuid[])) ORDER BY created_at"),
{"ids": ids},
)
return [row_to_dict(r) for r in result.fetchall()]
def list_for_user(self, user_id: str) -> list[dict]:
result = self._conn.execute(
text("SELECT * FROM user_tools WHERE user_id = :user_id ORDER BY created_at"),
{"user_id": user_id},
)
return [row_to_dict(r) for r in result.fetchall()]
def list_active_for_user(self, user_id: str) -> list[dict]:
"""Return only tools with ``status = true`` — matches the legacy
``find({"user": user, "status": True})`` used by the answer pipeline."""
result = self._conn.execute(
text(
"SELECT * FROM user_tools "
"WHERE user_id = :user_id AND status = true "
"ORDER BY created_at"
),
{"user_id": user_id},
)
return [row_to_dict(r) for r in result.fetchall()]
def find_by_user_and_name(self, user_id: str, name: str) -> Optional[dict]:
"""Used by the MCP save flow to decide between insert and update."""
result = self._conn.execute(
text(
"SELECT * FROM user_tools "
"WHERE user_id = :user_id AND name = :name "
"LIMIT 1"
),
{"user_id": user_id, "name": name},
)
row = result.fetchone()
return row_to_dict(row) if row is not None else None
def update(self, tool_id: str, user_id: str, fields: dict) -> bool:
"""Update arbitrary fields on a tool row.
``fields`` maps column names to new values. Only columns in
``_ALLOWED_COLUMNS`` are honored; unknown keys are silently dropped
to keep route handlers from accidentally leaking DB shape. JSONB
values are accepted as dicts/lists and serialized here.
Returns ``True`` if the row was updated, ``False`` if the id/user
didn't match anything.
"""
filtered = {k: v for k, v in fields.items() if k in _ALLOWED_COLUMNS}
if not filtered:
return False
set_clauses: list[str] = []
params: dict = {"id": tool_id, "user_id": user_id}
for col, val in filtered.items():
if col not in _ALLOWED_COLUMNS:
raise ValueError(f"disallowed column: {col!r}")
if col in _JSONB_COLUMNS:
set_clauses.append(f"{col} = CAST(:{col} AS jsonb)")
params[col] = _encode_jsonb(val)
else:
set_clauses.append(f"{col} = :{col}")
params[col] = val
set_clauses.append("updated_at = now()")
result = self._conn.execute(
text(
f"""
UPDATE user_tools
SET {", ".join(set_clauses)}
WHERE id = CAST(:id AS uuid) AND user_id = :user_id
"""
),
params,
)
return result.rowcount > 0
def delete(self, tool_id: str, user_id: str) -> bool:
result = self._conn.execute(
text("DELETE FROM user_tools WHERE id = CAST(:id AS uuid) AND user_id = :user_id"),
{"id": tool_id, "user_id": user_id},
)
return result.rowcount > 0