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", on_delete="SET NULL")
archived = Boolean(default=False)
# Calculated 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, on_delete="CASCADE")
assignee = ManyToOne("User", related_name="assigned_tasks", on_delete="SET NULL")
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.yaml:

- 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: DataCombo
- type: field
name: assignee
properties:
widget: DataCombo
- 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
anchor: mark_complete
properties:
label: Mark Complete
icon: Check
variant: primary
method: mark_complete
visible: (Q(status__neq='Done'))
- type: actionButton
anchor: reopen
properties:
label: Reopen
icon: RefreshCw
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.yaml:

- 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: DataCombo
- 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
anchor: tasks
content:
- type: field
name: tasks
properties:
widget: List
create: true
view: task_list_view

Create modules/todo/views/actions.yaml:

- data_type: WindowAction
name: Tasks
identifier: tasks_action
model: Task
modes: Kanban, List, Form
default_view: task_kanban_view
views:
- - R
- - 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:
- - R
- - 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.yaml:

- data_type: UiMenu
name: Todo
identifier: todo_menu
sequence: 15
icon: ListChecks
- 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.yaml):
- 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.yaml) 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.yaml:

- 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.yaml:

- data_type: TaskTag
identifier: tag_bug
name: Bug
color: '#EF4444'
apply_once: true
- data_type: TaskTag
identifier: tag_feature
name: Feature
color: '#3B82F6'
apply_once: true
- data_type: TaskTag
identifier: tag_improvement
name: Improvement
color: '#10B981'
apply_once: true

Your file is the record: removing a line removes the value

Section titled “Your file is the record: removing a line removes the value”

For the record types the module system owns — views, menus, actions, groups, model access, record rules — what your YAML declares is the record. A field you stop declaring goes back to its default on the next update, exactly as a record you stop declaring is deleted.

So deleting a line does what you expect:

- data_type: WindowAction
identifier: task_action
name: Tasks
model: Task
global_filter: Q(archived=False) # remove this line -> the filter is gone after -u

Two things follow from it.

A module may narrow another module’s screen, and the narrowing lifts cleanly. Declare the owner’s identifier and only the field you are changing; ownership stays with the owner, and your value applies while your module is installed:

# in your module — sales still owns this action
- data_type: WindowAction
identifier: sale_quote_action
global_filter: Q(is_cart=False)

Uninstall your module and the owner’s own re-import puts the field back — you write no cleanup code. Updates expand to every installed dependent and run in dependency order, so the owner is always followed by whoever patches it, within one transaction.

Not affected: apply_once: true records (skipped entirely once they exist, which is what protects data a user may edit), collections such as groups or implied_groups (their link/set commands are a contribution mechanism, so “undeclared” cannot mean “cleared”), the active flag (archiving is the user’s decision), calculated and mirrored values, and cron jobs (the scheduler writes their last_run/next_run, and no YAML declares those).

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
pre_dataImmediately before this module’s own views/data are imported__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 recalculate__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 seven __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.

pre_install runs for every module in the batch before any module’s data is imported, so it cannot see records a dependency is about to create. When your data would collide with a dependency’s — a site module claiming a page path that the website app’s demo data ships as a placeholder — use pre_data: it runs immediately before this module’s own import, with every dependency’s records already in place, and it runs on the update path too. It is the only hook that can resolve a collision before the uniqueness check that would otherwise abort the install mid-import.

async def pre_data():
"""Clear the way for this module's pages."""
with elevate():
WebPage = get_model("WebPage")
clashing = [
page for page in await WebPage.filter(slug__in=["about", "contact"]).all()
if not (page.identifier or "").startswith("mysite_")
]
if clashing:
await WebPage.delete(clashing)

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 gated, driven by an append-only change-id ledger (the ledger only grows; the changes it records — including destructive renames and deletes — are replayed by -u all), 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 --only schema gate blocks. Run fullfinity-server resolve (rename / delete / complex), commit the resulting entry in your module’s own schema_changes.yaml + the re-snapshotted <your_module>/schema_baseline.json, and -u all replays it. Both files live inside your module directory — your module owns its schema history. Resolve non-interactively (for an agent or CI) by declaring intent as flags instead of answering prompts:

    Terminal window
    fullfinity-server resolve --rename Task.due=deadline --delete Task.legacy_note
  • Distributing a module (app store / third party). Ship your schema_baseline.json and schema_changes.yaml inside the package. Because they travel with the module, -u all self-checks them on the customer’s machine and refuses to install a build that broke its own contract without a record (or that ships a stored surface but no baseline) — you don’t depend on the customer running your CI. Run check --only schema --module <id> at store intake to catch it before publish.

  • 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”