Creating Modules
This guide walks through creating a complete module from scratch.
Step 1: Create Directory Structure
Section titled “Step 1: Create Directory Structure”mkdir -p modules/todo/modelsmkdir -p modules/todo/viewsmkdir -p modules/todo/datamkdir -p modules/todo/securitymkdir -p modules/todo/templatestouch modules/todo/__init__.pytouch modules/todo/models/__init__.pyFolder Purposes
Section titled “Folder Purposes”| Folder | Purpose |
|---|---|
views/ | UI definitions (UiView, WindowAction, UiMenu) |
data/ | Seed/reference data (stages, categories) |
security/ | Access control (Group, ModelAccess, RecordRule) |
templates/ | All templates (web pages, reports, section templates) |
Files starting with _ are ignored.
Step 2: Create Manifest
Section titled “Step 2: Create Manifest”Create modules/todo/manifest.yaml:
name: Todoidentifier: todocategory: module_category_productivitydescription: Task management and todo listsdependencies:- coreicon: Checklistversion: '1.0'Step 3: Define Models
Section titled “Step 3: Define Models”Create modules/todo/models/task.py:
from fullfinity.engine.base import *
class TaskTag(Model): """Tags for categorizing tasks.""" name = Char(max_length=50, required=True, index=True) color = Char(max_length=20, default="#6366F1")
class TaskList(Model): """A list/project containing tasks.""" name = Char(max_length=255, required=True) description = Text() color = Char(max_length=20, default="#3B82F6") owner = ManyToOne("User", related_name="task_lists") archived = Boolean(default=False)
# Computed field task_count = Integer(calculate="_compute_task_count", store=False)
@Model.calculate("tasks") async def _compute_task_count(self): await self.fetch_related("tasks") self.task_count = len(self.tasks) if self.tasks else 0
class Task(Model): """Individual task/todo item.""" name = Char(max_length=255, required=True, description="Task Title") description = Text(description="Details") task_list = ManyToOne("TaskList", related_name="tasks", required=True) assignee = ManyToOne("User", related_name="assigned_tasks") tags = ManyToMany("TaskTag", related_name="tasks", through="fktasktag")
# Status status = Selection( choices=["To Do", "In Progress", "Review", "Done"], default="To Do", description="Status" ) priority = Selection( choices=["Low", "Medium", "High", "Urgent"], default="Medium", description="Priority" )
# Dates due_date = Date(description="Due Date") completed_at = Datetime(description="Completed At")
# Ordering sequence = Integer(default=10)
_order_by = ["sequence ASC", "id ASC"]
async def mark_complete(self): """Mark task as complete.""" from datetime import datetime self.status = "Done" self.completed_at = datetime.now() await self.save() return {"message": f"Task '{self.name}' completed"}
async def mark_incomplete(self): """Mark task as incomplete.""" self.status = "To Do" self.completed_at = None await self.save() return {"message": f"Task '{self.name}' reopened"}Then register the file in modules/todo/models/__init__.py — a model file is imported
only if its package __init__.py lists it (the loader imports the models/ package and
does not descend into it):
from . import taskA model file dropped in without this line silently never loads (no registration, no
__inherit__/_selection_add extensions). Keep the list alphabetical.
Step 4: Create Views
Section titled “Step 4: Create Views”Create modules/todo/views/task_views.json:
[ { "data_type": "UiView", "name": "task_kanban_view", "identifier": "task_kanban_view", "type": "Kanban", "model": "Task", "arch": [ { "drag_fields": ["status"], "content": [ { "type": "field", "name": "priority", "properties": { "widget": "Badge", "size": "xs", "colors": { "Low": "gray", "Medium": "blue", "High": "orange", "Urgent": "red" } } }, { "type": "field", "name": "name", "properties": { "widget": "Text", "fw": "600" } }, { "type": "row", "content": [ { "type": "field", "name": "assignee", "span": 6, "properties": { "widget": "Avatar", "size": "sm" } }, { "type": "field", "name": "due_date", "span": 6, "properties": { "widget": "Text", "size": "xs", "c": "dimmed" } } ] } ] } ] }, { "data_type": "UiView", "name": "task_list_view", "identifier": "task_list_view", "type": "List", "model": "Task", "arch": [ { "content": [ {"type": "field", "name": "name", "properties": {"widget": "Text"}}, {"type": "field", "name": "task_list", "properties": {"widget": "Text"}}, {"type": "field", "name": "assignee", "properties": {"widget": "Text"}}, {"type": "field", "name": "status", "properties": { "widget": "Badge", "colors": { "To Do": "gray", "In Progress": "blue", "Review": "orange", "Done": "green" } }}, {"type": "field", "name": "priority", "properties": { "widget": "Badge", "colors": { "Low": "gray", "Medium": "blue", "High": "orange", "Urgent": "red" } }}, {"type": "field", "name": "due_date", "properties": {"widget": "Text"}} ] } ] }, { "data_type": "UiView", "name": "task_form_view", "identifier": "task_form_view", "type": "Form", "model": "Task", "arch": [ { "type": "statusbar", "name": "status", "properties": { "widget": "Badge", "variant": "dot", "colors": { "To Do": "gray", "In Progress": "blue", "Review": "orange", "Done": "green" } } }, { "type": "actionButton", "properties": { "label": "Mark Complete", "icon": "IconCheck", "variant": "primary", "method": "mark_complete", "visible": "Q(status__neq='Done')" } }, { "type": "actionButton", "properties": { "label": "Reopen", "icon": "IconRefresh", "variant": "outline", "method": "mark_incomplete", "visible": "Q(status__eq='Done')" } }, { "type": "row", "content": [ { "type": "column", "span": 8, "content": [ { "type": "field", "name": "name", "properties": {"widget": "TextInput", "size": "lg", "placeholder": "Task title"} } ] }, { "type": "column", "span": 4, "content": [ {"type": "field", "name": "priority", "properties": {"widget": "SelectCombo"}} ] } ] }, { "type": "row", "content": [ { "type": "column", "span": 6, "content": [ {"type": "field", "name": "task_list", "properties": {"widget": "DataCombo"}}, {"type": "field", "name": "assignee", "properties": {"widget": "DataCombo"}}, {"type": "field", "name": "tags", "properties": {"widget": "MultiCombo"}} ] }, { "type": "column", "span": 6, "content": [ {"type": "field", "name": "due_date", "properties": {"widget": "DatePickerInput"}}, {"type": "field", "name": "completed_at", "properties": {"widget": "DateTimePicker", "readonly": true}} ] } ] }, { "type": "row", "content": [ { "type": "column", "span": 12, "content": [ {"type": "field", "name": "description", "properties": {"widget": "RichTextEditor"}} ] } ] } ] }, { "data_type": "UiView", "name": "task_search_view", "identifier": "task_search_view", "type": "Search", "model": "Task", "arch": [ { "type": "filter", "identifier": "my_tasks", "description": "My Tasks", "filter": "(Q(assignee__id__eq=uid))" }, { "type": "filter", "identifier": "overdue", "description": "Overdue", "filter": "(Q(due_date__lt=today) & Q(status__neq='Done'))" }, { "type": "group", "identifier": "group_status", "name": "status", "description": "Status" }, { "type": "group", "identifier": "group_priority", "name": "priority", "description": "Priority" } ] }]Create modules/todo/views/task_list_views.json:
[ { "data_type": "UiView", "name": "task_list_model_list_view", "identifier": "task_list_model_list_view", "type": "List", "model": "TaskList", "arch": [ { "content": [ {"type": "field", "name": "name", "properties": {"widget": "Text"}}, {"type": "field", "name": "owner", "properties": {"widget": "Text"}}, {"type": "field", "name": "task_count", "properties": {"widget": "Text"}}, {"type": "field", "name": "archived", "properties": {"widget": "Badge"}} ] } ] }, { "data_type": "UiView", "name": "task_list_model_form_view", "identifier": "task_list_model_form_view", "type": "Form", "model": "TaskList", "arch": [ { "type": "row", "content": [ { "type": "column", "span": 8, "content": [ {"type": "field", "name": "name", "properties": {"widget": "TextInput", "size": "lg"}} ] }, { "type": "column", "span": 4, "content": [ {"type": "field", "name": "color", "properties": {"widget": "ColorInput"}}, {"type": "field", "name": "archived", "properties": {"widget": "Switch"}} ] } ] }, { "type": "row", "content": [ { "type": "column", "span": 6, "content": [ {"type": "field", "name": "owner", "properties": {"widget": "DataCombo"}} ] } ] }, { "type": "row", "content": [ { "type": "column", "span": 12, "content": [ {"type": "field", "name": "description", "properties": {"widget": "TextArea"}} ] } ] }, { "type": "tab", "title": "Tasks", "content": [ { "type": "field", "name": "tasks", "properties": { "widget": "List", "create": true, "view": "task_list_view" } } ] } ] }]Step 5: Create Actions and Menus
Section titled “Step 5: Create Actions and Menus”Create modules/todo/views/actions.json:
[ { "data_type": "WindowAction", "name": "Tasks", "identifier": "tasks_action", "model": "Task", "modes": "Kanban,List,Form", "default_view": "task_kanban_view", "views": ["task_kanban_view", "task_list_view", "task_form_view"], "search_view": "task_search_view" }, { "data_type": "WindowAction", "name": "Task Lists", "identifier": "task_lists_action", "model": "TaskList", "modes": "List,Form", "views": ["task_list_model_list_view", "task_list_model_form_view"] }, { "data_type": "WindowAction", "name": "Tags", "identifier": "task_tags_action", "model": "TaskTag", "modes": "List,Form" }]Create modules/todo/views/menus.json:
[ { "data_type": "UiMenu", "name": "Todo", "identifier": "todo_menu", "sequence": 15, "icon": "IconChecklist" }, { "data_type": "UiMenu", "name": "Tasks", "identifier": "todo_tasks_menu", "parent": "todo_menu", "action": "tasks_action", "sequence": 10 }, { "data_type": "UiMenu", "name": "Lists", "identifier": "todo_lists_menu", "parent": "todo_menu", "action": "task_lists_action", "sequence": 20 }, { "data_type": "UiMenu", "name": "Setup", "identifier": "todo_config_menu", "parent": "todo_menu", "action": "configuration_todo_setup_action", "sequence": 100 }]Configuration is a single “Setup” leaf, not a parent menu of config models.
Reference/config models (here, TaskTag) are not exposed as their own menu items.
Instead the app gets exactly one leaf menu named “Setup” (sequence: 100) whose action
opens the shared Configuration settings form at this app’s tab; each former config
menu becomes a settingsLink on that tab. Do not add a “Configuration” parent menu
with a child UiMenu per config model. The three pieces are:
- The
Setupleaf above, pointing at aWindowActionontoConfiguration. - That
WindowAction(inactions.json):
{ "data_type": "WindowAction", "name": "Todo Setup", "identifier": "configuration_todo_setup_action", "model": "Configuration", "default_view": "configuration_form_view", "modes": "Form", "action_ctx": {"default_tab": "Todo"}, "views": [["R", ["configuration_form_view"]]]}- A tab added to
configuration_form_view(inviews/configuration_inherit.json) whosesettingsPanelcarries asettingsLinkwithaction: task_tags_action.
See your existing modules (invoicing, crm, sales) for a complete example of this
Configuration-tab pattern.
Step 6: Create Security Rules
Section titled “Step 6: Create Security Rules”Create modules/todo/security/security.json:
[ { "data_type": "Group", "name": "User", "identifier": "todo_user", "category": "Todo" }, { "data_type": "Group", "name": "Manager", "identifier": "todo_manager", "category": "Todo" }, { "data_type": "ModelAccess", "identifier": "task_user_access", "name": "Task User Access", "model": "Task", "group": "todo_user", "read_perm": true, "write_perm": true, "create_perm": true, "delete_perm": false }, { "data_type": "ModelAccess", "identifier": "task_manager_access", "name": "Task Manager Access", "model": "Task", "group": "todo_manager", "read_perm": true, "write_perm": true, "create_perm": true, "delete_perm": true }, { "data_type": "ModelAccess", "identifier": "task_list_user_access", "name": "TaskList User Access", "model": "TaskList", "group": "todo_user", "read_perm": true, "write_perm": true, "create_perm": true, "delete_perm": false }, { "data_type": "ModelAccess", "identifier": "task_tag_user_access", "name": "TaskTag User Access", "model": "TaskTag", "group": "todo_user", "read_perm": true, "write_perm": false, "create_perm": false, "delete_perm": false }, { "data_type": "ModelAccess", "identifier": "task_tag_manager_access", "name": "TaskTag Manager Access", "model": "TaskTag", "group": "todo_manager", "read_perm": true, "write_perm": true, "create_perm": true, "delete_perm": true }]Step 7: Add Initial Data (Optional)
Section titled “Step 7: Add Initial Data (Optional)”Create modules/todo/data/data.json:
[ { "data_type": "TaskTag", "identifier": "tag_bug", "name": "Bug", "color": "#EF4444" }, { "data_type": "TaskTag", "identifier": "tag_feature", "name": "Feature", "color": "#3B82F6" }, { "data_type": "TaskTag", "identifier": "tag_improvement", "name": "Improvement", "color": "#10B981" }]Module Hooks
Section titled “Module Hooks”Modules can define lifecycle hooks in their __init__.py file. These are plain async functions that run at specific points in the module’s lifecycle.
Lifecycle Hooks
Section titled “Lifecycle Hooks”| Hook | When it runs | Location |
|---|---|---|
pre_install | Before the schema migration, at the start of install | __init__.py |
post_install | After the schema migration and view/data import | __init__.py |
pre_update | Before the schema migration, at the start of an update | __init__.py |
post_update | After the migration, view import, and stored-field recompute | __init__.py |
pre_uninstall | Before tables are dropped (records/tables still exist) | __init__.py |
post_uninstall | After tables are dropped and views/menus/security removed | __init__.py |
All six __init__.py hooks are discovered by name: define a top-level
async def <hook_name>(): in the module’s __init__.py and it is awaited
automatically at the matching point — there is no manifest key to register them,
and a missing function is simply skipped. They take no arguments. Use
pre_uninstall for cleanup that must run while the module’s tables still
exist (e.g. unwinding external resources keyed off its records); by the time
post_uninstall runs the tables are already gone.
Install/Update Hooks
Section titled “Install/Update Hooks”These are defined in __init__.py and run during module install (-i) and update (-u) operations:
async def post_install(): """Runs once after the module is first installed.""" from fullfinity.engine.context import env_ctx env = env_ctx.get() # Create default data, configure settings, etc. await env("TaskTag").create([ {"name": "Bug", "color": "#EF4444"}, {"name": "Feature", "color": "#3B82F6"}, ])
async def post_update(): """Runs after the module is updated.""" passSchema changes & data migrations
Section titled “Schema changes & data migrations”There are no per-version upgrade files (upgrades/v{X}_{Y}.py and pre_upgrade/post_upgrade were retired). Migrations are declarative and append-only, driven by a change-id ledger, not a framework version:
-
Adding a field/model is free —
-u alladds the column/table and backfills existing rows from the field’sdefault/calculate/related_field. No file, no hook. -
Renaming / removing / retyping an existing field or model is a breaking change the
--check-schemagate blocks. Runfullfinity-server --resolve-schema(rename / delete / complex), commit the resultingschema_changes.yamlentry + re-snapshottedschema_baseline.json, and-u allreplays it. -
A pure data migration (a backfill or storage reshape, not tied to a schema change) is a
dataledger entry: write a hook in your module, e.g.modules/todo/migrations.py async def backfill_urgency(env):# ORM reflects the new code; use raw SQL / env helpers for the data pass.await env.conn.execute("UPDATE task SET urgency = priority WHERE urgency IS NULL")then add a
dataledger entry pointing at it (modules.todo.migrations:backfill_urgency) plus a fixture test (the gate requires it).-u allruns it once per database via the id clock — no version bump.
Full details: Migrations & Upgrades.
Step 8: Install the Module
Section titled “Step 8: Install the Module”- Restart the Fullfinity server
- Navigate to Settings → Modules
- Find “Todo” and click Install
Result
Section titled “Result”After installation, you’ll have:
- A “Todo” menu in the sidebar
- Kanban board for tasks (drag & drop by status)
- Task list and form views
- Task lists/projects with embedded task list
- Tags for categorization
- “Mark Complete” action button on tasks
- Filters for “My Tasks” and “Overdue”
Next Steps
Section titled “Next Steps”- Module Manifest - All manifest options
- Data Files - Loading initial data
- View Inheritance - Extending other modules