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", 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):
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.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: PriorityCreate 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_viewStep 5: Create Actions and Menus
Section titled “Step 5: Create Actions and Menus”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, FormCreate 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: 100Configuration 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.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- A tab added to
configuration_form_view(inviews/configuration_inherit.yaml) 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.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: trueStep 7: Add Initial Data (Optional)
Section titled “Step 7: Add Initial Data (Optional)”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: trueYour 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 -uTwo 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).
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 |
pre_data | Immediately before this module’s own views/data are imported | __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 recalculate | __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 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)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 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 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 --only schemagate blocks. Runfullfinity-server resolve(rename / delete / complex), commit the resulting entry in your module’s ownschema_changes.yaml+ the re-snapshotted<your_module>/schema_baseline.json, and-u allreplays 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.jsonandschema_changes.yamlinside the package. Because they travel with the module,-u allself-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. Runcheck --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
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