Skip to content

Building a CRM Module

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

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

Create manifest.yaml:

name: CRM
identifier: crm
description: Customer Relationship Management
category: module_category_crm
application: true
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", on_delete="SET NULL", 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, on_delete="RESTRICT", 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.yaml:

- 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:
- - L
- 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:
- - L
- crm_manager
rule: (Q(id__gt=0))
read_perm: true
write_perm: true
create_perm: true
delete_perm: true

Create data/stages.yaml:

- data_type: CrmStage
identifier: stage_new
name: New
sequence: 1
apply_once: true
- data_type: CrmStage
identifier: stage_qualified
name: Qualified
sequence: 2
apply_once: true
- data_type: CrmStage
identifier: stage_proposition
name: Proposition
sequence: 3
apply_once: true
- data_type: CrmStage
identifier: stage_negotiation
name: Negotiation
sequence: 4
apply_once: true
- data_type: CrmStage
identifier: stage_won
name: Won
sequence: 5
is_won: true
fold: true
apply_once: true

Create views/stage_views.yaml:

- data_type: UiView
identifier: crm_stage_list_view
type: List
model: CrmStage
arch:
- reorderable: sequence
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: row
content:
- type: column
span: 6
content:
- type: field
name: name
properties:
widget: TextInput
size: lg
- type: field
name: is_won
properties:
widget: Switch
- type: column
span: 6
content:
- type: field
name: fold
properties:
widget: Switch

Create views/lead_views.yaml:

- 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: DataCombo
- 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: Badge
variant: dot
- 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
anchor: notes
content:
- type: field
name: description
properties:
widget: RichTextEditor
nolabel: true
- type: tab
title: Tags
anchor: 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.yaml. 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
search_view: crm_lead_search_view
action_ctx:
view:
filters:
- my_leads
groups:
- stage
views:
- - R
- - crm_lead_kanban_view
- crm_lead_list_view
- crm_lead_form_view
- data_type: WindowAction
identifier: crm_stage_action
name: Stages
model: CrmStage
modes: List, Form
views:
- - R
- - 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:
- - R
- - configuration_form_view

Create views/menus.yaml. 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:
- - L
- 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.yaml:

- 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
anchor: crm
properties:
icon: Target
groups: [crm_manager]
content:
- type: settingsPanel
anchor: crm_setup
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)