Add a business module
Add business notes to an existing HoHu project, supporting creation and paginated reading. Complete the development setup first. This is a source extension, not a standalone plugin installation protocol.
Step reference
| Stage | Deliverable |
|---|---|
| Define boundaries | Endpoints, permission codes, and tenant-shared policy |
| Backend integration | Model, schemas, service, API, and migration |
| Web integration | Types, requests, page, translations, and menus |
| Access validation | Ordinary test role, permission rejection, and tenant isolation |
Follow the steps in order the first time. Use the page outline to find examples on later visits.
Separate project and file placement
For a first run, follow development setup: use hohu create notes-tutorial with Backend and Frontend, then hohu init and hohu dev, and complete the first sign-in. Use a dedicated database and separate Redis instance. Do not reuse an existing application's .env, keys or database. Running projects concurrently also requires separate ports and matching frontend proxy settings, not just a different browser URL. Backend and Web directories below mean hohu-admin/ and hohu-admin-web/ inside this new project.
The documentation repository's examples/notes/ contains source files, not a runnable application directory. Copy Python files into backend app/modules/notes/ using the package layout below, including each __init__.py; place notes.d.ts, notes.ts, index.vue and locales.ts at the Web paths specified below. Integrate ai_tools/note.py and service/projection.py in the next tutorial. Do not copy the whole examples directory into the backend.
1. Define the scope
Users with read permission can see all notes in their tenant. Creation requires a separate permission. Private notes, department scopes, editing and deletion require additional permission rules and regression tests. AI tools are added in the next chapter.
| Item | Contract |
|---|---|
| Table | biz_note |
| Endpoints | GET /business/notes, POST /business/notes |
| Read permission | business:note:list |
| Create permission | business:note:add |
| Web page | src/views/notes/index.vue |
| Tenant authority | Authenticated TenantContext, never the request body |
2. Define the model and schemas
Create app/modules/notes/ following Repository structure. Keep its root __init__.py empty. Each of api/, models/, schemas/ and service/ contains the note.py below and its package exports. Sources live in examples/notes/; copy Web files to their separate target paths.
models/note.py
from sqlalchemy import BigInteger, ForeignKey, Index, String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.id_generator import next_id
from app.db.base import Base
class Note(Base):
__tablename__ = "biz_note"
__table_args__ = (Index("ix_biz_note_tenant_id_note_id", "tenant_id", "note_id"),)
note_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, default=next_id)
tenant_id: Mapped[int] = mapped_column(
BigInteger,
ForeignKey("sys_tenant.tenant_id", ondelete="RESTRICT"),
nullable=False,
)
title: Mapped[str] = mapped_column(String(120), nullable=False)The composite index supports pagination by ID within a tenant. The foreign key requires an existing tenant. Future customer or order relationships must also enforce same-tenant ownership.
schemas/note.py
from pydantic import BaseModel, ConfigDict, Field, field_serializer
from pydantic.alias_generators import to_camel
class NoteCreate(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
title: str = Field(min_length=1, max_length=120)
class NoteOut(BaseModel):
model_config = ConfigDict(
alias_generator=to_camel, populate_by_name=True, from_attributes=True
)
note_id: int
title: str
@field_serializer("note_id")
def serialize_id(self, value: int) -> str:
return str(value)Creation rejects undeclared fields such as tenantId. Output uses a string noteId to preserve Snowflake precision. A note title is user content and is not translated when the interface language changes.
3. Implement the service and endpoints
service/note.py
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.base_response import PageResult
from app.core.tenant import TenantContext
from app.core.tenant_scope import tenant_filter, tenant_select, tenant_values
from ..models import Note
from ..schemas import NoteCreate, NoteOut
class NoteService:
async def create(
self, db: AsyncSession, body: NoteCreate, *, tenant: TenantContext
) -> Note:
note = Note(**tenant_values(body.model_dump(), tenant=tenant))
db.add(note)
await db.flush()
return note
async def page(
self, db: AsyncSession, *, tenant: TenantContext, current: int, size: int
) -> PageResult[NoteOut]:
total = await db.scalar(
select(func.count())
.select_from(Note)
.where(tenant_filter(Note, tenant=tenant))
)
query = (
tenant_select(Note, tenant=tenant)
.order_by(Note.note_id.desc())
.offset((current - 1) * size)
.limit(size)
)
rows = (await db.scalars(query)).all()
return PageResult(
records=[NoteOut.model_validate(row) for row in rows],
total=total or 0,
current=current,
size=size,
)
note_service = NoteService()Records and count use the same tenant scope. The service flushes without committing and orders explicitly by ID. SQL is expanded here to explain the scope; general modules can also reuse the pagination helper.
api/note.py
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.auth import require_permissions
from app.core.base_response import PageResult, ResponseModel
from app.core.tenant import TenantContext
from app.db.session import get_db
from app.modules.auth.service import get_current_tenant_context
from ..schemas import NoteCreate, NoteOut
from ..service import note_service
router = APIRouter(prefix="/business/notes", tags=["Notes"])
@router.get(
"",
response_model=ResponseModel[PageResult[NoteOut]],
dependencies=[Depends(require_permissions("business:note:list"))],
)
async def list_notes(
current: int = Query(1, ge=1),
size: int = Query(10, ge=1, le=100),
db: AsyncSession = Depends(get_db),
tenant: TenantContext = Depends(get_current_tenant_context),
):
return ResponseModel.success(
await note_service.page(db, tenant=tenant, current=current, size=size)
)
@router.post(
"",
response_model=ResponseModel[NoteOut],
dependencies=[Depends(require_permissions("business:note:add"))],
)
async def create_note(
body: NoteCreate,
db: AsyncSession = Depends(get_db),
tenant: TenantContext = Depends(get_current_tenant_context),
):
note = await note_service.create(db, body, tenant=tenant)
result = NoteOut.model_validate(note)
await db.commit()
return ResponseModel.success(result)The API authenticates requests, checks functional permissions, obtains trusted tenant context and commits successful creation. Hiding a button does not replace API authorization. Keep domain failures within the established response contract.
Package exports
Save these __init__.py files to expose objects used by registration and neighboring layers. Keep other package initialization files empty.
api/__init__.py
from .note import router
__all__ = ["router"]models/__init__.py
from .note import Note
__all__ = ["Note"]schemas/__init__.py
from .note import NoteCreate, NoteOut
__all__ = ["NoteCreate", "NoteOut"]service/__init__.py
from .note import note_service
__all__ = ["note_service"]4. Register endpoints and create a migration
At the router registration location in app/main.py, add:
from app.modules.notes.api import router as notes_router
app.include_router(notes_router)Add from app.modules.notes.models import Note to the model imports in alembic/env.py so the model enters the existing Base.metadata. From the backend directory, run:
uv run alembic revision --autogenerate -m "add business notes"Also register the resource in TENANT_MODEL_INVENTORY in app/core/tenant_inventory.py, preserving existing entries:
"biz_note": _resource("biz_note", "app.modules.notes.models:Note"),Add the table to the expected inventory in tests/core/test_tenant_inventory.py, retaining tenant index and disjoint ownership checks. After generating the migration, extend the actual revision chain and regression cases in tests/test_release_migration_roundtrip.py. Keep historical migration tests bound to their original revisions rather than replacing every target with the newest revision.
Review the migration for only the intended biz_note table, foreign key and index. Unexpected table deletions usually require checking missing model imports before proceeding. Apply the reviewed migration to your development database:
uv run alembic upgrade headRestart the backend. /docs should show the Notes group with both endpoints. Do not replace migrations with startup calls to Base.metadata.create_all().
5. Connect the complete Web page, translations and menus
Web paths below are relative to hohu-admin-web/. This example provides English and Chinese pages; actual modules follow their requested languages, without changing framework internationalization for a single-language module. Wire translations before type checking; otherwise the new $t keys will fail type checking.
Requests and page
First save the business types as src/typings/api/notes.d.ts:
declare namespace Api {
namespace Notes {
interface Note {
noteId: string;
title: string;
}
interface Page {
records: Note[];
total: number;
current: number;
size: number;
}
}
}Save the request file as src/service/api/notes.ts:
import { request } from '@/service/request';
export function fetchNotes(current = 1, size = 10) {
return request<Api.Notes.Page>({
url: '/business/notes',
method: 'get',
params: { current, size }
});
}
export function createNote(title: string) {
return request<Api.Notes.Note>({ url: '/business/notes', method: 'post', data: { title } });
}Append export * from './notes'; to src/service/api/index.ts, preserving existing exports. The page uses Api.Notes types; business types live separately from request functions.
Create src/views/notes/index.vue:
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { NButton, NCard, NDataTable, NForm, NFormItem, NInput, NPagination, NSpace, useMessage } from 'naive-ui';
import type { DataTableColumns } from 'naive-ui';
import { useAuth } from '@/hooks/business/auth';
import { $t } from '@/locales';
import { createNote, fetchNotes } from '@/service/api/notes';
const { hasAuth } = useAuth();
const message = useMessage();
const title = ref('');
const records = ref<Api.Notes.Note[]>([]);
const total = ref(0);
const current = ref(1);
const size = ref(10);
const loading = ref(false);
const saving = ref(false);
let requestVersion = 0;
const columns = computed<DataTableColumns<Api.Notes.Note>>(() => [
{ title: $t('notes.title'), key: 'title', minWidth: 180 },
{ title: $t('notes.id'), key: 'noteId', width: 200 }
]);
async function loadNotes() {
const version = ++requestVersion;
loading.value = true;
try {
const { data, error } = await fetchNotes(current.value, size.value);
if (version !== requestVersion) return;
if (error || !data) {
records.value = [];
total.value = 0;
return;
}
records.value = data.records;
total.value = data.total;
} finally {
if (version === requestVersion) loading.value = false;
}
}
function changePage(page: number) {
current.value = page;
void loadNotes();
}
function changeSize(value: number) {
size.value = value;
changePage(1);
}
async function saveNote() {
if (saving.value || !hasAuth('business:note:add')) return;
const value = title.value.trim();
if (!value || [...value].length > 120) {
message.warning($t('notes.titleRequired'));
return;
}
saving.value = true;
try {
const { error } = await createNote(value);
if (error) return;
title.value = '';
message.success($t('notes.created'));
current.value = 1;
await loadNotes();
} finally {
saving.value = false;
}
}
onMounted(() => void loadNotes());
</script>
<template>
<NSpace vertical :size="16">
<NCard v-if="hasAuth('business:note:add')" :title="$t('notes.create')">
<NForm @submit.prevent="saveNote">
<NFormItem :label="$t('notes.title')">
<NInput v-model:value="title" :disabled="saving" :placeholder="$t('notes.placeholder')" />
</NFormItem>
<NButton type="primary" attr-type="submit" :loading="saving" :disabled="saving">
{{ $t('notes.create') }}
</NButton>
</NForm>
</NCard>
<NCard :title="$t('notes.list')">
<NSpace vertical :size="16">
<NButton :loading="loading" @click="loadNotes">{{ $t('notes.refresh') }}</NButton>
<NDataTable
:columns="columns"
:data="records"
:row-key="(row: Api.Notes.Note) => row.noteId"
:loading="loading"
:scroll-x="380"
/>
<NPagination
:page="current"
:page-size="size"
:item-count="total"
:page-sizes="[10, 20, 50]"
:page-slot="3"
show-size-picker
@update:page="changePage"
@update:page-size="changeSize"
/>
</NSpace>
</NCard>
</NSpace>
</template>The page includes creation, title validation, saving state, refresh, pagination and button authorization. It preserves input on failure and returns to page one after success. Request versions prevent older responses from replacing newer page results. Server authorization and tenant scoping remain authoritative.
Translations and types
Save the shared resources as src/locales/notes.ts. They include page, menu, assistant and error text used by both tutorials:
// Copy to src/locales/notes.ts. Merge into the existing global messages.
export const notesZh = {
notes: {
title: '标题',
id: '编号',
create: '创建便签',
list: '业务便签',
refresh: '刷新',
placeholder: '输入 1–120 个字符的标题',
titleRequired: '请输入 1–120 个字符的标题',
created: '便签已创建'
},
route: { notes: '业务便签' },
permission: { business_note_list: '查看便签', business_note_add: '创建便签' },
agent: { notes: { name: '业务便签助手', description: '查询业务便签,并在确认后创建便签' } },
tool: { note: { field: { title: '标题' }, create: { confirm: '创建便签:{title}' } } },
chat: { confirmNoteCreate: '创建便签:{title}' },
errorCode: {
NOTE_TITLE_INVALID: '请输入 1–120 个字符的标题',
NOTE_APPROVAL_REQUIRED: '需要匹配的操作确认,请重新发起创建'
}
};
export const notesEn = {
notes: {
title: 'Title',
id: 'ID',
create: 'Create note',
list: 'Business notes',
refresh: 'Refresh',
placeholder: 'Enter a title of 1–120 characters',
titleRequired: 'Enter a title of 1–120 characters',
created: 'Note created'
},
route: { notes: 'Business notes' },
permission: { business_note_list: 'View notes', business_note_add: 'Create notes' },
agent: {
notes: { name: 'Business notes assistant', description: 'Query business notes and create them after confirmation' }
},
tool: { note: { field: { title: 'Title' }, create: { confirm: 'Create note: {title}' } } },
chat: { confirmNoteCreate: 'Create note: {title}' },
errorCode: {
NOTE_TITLE_INVALID: 'Enter a title of 1–120 characters',
NOTE_APPROVAL_REQUIRED: 'A matching approval is required. Start a new creation request.'
}
};Import import { notesZh as notesMessages } from '../notes'; in src/locales/langs/zh-cn.ts, and import { notesEn as notesMessages } from '../notes'; in en-us.ts. Apply these additions at the specified locations in both files, preserving existing entries rather than replacing the entire language file:
// Inside the existing const local: App.I18n.Schema = { ... }:
notes: notesMessages.notes,
// Replace the existing shorthand builtin, with:
builtin: {
...builtin,
permission: { ...builtin.permission, ...notesMessages.permission },
agent: { ...builtin.agent, ...notesMessages.agent }
},
// Inside the existing route object:
...notesMessages.route,
// Inside the root errorCode object, not similarly named objects under page:
...notesMessages.errorCode,
// Inside the existing ai.tool object:
...notesMessages.tool,
// page.ai.chat:
...notesMessages.chat,Extend App.I18n.Schema in src/typings/app.d.ts in four places:
// At the Schema root, alongside settings and builtin:
notes: typeof import('../locales/notes').notesEn.notes;
// Inside Schema.ai.tool, alongside field and user:
note: typeof import('../locales/notes').notesEn.tool.note;
// Inside Schema.errorCode:
NOTE_TITLE_INVALID: string;
NOTE_APPROVAL_REQUIRED: string;
// Schema.page.ai.chat:
confirmNoteCreate: string;route already accepts string keys; builtin.permission and builtin.agent use Record types. They need no additional hardcoded types. Do not edit generated router types: the dev server generates the notes route from the page.
Complete menu seed
Save this file as backend app/modules/notes/menu_seed.py:
"""Append these entries to the application's MENU_DEFINITIONS before syncing."""
NOTE_MENUS = [
{
"route_name": "notes",
"parent_route": "0",
"menu_name": "业务便签",
"menu_type": "C",
"icon": "carbon:document",
"icon_type": "1",
"component": "layout.base$view.notes",
"layout": "base",
"page": "notes",
"route_path": "/notes",
"i18n_key": "route.notes",
"order": 20,
"status": "1",
"hide_in_menu": False,
"keep_alive": False,
"constant": False,
"multi_tab": False,
},
{
"key": "business_note_list",
"parent_route": "notes",
"menu_name": "查看便签",
"menu_type": "F",
"permission": "business:note:list",
"route_path": "",
"status": "1",
},
{
"key": "business_note_add",
"parent_route": "notes",
"menu_name": "创建便签",
"menu_type": "F",
"permission": "business:note:add",
"route_path": "",
"status": "1",
},
]Import from app.modules.notes.menu_seed import NOTE_MENUS in app/modules/system/menu_seed.py. After the complete MENU_DEFINITIONS = [...] list, add this line once:
MENU_DEFINITIONS.extend(NOTE_MENUS)Synchronization derives builtin.permission.business_note_list and builtin.permission.business_note_add for the buttons. Both translations are included above.
This is incremental menu synchronization after module development, separate from the baseline initialization already performed by hohu init. Run uv run python -m scripts.sync_menus in the backend. Run hohu dev at the project root, wait for frontend route generation, then run pnpm typecheck in the Web directory. Verify the Notes group in backend /docs and the generated /notes route before assigning access.
Create a verification role
With the development administrator, open Role management. Create a dedicated test role and grant the Business notes page plus View notes and Create notes. Create an ordinary test user, assign that role, and sign in with a separate browser session. Do not verify only as administrator. The administrator needs existing menu-assignment and user-role-assignment permissions.
Synchronization does not automatically grant access. Sign out and back in to refresh old sessions. Begin in the default tenant; hosted tenants additionally need capability and menu provisioning described in multi-tenancy. A missing menu in another tenant is not evidence that data isolation works.
6. Verify the workflow
First run uv run ruff check app/modules/notes in the new backend and pnpm typecheck in Web. Then use the ordinary test user to complete the checks below and record actual results; compilation alone is not acceptance.
Authorize /docs with a development account access token and POST {"title":"First note"}. Expect code: 200 with a string data.noteId. GET returns records, total, current and size.
| Check | Expected result |
|---|---|
| Refresh after creation | The new title appears |
| Submit an empty title or tenantId | Input validation rejects it |
| Ordinary role without create permission | POST is rejected, including direct requests |
| Switch to a different tenant | Neither records nor count includes the first tenant's notes |
| Roll back after service execution | The new record is not persisted |
The documentation repository includes tests/test_notes_example.py, an in-memory SQLite check for record/count isolation, pagination, input validation, ID serialization and rollback. It does not replace PostgreSQL migration, HTTP authentication or browser tests. Run it with the backend virtualenv after setting HOHU_BACKEND_PATH to the backend checkout; it does not connect to the business database.
From the documentation repository, run the checks with the prepared backend virtual environment (replace the example backend path):
export HOHU_BACKEND_PATH=/path/to/hohu-admin
export PYTHONDONTWRITEBYTECODE=1
"$HOHU_BACKEND_PATH/.venv/bin/python" -m unittest discover -s tests -p 'test_notes*.py' -vOn PowerShell, set $env:HOHU_BACKEND_PATH and $env:PYTHONDONTWRITEBYTECODE, then use the backend .venv/Scripts/python.exe with the same unittest arguments.
Version the migration, module, menus, Web page and translations together. Production releases follow the existing CLI workflow; example tests and demo data do not belong in initialization scripts.
Files present when finished
| Location | Added or updated |
|---|---|
Backend app/modules/notes/ | Package __init__.py files, models/note.py, schemas/note.py, service/note.py, api/note.py, menu_seed.py |
| Backend registration | app/main.py, alembic/env.py, app/core/tenant_inventory.py, app/modules/system/menu_seed.py |
| Database | The generated and reviewed migration under alembic/versions/ |
| Web | src/typings/api/notes.d.ts, src/service/api/notes.ts, src/views/notes/index.vue, src/locales/notes.ts |
| Web wiring | src/service/api/index.ts, both src/locales/langs/*.ts files, src/typings/app.d.ts, generated router files |
If a menu appears but the page fails to load, check file placement, dev-server route generation and component: layout.base$view.notes. Untranslated keys usually mean messages were not merged at the correct level. If saving fails, inspect the response and current role permissions rather than inserting a database row to bypass the endpoint.
Next: connect AI
Continue with Connect a business module to AI to add queries and confirmed creation. HTTP endpoints and menus do not automatically enter the AI tool catalog.
See Web pages and requests for page state and request conventions, and debugging and validation for delivery checks.
