Skip to content

Building a CRM Module

This guide walks through creating a complete CRM module from scratch.

crm/
├── __init__.py
├── manifest.json
├── models/
│ ├── __init__.py
│ ├── crm_lead.py
│ └── crm_stage.py
├── views/
│ ├── lead_views.json
│ ├── stage_views.json
│ └── menus.json
├── security/
│ └── security.json
└── data/
└── stages.json

Create manifest.json:

{
"name": "CRM",
"identifier": "crm",
"description": "Customer Relationship Management",
"category": "module_category_crm",
"dependencies": ["core"],
"icon": "Filter"
}

Create models/crm_stage.py:

from fullfinity.engine.base import *
class CrmStage(Model):
_verbose_name = "CRM Stage"
_order_by = ["sequence ASC", "id ASC"]
name = Char(max_length=255, required=True, description="Stage Name")
sequence = Integer(default=10, description="Sequence")
is_won = Boolean(default=False, description="Is Won Stage")
fold = Boolean(default=False, description="Folded in Kanban", hint="Folded stages are collapsed by default")

Create models/crm_lead.py:

from fullfinity.engine.base import *
class CrmLead(Model):
_verbose_name = "Lead"
_order_by = ["id DESC"]
_collaborate = True
_global_search = True
# Basic Info
name = Char(max_length=255, required=True, description="Opportunity")
active = Boolean(default=True)
# Contact
contact = ManyToOne("Contact", related_name="leads", description="Customer")
email = Char(max_length=255, description="Email")
phone = Char(max_length=50, description="Phone")
# Sales Info
salesperson = ManyToOne("User", related_name="leads", on_delete="SET NULL", description="Salesperson")
# Pipeline
stage = ManyToOne("CrmStage", related_name="leads", required=True, description="Stage")
priority = Selection(
choices=["Low", "Medium", "High", "Urgent"],
default="Medium",
max_length=50,
description="Priority"
)
# Opportunity
opportunity_amount = Monetary(default=0, description="Expected Revenue")
probability = Integer(default=10, description="Probability (%)")
expected_close_date = Date(description="Expected Closing")
# Tags
tags = ManyToMany("CrmTag", related_name="leads", through="FkCrmLeadTag", description="Tags")
# Description
description = Text(description="Notes")
# UI effect for probability based on stage
@Model.ui_effect("stage")
async def on_stage_change(self):
"""Update probability when stage changes."""
if self.stage:
await self.fetch_related("stage")
if self.stage.is_won:
self.probability = 100
async def action_mark_won(self):
"""Mark lead as won."""
CrmStage = get_model("CrmStage")
won_stage = await CrmStage.filter(is_won=True).first()
if won_stage:
self.stage = won_stage
self.probability = 100
await self.save()
return {"message": "Lead marked as won!"}
async def action_mark_lost(self):
"""Mark lead as lost."""
self.active = False
await self.save()
return {"message": "Lead marked as lost."}
class CrmTag(Model):
_verbose_name = "CRM Tag"
name = Char(max_length=100, required=True, description="Tag Name")
color = Char(max_length=20, default="#6366F1", description="Color")
class FkCrmLeadTag(Model):
_verbose_name = "Lead Tag"
_is_through_table = True
lead = ManyToOne("CrmLead", related_name="lead_tags", on_delete="CASCADE")
tag = ManyToOne("CrmTag", related_name="tag_leads", on_delete="CASCADE")

Create security/security.json:

