Skip to content

Creating Modules

This guide walks through creating a complete module from scratch.

Terminal window
mkdir -p modules/todo/models
mkdir -p modules/todo/views
mkdir -p modules/todo/data
mkdir -p modules/todo/security
mkdir -p modules/todo/templates
touch modules/todo/__init__.py
touch modules/todo/models/__init__.py
FolderPurpose
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.

Create modules/todo/manifest.yaml:

name: Todo
identifier: todo
category: module_category_productivity
description: Task management and todo lists
dependencies:
- core
icon: Checklist
version: '1.0'

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):

modules/todo/models/__init__.py
from . import task

A model file dropped in without this line silently never loads (no registration, no __inherit__/_selection_add extensions). Keep the list alphabetical.

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"
}
}
]
}
]
}
]

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:

  1. The Setup leaf above, pointing at a WindowAction onto Configuration.
  2. That WindowAction (in actions.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"]]]
}
  1. A tab added to configuration_form_view (in views/configuration_inherit.json) whose settingsPanel carries a settingsLink with action: task_tags_action.

See your existing modules (invoicing, crm, sales) for a complete example of this Configuration-tab pattern.

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
}
]

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"
}
]

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.

HookWhen it runsLocation
pre_installBefore the schema migration, at the start of install__init__.py
post_installAfter the schema migration and view/data import__init__.py
pre_updateBefore the schema migration, at the start of an update__init__.py
post_updateAfter the migration, view import, and stored-field recompute__init__.py
pre_uninstallBefore tables are dropped (records/tables still exist)__init__.py
post_uninstallAfter 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.

These are defined in __init__.py and run during module install (-i) and update (-u) operations:

modules/todo/__init__.py
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."""
pass

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 all adds the column/table and backfills existing rows from the field’s default/calculate/related_field. No file, no hook.

  • Renaming / removing / retyping an existing field or model is a breaking change the --check-schema gate blocks. Run fullfinity-server --resolve-schema (rename / delete / complex), commit the resulting schema_changes.yaml entry + re-snapshotted schema_baseline.json, and -u all replays it.

  • A pure data migration (a backfill or storage reshape, not tied to a schema change) is a data ledger 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 data ledger entry pointing at it (modules.todo.migrations:backfill_urgency) plus a fixture test (the gate requires it). -u all runs it once per database via the id clock — no version bump.

Full details: Migrations & Upgrades.

  1. Restart the Fullfinity server
  2. Navigate to Settings → Modules
  3. Find “Todo” and click Install

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”