446 lines
17 KiB
Python
446 lines
17 KiB
Python
# -*- 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)",
|
||
],
|
||
}
|