[
{
"data_type": "Group",
"identifier": "crm_user",
"name": "User",
"category": "CRM"
},
{
"data_type": "Group",
"identifier": "crm_manager",
"name": "Manager",
"category": "CRM",
"implied_groups": [["L", "crm_user"]]
},
{
"data_type": "ModelAccess",
"identifier": "access_crm_lead_user",
"name": "Lead Access (User)",
"model": "CrmLead",
"group": "crm_user",
"read_perm": true,
"create_perm": true,
"write_perm": true,
"delete_perm": false
},
{
"data_type": "ModelAccess",
"identifier": "access_crm_lead_manager",
"name": "Lead Access (Manager)",
"model": "CrmLead",
"group": "crm_manager",
"read_perm": true,
"create_perm": true,
"write_perm": true,
"delete_perm": true
},
{
"data_type": "ModelAccess",
"identifier": "access_crm_stage_user",
"name": "Stage Access (User)",
"model": "CrmStage",
"group": "crm_user",
"read_perm": true,
"create_perm": false,
"write_perm": false,
"delete_perm": false
},
{
"data_type": "ModelAccess",
"identifier": "access_crm_stage_manager",
"name": "Stage Access (Manager)",
"model": "CrmStage",
"group": "crm_manager",
"read_perm": true,
"create_perm": true,
"write_perm": true,
"delete_perm": true
},
{
"data_type": "ModelAccess",
"identifier": "access_crm_tag_user",
"name": "Tag Access (User)",
"model": "CrmTag",
"group": "crm_user",
"read_perm": true,
"create_perm": true,
"write_perm": true,
"delete_perm": false
},
{
"data_type": "RecordRule",
"identifier": "rule_lead_user",
"name": "Leads: user sees own",
"model": "CrmLead",
"groups": ["crm_user"],
"rule": "(Q(salesperson__id__eq=uid))",
"read_perm": true,
"write_perm": true,
"create_perm": true,
"delete_perm": false
},
{
"data_type": "RecordRule",
"identifier": "rule_lead_manager",
"name": "Leads: manager sees all",
"model": "CrmLead",
"groups": ["crm_manager"],
"rule": "(Q(id__gt=0))",
"read_perm": true,
"write_perm": true,
"create_perm": true,
"delete_perm": true
}
]

Create data/stages.json:

[
{
"data_type": "CrmStage",
"identifier": "stage_new",
"name": "New",
"sequence": 1
},
{
"data_type": "CrmStage",
"identifier": "stage_qualified",
"name": "Qualified",
"sequence": 2
},
{
"data_type": "CrmStage",
"identifier": "stage_proposition",
"name": "Proposition",
"sequence": 3
},
{
"data_type": "CrmStage",
"identifier": "stage_negotiation",
"name": "Negotiation",
"sequence": 4
},
{
"data_type": "CrmStage",
"identifier": "stage_won",
"name": "Won",
"sequence": 5,
"is_won": true,
"fold": true
}
]

Create views/stage_views.json:

[
{
"data_type": "UiView",
"identifier": "crm_stage_list_view",
"type": "List",
"model": "CrmStage",
"arch": [
{
"content": [
{"type": "field", "name": "sequence", "properties": {"widget": "Text"}},
{"type": "field", "name": "name", "properties": {"widget": "Text", "fw": "600"}},
{"type": "field", "name": "is_won", "properties": {"widget": "Checkbox"}},
{"type": "field", "name": "fold", "properties": {"widget": "Checkbox"}}
]
}
]
},
{
"data_type": "UiView",
"identifier": "crm_stage_form_view",
"type": "Form",
"model": "CrmStage",
"arch": [
{
"type": "field",
"name": "name",
"properties": {"widget": "TextInput", "size": "lg"}
},
{
"type": "row",
"content": [
{
"type": "column",
"span": 6,
"content": [
{"type": "field", "name": "sequence", "properties": {"widget": "NumberInput"}},
{"type": "field", "name": "is_won", "properties": {"widget": "Switch"}}
]
},
{
"type": "column",
"span": 6,
"content": [
{"type": "field", "name": "fold", "properties": {"widget": "Switch"}}
]
}
]
}
]
}
]

Create views/lead_views.json:

