mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 16:52:00 +00:00
941723b17c
- 身份与工具:登录页建第一个管理员(认人接口的 401 说明能不能建);搭建者有只读的人和组名录,选人给候选并核对名字;授权三行带标题;端点请求头名校验(空名、非法名存不进、发不出时说清原因);写入能力在工具页试运行一律演练、不发送,HTTP 状态照实说成败;写记忆不算只读、查询能力只能选同一端点上的只读能力;审批与遮罩的说法和实际规则一致;开登录时不要署名、导入和发布记登录的人;使用者不显示推演等只给搭建者的操作,403 按是否开登录说话;登录页只认一次人 - 流程与实例:图里用到的岗位(派给谁、超时升级给谁、发送确认派给谁)全部派生、没绑人的发布时报错;写外部系统的参数按能力声明的类型收拢后再算哈希和发送,收不成就不发;发起人的实例页只看这一步的状态,处理人才给待办卡;会签进度带登录核实和时间,升级后照实写规则;实例状态写流程的结论(已退回、未确认、等待核实写入结果),核实生效后不再挂失败原因;流程应用首页不派生最近封存运行的结果块,看板没数据时是安静的空态;样例⑩的金额带类型和下限、待办标题带金额,表单上下限前后端一起生效 - 看板与证据:定时任务模板加 ISO 周、上一 ISO 周、本月、上月;出处链穿过原样解析查询结果的整形节点,追不到原始数据的照实说停在哪里,样例⑨改用 cell();应用首页和版本历史都能做成应用;指标块单位不重复;版本列表带退役说明;定时任务页头跟着刷新;删视图要确认、新视图能起名;编辑器属性用中文名、术语带解释 - 指标出具(用户 2026-10-15 拍板):契约只写「指标来自」、上游没有报告撰写节点、成果里没有模型写的话时按指标出具——必需指标齐、指标输入追到原始数据、成果字段里的数回指得上才完整出具;受管级要求每个指标静态地追到原始数据(governed.metric_untraced);出具横幅和应用印章写明按指标 - 受管工作流降为已发布只给管理员(Q8 顺延,服务端同样拦);工作流接口带当前发布版的级别,画布上改过之后也认得出受管工作流
501 lines
23 KiB
Python
501 lines
23 KiB
Python
"""按配方导入的接口(期 2,WP-5c;P2-SPEC 7.3):暂存、回答问题、改配方、AI 起草、试运行、提交、放弃,
|
||
以及「上传新一期」「修改配方」「查看当前配方」。
|
||
|
||
流程和状态都在 app.data.recipe_imports 里,这里只做三件事:解析请求、取署名、把业务错误转成带机读码的
|
||
CodedHTTPException(body 是 {"detail": 中文, "code": 机读码},前端按 code 分支)。
|
||
|
||
**请求模型里不放会触发 FastAPI 自带 422 的必填项**:它的 body 是 {detail: [...]}、不带 code,前端没法分支。
|
||
所以 JSON 请求体一律手工读成 dict(不是 JSON 对象才回 422 body_invalid),各字段由业务代码检查。
|
||
|
||
路由路径都以 /imports/ 开头,或是 /{source_id}/ 后面跟固定的 reupload、redraft、recipe,与 datasources.py
|
||
现有的 /{source_id}、/{source_id}/test|introspect|schema|imports 不重叠;在 main.py 里登记在 datasources
|
||
之前,免得 /imports/… 被通配的 {source_id} 先截走。
|
||
|
||
期 3(WP-5)加了 edits/preview、edits/apply、edits/undo、redraft-rules,PUT recipe 收可选的 origin;只读进程
|
||
(没拿到版本存储的守卫)里会写版本存储的入口一律回 503 store_unavailable(第 8 节遗留项 5),body 照样是
|
||
{detail, code}:前端把不是 JSON 的 503 当成网络断开。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import datetime as _dt
|
||
import logging
|
||
import re
|
||
from typing import Any
|
||
|
||
from fastapi import APIRouter, Depends, File, Form, Header, Request, UploadFile
|
||
from fastapi.responses import JSONResponse, Response
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.api.coded import CodedHTTPException
|
||
from app.api.datasources import _NAME_PATTERN, _out, read_upload
|
||
from app.api.deps import current_principal, signer_of
|
||
from app.data import recipe_imports, table_versions
|
||
from app.data.recipe_imports import EditRequest, ImportRefused
|
||
from app.data.recipe_types import Acceptance, AiAvailability, PeriodInput, Recipe, Selection
|
||
from app.db.base import get_session
|
||
from app.db.models import DataSource, ImportStaging, TableRecipe
|
||
|
||
router = APIRouter(prefix="/api/datasources", tags=["table-imports"])
|
||
logger = logging.getLogger(__name__)
|
||
|
||
_NAME_INVALID = "名称必须以小写字母开头,只能包含字母、数字和下划线(名称会成为工具名的一部分)"
|
||
_SIGNED_MAX = 100
|
||
|
||
|
||
#: 业务上的拒绝:ImportRefused 带着状态和机读码;StoreUnavailable 是只读进程拒写(503)
|
||
_REFUSALS = (ImportRefused, table_versions.StoreUnavailable)
|
||
|
||
|
||
def _refused(e: Exception) -> CodedHTTPException:
|
||
if isinstance(e, table_versions.StoreUnavailable):
|
||
return CodedHTTPException(503, str(e), "store_unavailable")
|
||
return CodedHTTPException(e.status, e.message, e.code) # type: ignore[attr-defined]
|
||
|
||
|
||
def _signed(body: dict[str, Any] | None, header: str | None) -> str | None:
|
||
"""署名:开了登录是登录的人;关登录时请求体的 signed_by 优先,其次请求头 X-Actor,只原样记下(未认证)。"""
|
||
raw = (body or {}).get("signed_by") if isinstance(body, dict) else None
|
||
return signer_of(raw, header, _SIGNED_MAX)
|
||
|
||
|
||
def _login_on() -> bool:
|
||
return current_principal().mode != "off"
|
||
|
||
|
||
#: JSON 请求体的字节上限和嵌套层数上限(SE-5)。最大的合法配方(200 个标签、几十个分段)也只有一两百 KB、
|
||
#: 十来层;更大更深的不是配方,照存会让静态校验跑几十秒、占着解析名额,深层嵌套还会让响应序列化失败
|
||
BODY_MAX_BYTES = 1_000_000
|
||
BODY_MAX_DEPTH = 32
|
||
|
||
|
||
def _json_depth_over(raw: bytes, limit: int) -> bool:
|
||
depth = 0
|
||
in_str = esc = False
|
||
for ch in raw.decode("utf-8", errors="replace"):
|
||
if in_str:
|
||
if esc:
|
||
esc = False
|
||
elif ch == "\\":
|
||
esc = True
|
||
elif ch == '"':
|
||
in_str = False
|
||
elif ch == '"':
|
||
in_str = True
|
||
elif ch in "[{":
|
||
depth += 1
|
||
if depth > limit:
|
||
return True
|
||
elif ch in "]}":
|
||
depth -= 1
|
||
return False
|
||
|
||
|
||
async def _body(request: Request) -> dict[str, Any]:
|
||
"""JSON 请求体。空 body 当作 {};超过字节上限回 413 body_too_large;不是 JSON 对象、嵌套过深回 422 body_invalid。"""
|
||
length = request.headers.get("content-length")
|
||
if length and length.isdigit() and int(length) > BODY_MAX_BYTES:
|
||
raise CodedHTTPException(413, f"请求体超过 {BODY_MAX_BYTES // 1_000_000} MB 的上限", "body_too_large")
|
||
raw = await request.body()
|
||
if len(raw) > BODY_MAX_BYTES:
|
||
raise CodedHTTPException(413, f"请求体超过 {BODY_MAX_BYTES // 1_000_000} MB 的上限", "body_too_large")
|
||
if not raw.strip():
|
||
return {}
|
||
if _json_depth_over(raw, BODY_MAX_DEPTH):
|
||
raise CodedHTTPException(422, f"请求体的 JSON 嵌套超过 {BODY_MAX_DEPTH} 层", "body_invalid")
|
||
try:
|
||
data = await request.json()
|
||
except (ValueError, RecursionError):
|
||
data = None
|
||
if not isinstance(data, dict):
|
||
raise CodedHTTPException(422, "请求体必须是一个 JSON 对象", "body_invalid")
|
||
return data
|
||
|
||
|
||
async def _staging(session: AsyncSession, staging_id: str) -> ImportStaging:
|
||
row = await session.get(ImportStaging, staging_id)
|
||
if row is None:
|
||
raise CodedHTTPException(404, "这次导入不存在,可能已过期并被清理。请重新开始导入", "staging_not_found")
|
||
return row
|
||
|
||
|
||
async def _open_staging(session: AsyncSession, staging_id: str) -> ImportStaging:
|
||
"""写操作要的暂存区:先让过期的过期(不能指望只有回收时才过期),再要求它未结束。"""
|
||
await table_versions.expire_stagings(session)
|
||
row = await _staging(session, staging_id)
|
||
if not table_versions.staging_is_open(row):
|
||
raise CodedHTTPException(409, recipe_imports._CLOSED, "staging_closed")
|
||
return row
|
||
|
||
|
||
async def _source(session: AsyncSession, source_id: str) -> DataSource:
|
||
row = await session.get(DataSource, source_id)
|
||
if row is None:
|
||
raise CodedHTTPException(404, "数据源不存在,可能已被删除", "source_not_found")
|
||
return row
|
||
|
||
|
||
async def _out_of(session: AsyncSession, staging: ImportStaging) -> dict[str, Any]:
|
||
"""StagingOut。提供 AI 起草时才去查模型接入的可用性(不提供时不必多一次查询)。"""
|
||
source = await session.get(DataSource, staging.source_id)
|
||
pipe = recipe_imports.pipeline()
|
||
ai: AiAvailability | None = None
|
||
if table_versions.staging_is_open(staging) and recipe_imports.ai_offered(staging):
|
||
try:
|
||
ai = await pipe.ai_availability(session)
|
||
except Exception as e: # noqa: BLE001 - 可用性只是提示,查不到按不可用显示
|
||
logger.warning("ai availability failed: %s", type(e).__name__)
|
||
ai = AiAvailability(False, reason="暂时无法确认模型接入是否可用。请到「设置 → 模型接入」检查")
|
||
return recipe_imports.staging_out(staging, source, ai=ai, units=tuple(pipe.units) or None)
|
||
|
||
|
||
def _unreadable(e: Exception) -> CodedHTTPException:
|
||
return CodedHTTPException(400, str(e) or "无法读取这个文件,请确认是未加密的 Excel 文件", "file_unreadable")
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 暂存、查看、放弃
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
@router.post("/imports/stage", status_code=201)
|
||
async def stage(
|
||
file: UploadFile | None = File(None),
|
||
name: str = Form(""),
|
||
description: str = Form(""),
|
||
signed_by: str = Form(""),
|
||
x_actor: str | None = Header(default=None),
|
||
session: AsyncSession = Depends(get_session),
|
||
) -> Any:
|
||
"""选文件开始一次按配方导入:原件存档、扫描、网格预览、系统发现、规则起草。新名字 kind=first;
|
||
同名的简单导入源 kind=switch。"""
|
||
from app.data.tabular import UnsupportedTable
|
||
|
||
if file is None:
|
||
raise CodedHTTPException(422, "请选择要导入的文件", "body_invalid")
|
||
if not re.match(_NAME_PATTERN, name or ""):
|
||
raise CodedHTTPException(400, _NAME_INVALID, "name_invalid")
|
||
raw = await read_upload(file)
|
||
try:
|
||
staging = await recipe_imports.stage_upload(
|
||
session, raw=raw, filename=file.filename or name, name=name, description=description or "",
|
||
signed_by=_signed({"signed_by": signed_by}, x_actor))
|
||
except _REFUSALS as e:
|
||
raise _refused(e) from e
|
||
except UnsupportedTable as e:
|
||
raise _unreadable(e) from e
|
||
return JSONResponse(status_code=201, content=await _out_of(session, staging))
|
||
|
||
|
||
@router.get("/imports/{staging_id}")
|
||
async def get_staging(staging_id: str, session: AsyncSession = Depends(get_session)) -> Any:
|
||
await table_versions.expire_stagings(session)
|
||
return await _out_of(session, await _staging(session, staging_id))
|
||
|
||
|
||
@router.delete("/imports/{staging_id}", status_code=204)
|
||
async def discard(staging_id: str, session: AsyncSession = Depends(get_session)) -> Response:
|
||
staging = await _open_staging(session, staging_id)
|
||
try:
|
||
await recipe_imports.discard_staging(session, staging)
|
||
except _REFUSALS as e:
|
||
raise _refused(e) from e
|
||
return Response(status_code=204)
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 回答问题、改配方
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
@router.post("/imports/{staging_id}/answers")
|
||
async def answers(staging_id: str, request: Request, session: AsyncSession = Depends(get_session)) -> Any:
|
||
body = await _body(request)
|
||
given = body.get("answers", {})
|
||
if not isinstance(given, dict):
|
||
raise CodedHTTPException(422, "回答必须是「问题 → 选项」的对象", "body_invalid")
|
||
staging = await _open_staging(session, staging_id)
|
||
try:
|
||
staging = await recipe_imports.answer(session, staging, given)
|
||
except _REFUSALS as e:
|
||
raise _refused(e) from e
|
||
return await _out_of(session, staging)
|
||
|
||
|
||
@router.put("/imports/{staging_id}/recipe")
|
||
async def put_recipe(staging_id: str, request: Request, session: AsyncSession = Depends(get_session)) -> Any:
|
||
body = await _body(request)
|
||
recipe = body.get("recipe")
|
||
if not isinstance(recipe, dict):
|
||
raise CodedHTTPException(422, "配方必须是一个 JSON 对象", "body_invalid")
|
||
origin = body.get("origin")
|
||
if origin not in (None, "rules_redraft"):
|
||
raise CodedHTTPException(422, "配方来源只能不填,或填 rules_redraft(采用按规则重新起草的结果)", "body_invalid")
|
||
staging = await _open_staging(session, staging_id)
|
||
try:
|
||
staging = await recipe_imports.put_recipe(session, staging, recipe, origin=origin or "manual")
|
||
except _REFUSALS as e:
|
||
raise _refused(e) from e
|
||
return await _out_of(session, staging)
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 期 3:修复按钮、框选、撤销、按规则重新起草
|
||
# --------------------------------------------------------------------------
|
||
|
||
_EDIT_SHAPE = "请求里要么给修复(fix),要么给框选(selection),二选一"
|
||
|
||
|
||
def _edit_request(body: dict[str, Any]) -> EditRequest:
|
||
"""{"fix": {id, option, reason?}} 或 {"selection": {sheet, ref, as, options?}},二选一(9.1)。形状不对回 422
|
||
edit_invalid;框选的形状校验交给契约 Selection.from_json(只抛 ValueError),输出时再经 to_json 改回 as。"""
|
||
fix, sel = body.get("fix"), body.get("selection")
|
||
if (fix is None) == (sel is None):
|
||
raise CodedHTTPException(422, _EDIT_SHAPE, "edit_invalid")
|
||
if fix is not None:
|
||
if (not isinstance(fix, dict) or not isinstance(fix.get("id"), str) or not fix["id"]
|
||
or not isinstance(fix.get("option"), str) or not fix["option"]):
|
||
raise CodedHTTPException(422, "修复请求要指明是哪条修复建议(id)和选的哪一项(option)", "edit_invalid")
|
||
reason = fix.get("reason")
|
||
if reason is not None and not isinstance(reason, str):
|
||
raise CodedHTTPException(422, "理由必须是一段文字", "edit_invalid")
|
||
return EditRequest(kind="fix", fix_id=fix["id"], option=fix["option"], reason=reason)
|
||
try:
|
||
return EditRequest(kind="selection", selection=Selection.from_json(sel))
|
||
except ValueError as e:
|
||
raise CodedHTTPException(422, str(e) or "框选的请求格式不对", "edit_invalid") from e
|
||
|
||
|
||
@router.post("/imports/{staging_id}/edits/preview")
|
||
async def edits_preview(staging_id: str, request: Request, session: AsyncSession = Depends(get_session)) -> Any:
|
||
"""修复或框选的预览(不改工作配方)。seq 原样回传:界面只采用最新一次的回包。"""
|
||
body = await _body(request)
|
||
req = _edit_request(body)
|
||
staging = await _open_staging(session, staging_id)
|
||
try:
|
||
return await recipe_imports.edit_preview(session, staging, req, seq=body.get("seq"))
|
||
except _REFUSALS as e:
|
||
raise _refused(e) from e
|
||
|
||
|
||
@router.post("/imports/{staging_id}/edits/apply")
|
||
async def edits_apply(staging_id: str, request: Request, x_actor: str | None = Header(default=None),
|
||
session: AsyncSession = Depends(get_session)) -> Any:
|
||
"""应用预览过的修复或框选:选项和理由与预览时逐字相同,expected_sha256 是预览给的 recipe_sha256_after。"""
|
||
body = await _body(request)
|
||
req = _edit_request(body)
|
||
expected = body.get("expected_sha256")
|
||
staging = await _open_staging(session, staging_id)
|
||
try:
|
||
staging = await recipe_imports.edit_apply(
|
||
session, staging, req, expected_sha256=expected if isinstance(expected, str) else None,
|
||
signed_by=_signed(body, x_actor))
|
||
except _REFUSALS as e:
|
||
raise _refused(e) from e
|
||
return await _out_of(session, staging)
|
||
|
||
|
||
@router.post("/imports/{staging_id}/edits/undo")
|
||
async def edits_undo(staging_id: str, request: Request, session: AsyncSession = Depends(get_session)) -> Any:
|
||
await _body(request)
|
||
staging = await _open_staging(session, staging_id)
|
||
try:
|
||
staging = await recipe_imports.edit_undo(session, staging)
|
||
except _REFUSALS as e:
|
||
raise _refused(e) from e
|
||
return await _out_of(session, staging)
|
||
|
||
|
||
@router.post("/imports/{staging_id}/redraft-rules")
|
||
async def redraft_rules(staging_id: str, request: Request, session: AsyncSession = Depends(get_session)) -> Any:
|
||
"""按规则重新起草(不调用模型、不改工作配方),名字对齐到现行配方;「采用」走 PUT recipe(origin=rules_redraft)。"""
|
||
from app.data.tabular import UnsupportedTable
|
||
|
||
await _body(request)
|
||
staging = await _open_staging(session, staging_id)
|
||
try:
|
||
return await recipe_imports.redraft_rules(session, staging)
|
||
except _REFUSALS as e:
|
||
raise _refused(e) from e
|
||
except UnsupportedTable as e:
|
||
raise _unreadable(e) from e
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# AI 起草
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
@router.get("/imports/{staging_id}/draft-ai/preview")
|
||
async def draft_ai_preview(staging_id: str, session: AsyncSession = Depends(get_session)) -> Any:
|
||
staging = await _open_staging(session, staging_id)
|
||
try:
|
||
return await recipe_imports.ai_preview(session, staging)
|
||
except _REFUSALS as e:
|
||
raise _refused(e) from e
|
||
|
||
|
||
@router.post("/imports/{staging_id}/draft-ai")
|
||
async def draft_ai(staging_id: str, request: Request, x_actor: str | None = Header(default=None),
|
||
session: AsyncSession = Depends(get_session)) -> Any:
|
||
body = await _body(request)
|
||
sha = body.get("preview_sha256")
|
||
staging = await _open_staging(session, staging_id)
|
||
try:
|
||
staging = await recipe_imports.run_ai_draft(
|
||
session, staging, consent=body.get("consent") is True,
|
||
preview_sha256=sha if isinstance(sha, str) else None, signed_by=_signed(body, x_actor))
|
||
except _REFUSALS as e:
|
||
raise _refused(e) from e
|
||
return await _out_of(session, staging)
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 试运行、提交
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def _context_inputs(raw: Any, signed_by: str | None) -> dict[str, PeriodInput] | None:
|
||
"""{"统计期": {"start": "2026-09-01", "end": "2026-09-30"}} → PeriodInput。不合法回 422 context_invalid。"""
|
||
if raw is None or raw == {}:
|
||
return None
|
||
bad = CodedHTTPException(422, "统计期的录入不对:请填写起止日期(YYYY-MM-DD),起点不能晚于终点", "context_invalid")
|
||
if not isinstance(raw, dict):
|
||
raise bad
|
||
out: dict[str, PeriodInput] = {}
|
||
for key, value in raw.items():
|
||
if key != recipe_imports.PERIOD_KEY or not isinstance(value, dict):
|
||
raise bad
|
||
try:
|
||
start = _dt.date.fromisoformat(str(value.get("start") or ""))
|
||
end = _dt.date.fromisoformat(str(value.get("end") or ""))
|
||
except ValueError:
|
||
raise bad from None
|
||
if start > end:
|
||
raise bad
|
||
who = value.get("signed_by")
|
||
# 开了登录时 signed_by 已经是登录的人,这里填的不看(signer_of 同一条)
|
||
out[key] = PeriodInput(start=start, end=end,
|
||
signed_by=(str(who).strip()[:_SIGNED_MAX]
|
||
if isinstance(who, str) and who.strip() and not _login_on() else signed_by))
|
||
return out
|
||
|
||
|
||
@router.post("/imports/{staging_id}/trial")
|
||
async def trial(staging_id: str, request: Request, x_actor: str | None = Header(default=None),
|
||
session: AsyncSession = Depends(get_session)) -> Any:
|
||
body = await _body(request)
|
||
who = _signed(body, x_actor)
|
||
inputs = _context_inputs(body.get("context_inputs"), who)
|
||
staging = await _open_staging(session, staging_id)
|
||
try:
|
||
staging = await recipe_imports.run_trial(session, staging, context_inputs=inputs, signed_by=who)
|
||
except _REFUSALS as e:
|
||
raise _refused(e) from e
|
||
return await _out_of(session, staging)
|
||
|
||
|
||
def _acceptances(raw: Any, who: str | None) -> list[Acceptance]:
|
||
if raw is None:
|
||
return []
|
||
if not isinstance(raw, list):
|
||
raise CodedHTTPException(422, "接受理由必须是「核对 → 理由」的列表", "body_invalid")
|
||
out = []
|
||
for a in raw:
|
||
if not isinstance(a, dict) or not isinstance(a.get("check_id"), str):
|
||
raise CodedHTTPException(422, "接受理由必须是「核对 → 理由」的列表", "body_invalid")
|
||
reason = a.get("reason")
|
||
out.append(Acceptance(check_id=a["check_id"], reason=reason if isinstance(reason, str) else "",
|
||
signed_by=who))
|
||
return out
|
||
|
||
|
||
@router.post("/imports/{staging_id}/commit", status_code=201)
|
||
async def commit(staging_id: str, request: Request, x_actor: str | None = Header(default=None),
|
||
session: AsyncSession = Depends(get_session)) -> Any:
|
||
body = await _body(request)
|
||
who = _signed(body, x_actor)
|
||
confirmations = body.get("confirmations", [])
|
||
if not isinstance(confirmations, list) or not all(isinstance(x, str) for x in confirmations):
|
||
raise CodedHTTPException(422, "确认项必须是 id 的列表", "body_invalid")
|
||
trial_id = body.get("trial_id")
|
||
staging = await _open_staging(session, staging_id)
|
||
try:
|
||
result = await recipe_imports.commit_staging(
|
||
session, staging, trial_id=trial_id if isinstance(trial_id, str) else "",
|
||
confirmations=confirmations, acceptances=_acceptances(body.get("acceptances"), who), signed_by=who)
|
||
except _REFUSALS as e:
|
||
raise _refused(e) from e
|
||
source = await session.get(DataSource, result.source_id, populate_existing=True)
|
||
return JSONResponse(status_code=201, content={
|
||
"source": (await _out(session, source)).model_dump() if source is not None else None,
|
||
"import_id": result.import_id, "snapshot_id": result.snapshot_id, "build_id": result.build_id,
|
||
"recipe_id": result.recipe_id, "build_reused": result.build_reused, "unchanged": result.unchanged,
|
||
# 期 3:同版本数据文件被改过、已用本次的文件恢复;启用后当前版本含几期;直接启用了内容相同的已有版本
|
||
"build_restored": result.build_restored, "parts": result.parts, "snapshot_reused": result.snapshot_reused,
|
||
})
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 上传新一期、修改配方、当前配方
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
@router.post("/{source_id}/reupload", status_code=201)
|
||
async def reupload(
|
||
source_id: str,
|
||
file: UploadFile | None = File(None),
|
||
signed_by: str = Form(""),
|
||
x_actor: str | None = Header(default=None),
|
||
session: AsyncSession = Depends(get_session),
|
||
) -> Any:
|
||
"""上传新一期:按当前配方自动试运行(不调模型),回来时已带回执和差异卡。"""
|
||
from app.data.tabular import UnsupportedTable
|
||
|
||
source = await _source(session, source_id)
|
||
if not source.current_recipe_id:
|
||
raise CodedHTTPException(409, recipe_imports._NOT_RECIPE, "not_recipe_source")
|
||
if file is None:
|
||
raise CodedHTTPException(422, "请选择要导入的文件", "body_invalid")
|
||
raw = await read_upload(file)
|
||
try:
|
||
staging = await recipe_imports.reupload(session, source, raw=raw, filename=file.filename or source.name,
|
||
signed_by=_signed({"signed_by": signed_by}, x_actor))
|
||
except _REFUSALS as e:
|
||
raise _refused(e) from e
|
||
except UnsupportedTable as e:
|
||
raise _unreadable(e) from e
|
||
return JSONResponse(status_code=201, content=await _out_of(session, staging))
|
||
|
||
|
||
@router.post("/{source_id}/redraft", status_code=201)
|
||
async def redraft(source_id: str, request: Request, x_actor: str | None = Header(default=None),
|
||
session: AsyncSession = Depends(get_session)) -> Any:
|
||
from app.data.tabular import UnsupportedTable
|
||
|
||
body = await _body(request)
|
||
source = await _source(session, source_id)
|
||
try:
|
||
staging = await recipe_imports.redraft(session, source, signed_by=_signed(body, x_actor))
|
||
except _REFUSALS as e:
|
||
raise _refused(e) from e
|
||
except UnsupportedTable as e:
|
||
raise _unreadable(e) from e
|
||
return JSONResponse(status_code=201, content=await _out_of(session, staging))
|
||
|
||
|
||
@router.get("/{source_id}/recipe")
|
||
async def get_recipe(source_id: str, session: AsyncSession = Depends(get_session)) -> Any:
|
||
source = await _source(session, source_id)
|
||
row = await session.get(TableRecipe, source.current_recipe_id) if source.current_recipe_id else None
|
||
if row is None:
|
||
raise CodedHTTPException(409, recipe_imports._NOT_RECIPE, "not_recipe_source")
|
||
try:
|
||
full = Recipe.model_validate(row.recipe).model_dump(mode="json")
|
||
except Exception: # noqa: BLE001 - 存下的配方都是校验过的;万一读不出,原样给出
|
||
full = row.recipe
|
||
return {
|
||
"recipe_id": row.id, "seq": row.seq, "origin": row.origin, "recipe": full,
|
||
"recipe_sha256": row.recipe_sha256, "confirmations": list(row.confirmations or []),
|
||
"signed_by": row.signed_by, "activated_at": row.activated_at.isoformat() if row.activated_at else None,
|
||
}
|