diff --git a/json/evidence_pbl_artifact.json b/json/evidence_pbl_artifact.json index 6ade314..e427d5f 100644 --- a/json/evidence_pbl_artifact.json +++ b/json/evidence_pbl_artifact.json @@ -1,187 +1,56 @@ { "tblname": "pbl_artifact", "alias": "pbl_evidence", - "browserfields": { - "id": { - "label": "ID", - "list": true, - "type": "number" - }, - "tenant_id": { - "label": "租户ID", - "list": true, - "type": "text" - }, - "artifact_uid": { - "label": "产出UID", - "type": "text", - "list": true - }, - "session_id": { - "label": "会话", - "type": "number", - "list": true - }, - "blueprint_id": { - "label": "蓝图", - "type": "number", - "list": true - }, - "team_id": { - "label": "团队", - "type": "number", - "list": true - }, - "creator_id": { - "label": "作者", - "type": "text", - "list": true - }, - "title": { - "label": "标题", - "type": "select", - "list": true - }, - "artifact_type": { - "label": "类型", - "type": "select", - "list": true - }, - "content_json": { - "label": "内容", - "type": "text", - "list": true - }, - "version_no": { - "label": "版本", - "type": "number", - "list": true - }, - "status": { - "label": "状态", - "type": "select", - "list": true - }, - "submitted_at": { - "label": "提交时间", - "type": "text", - "list": true - } - }, + "title": "学习产出物(artifact)", "params": { "browserfields": { - "id": { - "label": "ID", - "list": true, - "type": "number" - }, - "tenant_id": { - "label": "租户ID", - "list": true, - "type": "text" - }, - "artifact_uid": { - "label": "产出UID", - "type": "text", - "list": true - }, - "session_id": { - "label": "会话", - "type": "number", - "list": true - }, - "blueprint_id": { - "label": "蓝图", - "type": "number", - "list": true - }, - "team_id": { - "label": "团队", - "type": "number", - "list": true - }, - "creator_id": { - "label": "作者", - "type": "text", - "list": true - }, - "title": { - "label": "标题", - "type": "select", - "list": true - }, - "artifact_type": { - "label": "类型", - "type": "select", - "list": true - }, - "content_json": { - "label": "内容", - "type": "text", - "list": true - }, - "version_no": { - "label": "版本", - "type": "number", - "list": true - }, - "status": { - "label": "状态", - "type": "select", - "list": true - }, - "submitted_at": { - "label": "提交时间", - "type": "text", - "list": true + "exclouded": [ + "content_json", + "created_at", + "updated_at" + ], + "alters": { + "artifact_type": { + "uitype": "code", + "data": [ + {"value": "document", "text": "文档报告"}, + {"value": "code", "text": "程序代码"}, + {"value": "model", "text": "模型/场景"}, + {"value": "presentation", "text": "演示汇报"}, + {"value": "poster", "text": "海报展板"}, + {"value": "video", "text": "音视频作品"}, + {"value": "dataset", "text": "数据/素材集"}, + {"value": "other", "text": "其他"} + ] + }, + "status": { + "uitype": "code", + "data": [ + {"value": "draft", "text": "草稿"}, + {"value": "submitted", "text": "已提交"}, + {"value": "reviewing", "text": "评审中"}, + {"value": "returned", "text": "退回修改"}, + {"value": "graded", "text": "已评分"}, + {"value": "archived", "text": "已归档"} + ] + } } }, + "editexclouded": [ + "id", + "tenant_id", + "artifact_uid", + "version_no", + "created_at", + "updated_at" + ], + "logined_userorgid": "tenant_id", "editable": { - "artifact_uid": { - "label": "产出UID", - "type": "text" - }, - "session_id": { - "label": "会话", - "type": "number" - }, - "blueprint_id": { - "label": "蓝图", - "type": "number" - }, - "team_id": { - "label": "团队", - "type": "number" - }, - "creator_id": { - "label": "作者", - "type": "text" - }, - "title": { - "label": "标题", - "type": "select" - }, - "artifact_type": { - "label": "类型", - "type": "select" - }, - "content_json": { - "label": "内容", - "type": "text" - }, - "version_no": { - "label": "版本", - "type": "number" - }, - "status": { - "label": "状态", - "type": "select" - }, - "submitted_at": { - "label": "提交时间", - "type": "text" - } + "new_data_url": "{{entire_url('../api/pbl_artifact_create.dspy')}}", + "update_data_url": "{{entire_url('../api/pbl_artifact_update.dspy')}}", + "delete_data_url": "{{entire_url('../api/pbl_artifact_delete.dspy')}}" }, + "sortby": ["created_at desc"], "order_by": "id DESC", "page_size": 20 } diff --git a/json/evidence_pbl_evidence.json b/json/evidence_pbl_evidence.json index 77edccf..1072731 100644 --- a/json/evidence_pbl_evidence.json +++ b/json/evidence_pbl_evidence.json @@ -1,159 +1,54 @@ { "tblname": "pbl_evidence", "alias": "pbl_evidence", - "browserfields": { - "id": { - "label": "ID", - "list": true, - "type": "number" - }, - "tenant_id": { - "label": "租户ID", - "list": true, - "type": "text" - }, - "artifact_id": { - "label": "产出物", - "type": "number", - "list": true - }, - "evidence_type": { - "label": "证据类型", - "type": "select", - "list": true - }, - "source_event_id": { - "label": "来源 runtime_event", - "type": "text", - "list": true - }, - "session_id": { - "label": "会话", - "type": "number", - "list": true - }, - "learner_id": { - "label": "学习者", - "type": "text", - "list": true - }, - "blueprint_id": { - "label": "蓝图", - "type": "number", - "list": true - }, - "payload_json": { - "label": "证据体", - "type": "text", - "list": true - }, - "occurred_at": { - "label": "发生时间", - "type": "text", - "list": true - }, - "dedup_key": { - "label": "去重键", - "type": "text", - "list": true - } - }, + "title": "学习证据(runtime_event 幂等采集)", "params": { "browserfields": { - "id": { - "label": "ID", - "list": true, - "type": "number" - }, - "tenant_id": { - "label": "租户ID", - "list": true, - "type": "text" - }, - "artifact_id": { - "label": "产出物", - "type": "number", - "list": true - }, - "evidence_type": { - "label": "证据类型", - "type": "select", - "list": true - }, - "source_event_id": { - "label": "来源 runtime_event", - "type": "text", - "list": true - }, - "session_id": { - "label": "会话", - "type": "number", - "list": true - }, - "learner_id": { - "label": "学习者", - "type": "text", - "list": true - }, - "blueprint_id": { - "label": "蓝图", - "type": "number", - "list": true - }, - "payload_json": { - "label": "证据体", - "type": "text", - "list": true - }, - "occurred_at": { - "label": "发生时间", - "type": "text", - "list": true - }, - "dedup_key": { - "label": "去重键", - "type": "text", - "list": true + "exclouded": [ + "payload_json", + "dedup_key", + "collect_batch_id", + "created_at", + "updated_at" + ], + "alters": { + "evidence_type": { + "uitype": "code", + "data": [ + {"value": "artifact", "text": "产出物"}, + {"value": "assessment", "text": "测评作答"}, + {"value": "peer_review", "text": "同伴互评"}, + {"value": "reflection", "text": "反思日志"}, + {"value": "milestone", "text": "里程碑达成"}, + {"value": "collaboration", "text": "协作行为"}, + {"value": "resource", "text": "资源使用"}, + {"value": "observation", "text": "一般观测"} + ] + }, + "artifact_id": { + "uitype": "code", + "dataurl": "{{entire_url('../api/pbl_artifact_options.dspy')}}", + "datamethod": "GET", + "valueField": "value", + "textField": "text" + } } }, + "editexclouded": [ + "id", + "tenant_id", + "dedup_key", + "collect_batch_id", + "created_at", + "updated_at" + ], + "logined_userorgid": "tenant_id", "editable": { - "artifact_id": { - "label": "产出物", - "type": "number" - }, - "evidence_type": { - "label": "证据类型", - "type": "select" - }, - "source_event_id": { - "label": "来源 runtime_event", - "type": "text" - }, - "session_id": { - "label": "会话", - "type": "number" - }, - "learner_id": { - "label": "学习者", - "type": "text" - }, - "blueprint_id": { - "label": "蓝图", - "type": "number" - }, - "payload_json": { - "label": "证据体", - "type": "text" - }, - "occurred_at": { - "label": "发生时间", - "type": "text" - }, - "dedup_key": { - "label": "去重键", - "type": "text" - } + "new_data_url": "{{entire_url('../api/pbl_evidence_collect.dspy')}}", + "update_data_url": "{{entire_url('../api/pbl_evidence_update.dspy')}}", + "delete_data_url": "{{entire_url('../api/pbl_evidence_delete.dspy')}}" }, + "sortby": ["occurred_at desc"], "order_by": "id DESC", "page_size": 20 } diff --git a/pbl_evidence/crud_api.py b/pbl_evidence/crud_api.py new file mode 100644 index 0000000..4748fa3 --- /dev/null +++ b/pbl_evidence/crud_api.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""pbl_evidence.crud_api —— CRUD 定义(json/*.json)所需的自定义端点后端。 + +为什么单独成文件(不改 api.py) +------------------------------ +api.py 已有 390 行契约面(产出物 CRUD + 证据幂等采集)。本文件只补 +``json/evidence_pbl_evidence.json`` 里 ``params.editable`` 三个 URL 与 +``browserfields.alters[].dataurl`` 所需的 4 个后端函数,保持 api.py 不动、 +职责清晰:api.py = 业务契约,crud_api.py = CRUD 框架适配。 + +规范依据(crud-definition-spec / module-development-spec) +-------------------------------------------------------- +1. ``editable.new_data_url/update_data_url/delete_data_url`` 必须有对应 + ``wwwroot/api/*.dspy``,且路径不含 ``wwwroot/``(json/ 视角用 ``../api/``)。 +2. 下拉数据接口(``alters[].dataurl``)**必须返回裸 JSON 数组** + ``[{value, text}, ...]``,异常/空数据返回 ``[]``,不许包 status/data。 +3. 多租户铁律:所有读写 ``tenant_id`` 强制打头,缺失即 fail-closed; + 更新/删除必须校验记录归属当前租户,防越权改他人证据。 +4. 列名不猜:先 ``table_columns`` 发现真实列,再按交集拼 SQL。 +5. 证据表的幂等唯一索引是 ``uk_ev_dedup(tenant_id, source_event_id, evidence_type)``; + 手工编辑若改动这三元组,``dedup_key`` 必须同步重算,否则去重键与唯一索引漂移。 +""" + +from pbl_common.api import PblError, actor_id, json_dump, now_str, tenant_id + +from pbl_evidence.db import ( + build_update, build_where, json_loads, pick_columns, q_all, q_exec, q_one, + table_columns, +) +from pbl_evidence.evidence_map import ( + EVIDENCE_TYPES, build_dedup_key, normalize_dt, +) + +# 证据表允许人工修订的列(唯一索引三元组中的 tenant_id 永不开放) +EVIDENCE_EDITABLE_COLS = ('artifact_id', 'evidence_type', 'source_event_id', + 'session_id', 'learner_id', 'blueprint_id', + 'payload_json', 'occurred_at') + + +def _require_tenant(): + tid = tenant_id() + if not tid: + raise PblError('PBL_E_TENANT_MISSING', + 'tenant_id 缺失:pbl_evidence 所有读写强制带租户(fail-closed)') + return str(tid) + + +def _blank(value): + return value is None or (isinstance(value, str) and value.strip() == '') + + +# ══════════════════════════════════════════════════════════════════════ +# 下拉数据:产出物选项(browserfields.alters.artifact_id.dataurl) +# ══════════════════════════════════════════════════════════════════════ +async def pbl_artifact_options(**kw): + """返回当前租户可选产出物 ``[{value,text}]``(裸数组,供 bricks UiCode 渲染)。 + + 只取 id/title 两列(不 SELECT *,避免把 content_json 大字段拖进下拉查询)。 + 任何异常都降级为 ``[]``——下拉数据源报错会让整个列表页 500,宁可空列表。 + """ + try: + tid = _require_tenant() + cols = await table_columns('pbl_artifact') + if not cols or 'id' not in cols: + return [] + text_col = 'title' if 'title' in cols else ('artifact_uid' if 'artifact_uid' in cols else 'id') + where = {'tenant_id': tid} + for key in ('session_id', 'blueprint_id', 'status', 'creator_id'): + if not _blank(kw.get(key)) and key in cols: + where[key] = kw[key] + where_sql, args = build_where(where, cols) + limit = min(500, max(1, int(kw.get('limit') or 200))) + args2 = dict(args) + args2['limit'] = limit + rows = await q_all( + 'SELECT `id`, `%s` AS label FROM `pbl_artifact` WHERE %s ' + 'ORDER BY `created_at` DESC LIMIT ${limit}$' % (text_col, where_sql), args2) + out = [] + for r in rows: + val = r.get('id') + if _blank(val): + continue + label = r.get('label') + out.append({'value': str(val), + 'text': ('%s' % label) if not _blank(label) else str(val)}) + return out + except Exception: + return [] + + +# ══════════════════════════════════════════════════════════════════════ +# 证据人工修订 / 删除(editable.update_data_url / delete_data_url) +# ══════════════════════════════════════════════════════════════════════ +async def pbl_evidence_update(**kw): + """按 id 修订证据(强制租户校验;payload_json 自动包成合法 JSON 文本)。 + + 与幂等采集的边界:批量采集走 ``pbl_evidence_collect_from_events``(服务端权威、 + 只增不改);本函数只服务人工纠错/补注,不开放 tenant_id,也不允许改 collect_batch_id + (采集批次号是审计线索)。改唯一索引三元组时同步重算 dedup_key。 + """ + tid = _require_tenant() + eid = kw.get('id') or kw.get('evidence_id') + if _blank(eid): + raise PblError('PBL_E_PARAM', '缺少 id(证据主键)') + + cols = await table_columns('pbl_evidence') + if not cols: + raise PblError('PBL_E_DB_UNAVAILABLE', 'pbl_evidence 表不存在,请先执行 models DDL') + + where_sql, args = build_where({'tenant_id': tid, 'id': eid}, cols) + cur = await q_one('SELECT * FROM `pbl_evidence` WHERE %s LIMIT 1' % where_sql, args) + if not cur: + raise PblError('PBL_E_NOT_FOUND', '证据不存在或不属于当前租户') + + upd = {'updated_at': now_str()} + for key in EVIDENCE_EDITABLE_COLS: + if key not in cols or _blank(kw.get(key)): + continue + if key == 'evidence_type': + etype = str(kw[key]).strip() + if etype not in EVIDENCE_TYPES: + raise PblError('PBL_E_PARAM', + 'evidence_type 须为 %s' % '/'.join(sorted(EVIDENCE_TYPES))) + upd['evidence_type'] = etype + elif key == 'payload_json': + raw = kw[key] + if isinstance(raw, str): + parsed = json_loads(raw, default=None) + if parsed is None: + raise PblError('PBL_E_PARAM', 'payload_json 不是合法 JSON') + upd['payload_json'] = json_dump(parsed) + else: + upd['payload_json'] = json_dump(raw) + elif key == 'occurred_at': + stamp = normalize_dt(kw[key]) + if not stamp: + raise PblError('PBL_E_PARAM', 'occurred_at 格式须为 YYYY-MM-DD HH:MM:SS') + upd['occurred_at'] = stamp + elif key == 'source_event_id': + upd['source_event_id'] = str(kw[key]).strip()[:32] + else: + upd[key] = str(kw[key])[:32] + + # 唯一索引三元组变化 → dedup_key 同步重算(保持与 uk_ev_dedup 一致) + if 'dedup_key' in cols: + final_src = str(upd.get('source_event_id', cur.get('source_event_id') or '')) + final_type = str(upd.get('evidence_type', cur.get('evidence_type') or '')) + if final_src and final_type: + upd['dedup_key'] = build_dedup_key(tid, final_src, final_type) + + upd = pick_columns(upd, cols) + sql, sql_args = build_update('pbl_evidence', upd, cols, {'tenant_id': tid, 'id': eid}) + await q_exec(sql, sql_args) + return {'ok': True, 'status': 'OK', 'id': eid, + 'updated_fields': sorted(upd.keys()), + 'message': '证据已更新(%s 个字段)' % len(upd)} + + +async def pbl_evidence_delete(**kw): + """按 id 删除证据(强制租户校验;支持 ids 批量,逗号分隔或数组)。""" + tid = _require_tenant() + raw_ids = kw.get('ids') if not _blank(kw.get('ids')) else ( + kw.get('id') or kw.get('evidence_id')) + if _blank(raw_ids): + raise PblError('PBL_E_PARAM', '缺少 id / ids') + if isinstance(raw_ids, (list, tuple)): + id_list = [str(x).strip() for x in raw_ids if not _blank(x)] + else: + id_list = [x.strip() for x in str(raw_ids).split(',') if x.strip()] + if not id_list: + raise PblError('PBL_E_PARAM', 'id / ids 为空') + + cols = await table_columns('pbl_evidence') + if not cols: + raise PblError('PBL_E_DB_UNAVAILABLE', 'pbl_evidence 表不存在,请先执行 models DDL') + + deleted = 0 + for eid in id_list[:500]: + where_sql, args = build_where({'tenant_id': tid, 'id': eid}, cols) + cur = await q_one('SELECT `id` FROM `pbl_evidence` WHERE %s LIMIT 1' % where_sql, args) + if not cur or _blank(cur.get('id')): + continue + await q_exec('DELETE FROM `pbl_evidence` WHERE `tenant_id`=${tenant_id}$ ' + 'AND `id`=${id}$', {'tenant_id': tid, 'id': eid}) + deleted += 1 + return {'ok': True, 'status': 'OK', 'deleted': deleted, + 'requested': len(id_list), 'operator': actor_id() or '0', + 'message': '已删除 %s 条证据' % deleted}