[
{
"data_type": "UiView",
"identifier": "crm_lead_kanban_view",
"type": "Kanban",
"model": "CrmLead",
"arch": [
{
"drag_fields": ["stage"],
"header_aggregate": {
"field": "opportunity_amount",
"type": "sum"
},
"content": [
{
"type": "row",
"content": [
{
"type": "field",
"name": "priority",
"span": 6,
"properties": {
"widget": "Badge",
"size": "xs",
"variant": "dot",
"colors": {
"Low": "gray",
"Medium": "blue",
"High": "orange",
"Urgent": "red"
}
}
},
{
"type": "field",
"name": "probability",
"span": 6,
"properties": {"widget": "Text", "size": "xs", "c": "dimmed"}
}
]
},
{
"type": "field",
"name": "name",
"properties": {"widget": "Text", "fw": "600", "lineClamp": 2}
},
{
"type": "field",
"name": "contact",
"properties": {"widget": "Text", "size": "sm", "c": "dimmed"}
},
{
"type": "row",
"properties": {"mt": "sm"},
"content": [
{
"type": "field",
"name": "opportunity_amount",
"span": 6,
"properties": {"widget": "Text", "fw": "500"}
},
{
"type": "field",
"name": "salesperson",
"span": 6,
"properties": {"widget": "Avatar", "size": "sm"}
}
]
}
]
}
]
},
{
"data_type": "UiView",
"identifier": "crm_lead_list_view",
"type": "List",
"model": "CrmLead",
"arch": [
{
"content": [
{"type": "field", "name": "name", "properties": {"widget": "Text", "fw": "600"}},
{"type": "field", "name": "contact", "properties": {"widget": "Text"}},
{"type": "field", "name": "stage", "properties": {"widget": "Badge"}},
{"type": "field", "name": "opportunity_amount", "properties": {"widget": "Text"}},
{"type": "field", "name": "probability", "properties": {"widget": "Text"}},
{"type": "field", "name": "salesperson", "properties": {"widget": "Avatar", "size": "sm"}},
{"type": "field", "name": "expected_close_date", "properties": {"widget": "Text"}}
]
}
]
},
{
"data_type": "UiView",
"identifier": "crm_lead_form_view",
"type": "Form",
"model": "CrmLead",
"arch": [
{
"type": "statusbar",
"name": "stage",
"properties": {"widget": "StatusBar"}
},
{
"type": "action_buttons",
"content": [
{
"type": "button",
"name": "action_mark_won",
"properties": {
"label": "Mark Won",
"color": "green",
"visible": "(Q(active__eq=True))"
}
},
{
"type": "button",
"name": "action_mark_lost",
"properties": {
"label": "Mark Lost",
"color": "red",
"variant": "outline",
"visible": "(Q(active__eq=True))"
}
}
]
},
{
"type": "field",
"name": "name",
"properties": {"widget": "TextInput", "size": "lg", "nolabel": true, "placeholder": "Opportunity name..."}
},
{
"type": "row",
"content": [
{
"type": "column",
"span": 6,
"content": [
{"type": "field", "name": "contact", "properties": {"widget": "DataCombo"}},
{"type": "field", "name": "email", "properties": {"widget": "TextInput"}},
{"type": "field", "name": "phone", "properties": {"widget": "TextInput"}},
{"type": "field", "name": "salesperson", "properties": {"widget": "DataCombo"}}
]
},
{
"type": "column",
"span": 6,
"content": [
{"type": "field", "name": "opportunity_amount", "properties": {"widget": "NumberInput"}},
{"type": "field", "name": "probability", "properties": {"widget": "NumberInput", "suffix": "%"}},
{"type": "field", "name": "expected_close_date", "properties": {"widget": "DatePickerInput"}},
{"type": "field", "name": "priority", "properties": {"widget": "SelectCombo"}}
]
}
]
},
{
"type": "tabs",
"content": [
{
"type": "tab",
"title": "Notes",
"content": [
{"type": "field", "name": "description", "properties": {"widget": "RichTextEditor", "nolabel": true}}
]
},
{
"type": "tab",
"title": "Tags",
"content": [
{"type": "field", "name": "tags", "properties": {"widget": "MultiCombo"}}
]
}
]
}
]
},
{
"data_type": "UiView",
"identifier": "crm_lead_search_view",
"type": "Search",
"model": "CrmLead",
"arch": [
{"type": "filter", "identifier": "my_leads", "description": "My Leads", "filter": "(Q(salesperson__id__eq=uid))"},
{"type": "filter", "identifier": "unassigned", "description": "Unassigned", "filter": "(Q(salesperson__isnull=True))"},
{"type": "filter", "identifier": "won", "description": "Won", "filter": "(Q(stage__is_won=True))"},
{"type": "separator"},
{"type": "group", "identifier": "group_stage", "name": "stage", "description": "Stage"},
{"type": "group", "identifier": "group_salesperson", "name": "salesperson", "description": "Salesperson"}
]
}
]

