--- name: sage-module-scaffolding description: "Use when building a new Sage module from scratch." version: 1.0.0 --- # Sage Module Scaffolding Complete recipe for creating a new Sage module. Derived from building `storage_mgr` and `image_mgr`. ## File Structure ``` module_name/ ← repo root ├── setup.json ← module metadata ├── module_name/ ← Python package (matches packages[] in setup.json) │ └── __init__.py ← core async functions called by DSPY ├── models/ ← DB table definitions (one JSON per table) │ └── table_name.json ├── json/ ← CRUD browser configs (one JSON per table alias) │ └── table_name_list.json ├── wwwroot/ │ └── api/ ← custom DSPY endpoints │ └── custom_action.dspy ├── scripts/ │ └── load_path.py ← RBAC permission registration └── README.md ``` ## Step 1: setup.json ```json { "name": "module_name", "version": "0.1.0", "description": "简短中文描述", "packages": ["module_name"], "install_requires": ["apppublic", "sqlor", "ahserver", "appbase"], "python_requires": ">=3.10" } ``` Pitfall: `packages` must match Python directory name. `install_requires` always includes apppublic, sqlor, ahserver, appbase. Add `rbac` if needed. ## Step 2: models/*.json — Data Models One JSON per DB table. Three sections: summary, fields, codes. ```json { "summary": [{ "name": "table_name", "title": "中文表名", "primary": ["id"], "catelog": "entity" }], "fields": [ {"name": "id", "title": "id", "type": "str", "length": 32, "nullable": "no"}, {"name": "resellerid", "title": "商户机构id", "type": "str", "length": 32, "nullable": "no"}, {"name": "name", "title": "名称", "type": "str", "length": 128, "nullable": "no"}, {"name": "status", "title": "状态", "type": "char", "length": 16, "default": "active"}, {"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"}, {"name": "updated_at", "title": "更新时间", "type": "timestamp", "nullable": "no"} ], "codes": [ {"field": "status", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='my_enum'"}, {"field": "fk_col", "table": "foreign_table", "valuefield": "id", "textfield": "name"} ] } ``` Rules: - EVERY table MUST have: `id` (str 32), `resellerid` (str 32), `created_at`, `updated_at` - `catelog: "entity"` = standard CRUD table - `codes`: enums → `appcodes_kv` with `cond`; FKs → target table name - Types: str, char, int, bigint, text, timestamp ## Step 3: json/*_list.json — Browser Configs ```json { "tblname": "table_name", "alias": "table_name_list", "title": "页面标题", "params": { "browserfields": { "name": {"title": "名称", "width": 150}, "status": {"title": "状态", "width": 80} }, "editexclouded": ["id", "resellerid", "created_at", "updated_at"], "toolbar": {"tools": []}, "binds": [], "new_data_url": "{{entire_url('/module_name/api/table_name_create.dspy')}}", "update_data_url": "{{entire_url('/module_name/api/table_name_update.dspy')}}", "delete_data_url": "{{entire_url('/module_name/api/table_name_delete.dspy')}}", "logined_userorgid": "resellerid" } } ``` Rules: - URL pattern: `/module_name/api/{tblname}_{action}.dspy` - CRUD DSPY files are framework-handled — no need to create files unless custom logic - `logined_userorgid: "resellerid"` = auto-filter by org - Pitfall: `tblname` mismatch between model JSON and browser JSON = silent failure ## Step 4: wwwroot/api/*.dspy — Custom Endpoints ```python result = await function_name(request, params_kw) return result if isinstance(result, dict) else {'widgettype': 'Error', 'options': {'title': '失败', 'message': str(result)}} ``` DSPY safety rules: - NO f-strings — use concatenation or `.format()` - NO bare `import` — use `from` imports - Built-in globals available: `json`, `uuid`, `DBPools`, `os`, `request` - `uuid()` no-args for IDs, NOT `uuid.uuid4()` - Always guard with `isinstance(result, dict)` ## Step 5: scripts/load_path.py Copy from any existing module, change only `mod_name`. Auto-discovers CRUD aliases from json/ and APIs from wwwroot/api/. Registers paths for roles: `any`, `logined`, `reseller.operator`. ## Step 6: module_name/__init__.py — Core Logic ```python import datetime, json, asyncio from appPublic.uniqueID import getID from sqlor.dbpools import DBPools MODULE_NAME = 'pccs' # Sage app DB namespace, NOT module name async def some_function(request, params_kw): env = request._run_ns dbname = env.get_module_dbname(MODULE_NAME) async with DBPools().sqlorContext(dbname) as sor: recs = await sor.R('table_name', {'status': 'active'}) return {'status': 'ok', 'data': '...'} ``` Key conventions: - `MODULE_NAME = 'pccs'` = Sage app DB name, NOT the module's own name - `request._run_ns` gives Sage runtime environment - `sqlor`: C/R/U/D for CRUD; `sqlExe(sql, params)` for complex queries — `${var}$` placeholders - `getID()` from appPublic.uniqueID for new record IDs - Pitfall: forgetting `await` on sqlor methods returns coroutines instead of results ## Deployment 1. `python scripts/load_path.py` — RBAC registration 2. Commit + push to git 3. Server: `git pull`, restart Sage (or `pip install -e .`) 4. Verify: `/module_name/table_name_list/index.ui` ## Reference Examples - `references/storage-mgr-example.md` — Shared storage management module (3 tables, 3 custom APIs) - `references/image-mgr-example.md` — Container image management module (3 tables, 6 custom APIs, 8 preset domestic mirrors)