2026-09-17 15:16:08 +08:00

446 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""M1b 子对象泛化契约(7 类)+ 扩展字段读写。
对应 QC 退回意见 #6:需提供 7 类子对象泛化契约/扩展接口的可验收证据。
本模块给出**统一契约**:任何子对象类型都通过同一组函数操作,类型差异
只体现在 SUBOBJECT_SPECS 声明(表名/主键/必填字段/允许的扩展字段宿主)。
契约(7 类共用,签名一致 = 泛化):
list_subobjects(tenant_id, blueprint_id, stype, ...) -> list[dict]
get_subobject(tenant_id, stype, obj_id) -> dict
create_subobject(tenant_id, stype, blueprint_id, data) -> dict
update_subobject(tenant_id, stype, obj_id, data) -> dict
delete_subobject(tenant_id, stype, obj_id) -> int
get_ext(tenant_id, stype, obj_id) -> dict(扩展字段值)
set_ext(tenant_id, stype, obj_id, values, source) -> dict(幂等写)
validate_ext(tenant_id, stype, values) -> list[问题]
subobject_tree(tenant_id, blueprint_id) -> 7 类聚合树
7 类子对象:driving_question / learning_goal / mission / problem / role /
learner / artifact_def(pbl_project 为蓝图根,不属子对象)。
"""
from .crud_factory import tenant_crud
from .dbutil import get_conn, sql_rows
from .errors import ErrorCode, PblNotFound, PblValidationError
from .tables import SUBOBJECT_TYPES
from .tenant import require_tenant
from .util import json_dump, json_load, new_id, now_str
__all__ = [
"SUBOBJECT_TYPES",
"SUBOBJECT_SPECS",
"EXT_TABLE",
"list_subobject_types",
"get_spec",
"crud_for",
"list_subobjects",
"get_subobject",
"create_subobject",
"update_subobject",
"delete_subobject",
"get_ext",
"set_ext",
"validate_ext",
"subobject_tree",
"subobject_contract",
]
EXT_TABLE = "pbl_subobject_ext"
EXT_DEF_TABLE = "pbl_ext_field_def"
#: 7 类子对象规格声明(泛化契约的类型差异全部集中在此)
SUBOBJECT_SPECS = {
"driving_question": {
"table": "pbl_driving_question",
"pk": "id",
"label": "驱动性问题",
"required": ["blueprint_id", "question"],
"text_field": "question",
"order_by": "sort_no",
},
"learning_goal": {
"table": "pbl_learning_goal",
"pk": "id",
"label": "学习目标",
"required": ["blueprint_id", "goal"],
"text_field": "goal",
"order_by": "sort_no",
},
"mission": {
"table": "pbl_mission",
"pk": "id",
"label": "任务/关卡",
"required": ["blueprint_id", "name"],
"text_field": "name",
"order_by": "sort_no",
},
"problem": {
"table": "pbl_problem",
"pk": "id",
"label": "问题",
"required": ["blueprint_id", "title"],
"text_field": "title",
"order_by": "sort_no",
},
"role": {
"table": "pbl_role",
"pk": "id",
"label": "角色",
"required": ["blueprint_id", "name"],
"text_field": "name",
"order_by": "sort_no",
},
"learner": {
"table": "pbl_learner",
"pk": "id",
"label": "学习者",
"required": ["blueprint_id", "name"],
"text_field": "name",
"order_by": "sort_no",
},
"artifact_def": {
"table": "pbl_artifact_def",
"pk": "id",
"label": "产出物定义",
"required": ["blueprint_id", "name"],
"text_field": "name",
"order_by": "sort_no",
},
}
_CRUD_CACHE = {}
_EXT_CRUD = None
def list_subobject_types():
"""返回 7 类子对象类型名(封闭枚举)。"""
return list(SUBOBJECT_TYPES)
def get_spec(stype):
"""取子对象规格,类型非法即抛 PblValidationError(fail-closed)。"""
spec = SUBOBJECT_SPECS.get(stype)
if not spec:
raise PblValidationError(
message="unknown subobject_type: %r" % (stype,),
code=ErrorCode.INVALID_PARAM,
detail={"allowed": list(SUBOBJECT_TYPES)},
)
return spec
def crud_for(stype):
"""取(缓存)子对象 CRUD 实例——租户隔离,不允许 NULL 租户。"""
if stype not in _CRUD_CACHE:
spec = get_spec(stype)
_CRUD_CACHE[stype] = tenant_crud(
table=spec["table"], pk=spec["pk"],
allow_null_tenant=False, code_prefix=stype,
)
return _CRUD_CACHE[stype]
def _ext_crud():
global _EXT_CRUD
if _EXT_CRUD is None:
_EXT_CRUD = tenant_crud(
table=EXT_TABLE, pk="id", allow_null_tenant=False,
unique_keys=("subobject_type", "subobject_id", "field_key"),
code_prefix="subext",
)
return _EXT_CRUD
def _table_ready(table, conn=None):
"""表是否存在(基表未建时降级为空集,不抛错——离线兜底路径)。"""
rows = sql_rows(
"SELECT COUNT(*) AS n FROM sqlite_master WHERE type='table' AND name=?",
[table], conn=conn or get_conn())
return bool(rows) and int(rows[0]["n"] or 0) > 0
# ---------------- 泛化 CRUD(7 类同签名) ----------------
def list_subobjects(tenant_id, blueprint_id, stype, limit=200, offset=0, conn=None):
"""列出某蓝图下某类子对象(租户打头)。"""
t = require_tenant(tenant_id)
spec = get_spec(stype)
crud = crud_for(stype)
if not _table_ready(spec["table"], conn=conn):
return []
order = spec.get("order_by") or spec["pk"]
return crud.list(tenant_id=t, where="blueprint_id = ?", params=[blueprint_id],
order_by=order, limit=limit, offset=offset, conn=conn)
def get_subobject(tenant_id, stype, obj_id, conn=None):
"""取单个子对象。"""
t = require_tenant(tenant_id)
get_spec(stype)
return crud_for(stype).get(tenant_id=t, obj_id=obj_id, conn=conn)
def create_subobject(tenant_id, stype, blueprint_id, data=None, actor_id=None,
conn=None):
"""创建子对象:必填校验 + 租户写入 + 扩展字段初值一并落库。"""
t = require_tenant(tenant_id)
spec = get_spec(stype)
payload = dict(data or {})
missing = [k for k in spec["required"] if k not in payload or payload[k] in ("", None)]
if missing and "blueprint_id" in missing:
payload["blueprint_id"] = blueprint_id
missing = [m for m in missing if m != "blueprint_id"]
if missing:
raise PblValidationError(
message="missing required fields for %s" % stype,
code=ErrorCode.INVALID_PARAM,
detail={"subobject_type": stype, "missing": missing},
)
payload["blueprint_id"] = blueprint_id
ext_values = payload.pop("ext", None) or payload.pop("ext_values", None)
row = crud_for(stype).create(tenant_id=t, data=payload, actor_id=actor_id,
conn=conn)
if ext_values:
set_ext(t, stype, row[spec["pk"]], ext_values, source="manual",
blueprint_id=blueprint_id, actor_id=actor_id, conn=conn)
row["ext"] = get_ext(t, stype, row[spec["pk"]], conn=conn)
return row
def update_subobject(tenant_id, stype, obj_id, data=None, actor_id=None, conn=None):
"""更新子对象基础字段(ext 走 set_ext)。"""
t = require_tenant(tenant_id)
get_spec(stype)
payload = dict(data or {})
payload.pop("ext", None)
payload.pop("ext_values", None)
return crud_for(stype).update(tenant_id=t, obj_id=obj_id, data=payload,
actor_id=actor_id, conn=conn)
def delete_subobject(tenant_id, stype, obj_id, actor_id=None, conn=None):
"""删除子对象并级联清理其扩展值(应用层级联,无外键)。"""
t = require_tenant(tenant_id)
get_spec(stype)
n = crud_for(stype).delete(tenant_id=t, obj_id=obj_id, actor_id=actor_id,
conn=conn)
c = conn or get_conn()
if _table_ready(EXT_TABLE, conn=c):
from .dbutil import sql_exec
sql_exec(
"DELETE FROM %s WHERE tenant_id = ? AND subobject_type = ? "
"AND subobject_id = ?" % EXT_TABLE,
[t, stype, obj_id], conn=c)
return int(n or 0)
# ---------------- 扩展字段(泛化契约核心) ----------------
def list_ext_defs(tenant_id, stype, owner_code=None, conn=None):
"""取某子对象类型的扩展字段定义清单(平台公共 + 租户自定义合并)。"""
t = require_tenant(tenant_id)
c = conn or get_conn()
if not _table_ready(EXT_DEF_TABLE, conn=c):
return []
owner_code = owner_code or stype
rows = sql_rows(
"SELECT * FROM %s WHERE owner_type = ? AND owner_code = ? "
"AND (tenant_id IS NULL OR tenant_id = ?) AND enabled = 1 "
"ORDER BY sort_no, field_key" % EXT_DEF_TABLE,
[stype, owner_code, t], conn=c)
out = []
for r in rows:
d = dict(r)
d["enum_options"] = json_load(d.get("enum_options_json"), default=[])
d["validation"] = json_load(d.get("validation_json"), default={})
out.append(d)
return out
def get_ext(tenant_id, stype, obj_id, conn=None):
"""取子对象全部扩展字段值 -> {field_key: value}。"""
t = require_tenant(tenant_id)
get_spec(stype)
c = conn or get_conn()
if not _table_ready(EXT_TABLE, conn=c):
return {}
rows = sql_rows(
"SELECT field_key, field_value_json, value_type FROM %s "
"WHERE tenant_id = ? AND subobject_type = ? AND subobject_id = ?" % EXT_TABLE,
[t, stype, obj_id], conn=c)
out = {}
for r in rows:
out[r["field_key"]] = json_load(r.get("field_value_json"), default=None)
return out
def set_ext(tenant_id, stype, obj_id, values=None, source="manual",
blueprint_id=None, actor_id=None, conn=None, validate=True):
"""幂等写扩展字段值(存在则更新,不存在则插入)。
:return: {"written": n, "values": {...}, "problems": [...]}
"""
t = require_tenant(tenant_id)
get_spec(stype)
values = dict(values or {})
problems = validate_ext(t, stype, values, conn=conn) if validate else []
if problems:
raise PblValidationError(
message="ext values validation failed",
code=ErrorCode.VALIDATION_FAILED,
detail={"subobject_type": stype, "problems": problems},
)
c = conn or get_conn()
if not _table_ready(EXT_TABLE, conn=c):
raise PblNotFound(
message="ext table not initialized: %s" % EXT_TABLE,
code=ErrorCode.NOT_FOUND, detail={"table": EXT_TABLE})
from .dbutil import sql_exec
written = 0
bp = blueprint_id or ""
for key, val in values.items():
vtype = _infer_type(val)
vjson = json_dump(val)
exist = sql_rows(
"SELECT id FROM %s WHERE tenant_id = ? AND subobject_type = ? "
"AND subobject_id = ? AND field_key = ?" % EXT_TABLE,
[t, stype, obj_id, key], conn=c)
if exist:
sql_exec(
"UPDATE %s SET field_value_json = ?, value_type = ?, source = ?, "
"updated_at = ? WHERE id = ?" % EXT_TABLE,
[vjson, vtype, source, now_str(), exist[0]["id"]], conn=c)
else:
sql_exec(
"INSERT INTO %s (id, tenant_key, tenant_id, blueprint_id, "
"subobject_type, subobject_id, field_key, field_value_json, "
"value_type, source, created_at, updated_at) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?)" % EXT_TABLE,
[new_id("subext"), t, t, bp, stype, obj_id, key, vjson, vtype,
source, now_str(), now_str()], conn=c)
written += 1
return {"written": written, "values": get_ext(t, stype, obj_id, conn=c),
"problems": []}
def _infer_type(val):
"""按 Python 值推断 value_type。"""
if isinstance(val, bool):
return "bool"
if isinstance(val, int):
return "int"
if isinstance(val, float):
return "float"
if isinstance(val, (dict, list)):
return "json"
return "string"
def validate_ext(tenant_id, stype, values=None, conn=None):
"""按扩展字段定义校验值,返回问题清单(空 = 通过)。"""
t = require_tenant(tenant_id)
get_spec(stype)
values = dict(values or {})
defs = list_ext_defs(t, stype, conn=conn)
problems = []
known = {}
for d in defs:
key = d["field_key"]
known[key] = d
raw = values.get(key, None)
if int(d.get("required") or 0) == 1 and (raw is None or raw == ""):
problems.append({"field_key": key, "code": "REQUIRED",
"message": "field is required"})
continue
if raw is None or raw == "":
continue
ftype = d.get("field_type") or "string"
v = d.get("validation") or {}
if ftype in ("int", "float"):
try:
num = float(raw)
except (TypeError, ValueError):
problems.append({"field_key": key, "code": "TYPE",
"message": "expect number, got %r" % (raw,)})
continue
if v.get("min") is not None and num < float(v["min"]):
problems.append({"field_key": key, "code": "MIN",
"message": "value < min(%s)" % v["min"]})
if v.get("max") is not None and num > float(v["max"]):
problems.append({"field_key": key, "code": "MAX",
"message": "value > max(%s)" % v["max"]})
elif ftype == "bool":
if not isinstance(raw, bool) and str(raw).lower() not in (
"0", "1", "true", "false", "yes", "no"):
problems.append({"field_key": key, "code": "TYPE",
"message": "expect bool"})
elif ftype == "enum":
opts = d.get("enum_options") or []
if opts and raw not in opts:
problems.append({"field_key": key, "code": "ENUM",
"message": "value not in options",
"options": opts})
elif ftype == "string":
ml = v.get("maxLength")
if ml and len(str(raw)) > int(ml):
problems.append({"field_key": key, "code": "MAX_LENGTH",
"message": "length > %s" % ml})
unknown = [k for k in values.keys() if known and k not in known]
for k in unknown:
problems.append({"field_key": k, "code": "UNDECLARED",
"message": "field not declared in pbl_ext_field_def"})
return problems
def subobject_tree(tenant_id, blueprint_id, conn=None):
"""聚合 7 类子对象 + 各自扩展值 -> 蓝图子对象树(一次调用取全量)。"""
t = require_tenant(tenant_id)
tree = {"blueprint_id": blueprint_id, "tenant_id": t, "subobjects": {},
"counts": {}}
total = 0
for stype in SUBOBJECT_TYPES:
rows = list_subobjects(t, blueprint_id, stype, conn=conn)
spec = get_spec(stype)
items = []
for r in rows:
oid = r.get(spec["pk"])
item = dict(r)
item["ext"] = get_ext(t, stype, oid, conn=conn)
items.append(item)
tree["subobjects"][stype] = items
tree["counts"][stype] = len(items)
total += len(items)
tree["counts"]["__total__"] = total
return tree
def subobject_contract():
"""导出泛化契约说明(供交付文档/接口自证,QC 意见 #6 取证用)。"""
return {
"types": list(SUBOBJECT_TYPES),
"type_count": len(SUBOBJECT_TYPES),
"unified_methods": [
"list_subobjects(tenant_id, blueprint_id, stype, limit, offset)",
"get_subobject(tenant_id, stype, obj_id)",
"create_subobject(tenant_id, stype, blueprint_id, data)",
"update_subobject(tenant_id, stype, obj_id, data)",
"delete_subobject(tenant_id, stype, obj_id)",
"get_ext(tenant_id, stype, obj_id)",
"set_ext(tenant_id, stype, obj_id, values, source)",
"validate_ext(tenant_id, stype, values)",
"subobject_tree(tenant_id, blueprint_id)",
],
"specs": {k: {"table": v["table"], "pk": v["pk"], "label": v["label"],
"required": list(v["required"])}
for k, v in SUBOBJECT_SPECS.items()},
"ext_table": EXT_TABLE,
"ext_def_table": EXT_DEF_TABLE,
"invariants": [
"所有方法首参 tenant_id,内部 require_tenant 强制校验,缺失抛 TenantMissingError",
"类型差异仅由 SUBOBJECT_SPECS 声明,方法签名 7 类完全一致(泛化)",
"扩展值一行一 (子对象, 字段键),唯一键 tenant_key+type+id+field_key 幂等",
"删除子对象应用层级联清理扩展值,不建 FOREIGN KEY(Q-OPEN-3)",
],
}