Create views/actions.json. Note the Setup action: config/reference models (Stages, Tags, …) are not given their own menu items — they’re reached from the Configuration settings form. So instead of a “Stages” menu, we add one WindowAction that opens the Configuration form on the CRM tab:

[
{
"data_type": "WindowAction",
"identifier": "crm_lead_action",
"name": "Pipeline",
"model": "CrmLead",
"modes": "Kanban, List, Form",
"default_view": "crm_lead_kanban_view",
"views": ["crm_lead_kanban_view", "crm_lead_list_view", "crm_lead_form_view"],
"search_view": "crm_lead_search_view",
"context": {
"view": {
"filters": ["my_leads"],
"groups": ["stage"]
}
}
},
{
"data_type": "WindowAction",
"identifier": "crm_stage_action",
"name": "Stages",
"model": "CrmStage",
"modes": "List, Form",
"views": ["crm_stage_list_view", "crm_stage_form_view"]
},
{
"data_type": "WindowAction",
"identifier": "configuration_crm_setup_action",
"name": "CRM Setup",
"model": "Configuration",
"default_view": "configuration_form_view",
"modes": "Form",
"action_ctx": {"default_tab": "crm"},
"views": ["configuration_form_view"]
}
]

Create views/menus.json. The app gets a CRM root, a Pipeline leaf, and a single Setup leaf that opens the Configuration form. There is no “Configuration” parent menu with a child menu per config model — that’s an anti-pattern:

[
{
"data_type": "UiMenu",
"identifier": "crm_menu_root",
"name": "CRM",
"sequence": 20,
"icon": "Target"
},
{
"data_type": "UiMenu",
"identifier": "crm_menu_pipeline",
"name": "Pipeline",
"parent": "crm_menu_root",
"action": "crm_lead_action",
"sequence": 10
},
{
"data_type": "UiMenu",
"identifier": "crm_menu_setup",
"name": "Setup",
"parent": "crm_menu_root",
"action": "configuration_crm_setup_action",
"sequence": 100,
"groups": ["crm_manager"]
}
]

Finally, surface the Stages config model inside the Configuration form. Inherit configuration_form_view, add a CRM tab into pageTabs, and put a settingsLink (pointing at crm_stage_action) in a settingsPanel. Create views/configuration_inherit.json:

[
{
"data_type": "UiView",
"identifier": "configuration_form_view_crm",
"inherited_view": "configuration_form_view",
"type": "Form",
"model": "Configuration",
"arch": {
"operations": [
{
"action": "add",
"target": {"type": "pageTabs"},
"position": "inside",
"value": {
"type": "tab",
"name": "crm",
"title": "CRM",
"properties": {"icon": "Target", "groups": ["crm_manager"]},
"content": [
{
"type": "settingsPanel",
"properties": {"title": "Setup"},
"content": [
{
"type": "settingsLink",
"properties": {
"label": "Stages",
"description": "Configure pipeline stages for leads",
"action": "crm_stage_action"
}
}
]
}
]
}
}
]
}
}
]

You now have a complete CRM module with:

  • Models: Lead, Stage, and Tag with proper relationships
  • Views: Kanban pipeline, List, and Form views
  • Actions: Mark Won/Lost buttons with business logic
  • Search: Filters (My Leads, Unassigned, Won) and grouping
  • Security: User and Manager groups with record rules
  • Menus: CRM menu with a Pipeline leaf and a Setup leaf that opens the Configuration form (Stages reached via a settings link)
PatternExample
_verbose_nameRequired model attribute for UI labels
_collaborateEnable activity tracking on leads
_global_searchMake leads searchable globally
ManyToOneLink leads to contacts and stages
ManyToManyTags with through table
@Model.ui_effectUpdate probability when stage changes
Action methodsaction_mark_won() callable from UI
UiMenuMenu items with parent hierarchy
WindowActionActions linking views to menus
  • Add email integration
  • Create dashboard views
  • Add reporting and analytics
  • Implement lead scoring
  • Add automated actions (e.g., assign leads automatically)