5.7 KiB

name description version
sage-module-scaffolding Use when building a new Sage module from scratch. 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

{
  "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.

{
  "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

{
  "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

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

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)