Building a CRM Module
This guide walks through creating a complete CRM module from scratch.
Module Structure
Section titled “Module Structure”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.jsonStep 1: Module Manifest
Section titled “Step 1: Module Manifest”Create manifest.json:
{ "name": "CRM", "identifier": "crm", "description": "Customer Relationship Management", "category": "module_category_crm", "dependencies": ["core"], "icon": "Filter"}Step 2: Define Models
Section titled “Step 2: Define Models”Stage Model
Section titled “Stage Model”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")Lead Model
Section titled “Lead Model”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")Step 3: Security
Section titled “Step 3: Security”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 }]Step 4: Initial Data
Section titled “Step 4: Initial Data”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 }]Step 5: Views
Section titled “Step 5: Views”Stage Views
Section titled “Stage Views”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"}} ] } ] } ] }]Lead Views
Section titled “Lead Views”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"} ] }]Actions
Section titled “Actions”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"] }]Configuration Tab
Section titled “Configuration Tab”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" } } ] } ] } } ] } }]Result
Section titled “Result”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)
Key Patterns Used
Section titled “Key Patterns Used”| Pattern | Example |
|---|---|
_verbose_name | Required model attribute for UI labels |
_collaborate | Enable activity tracking on leads |
_global_search | Make leads searchable globally |
ManyToOne | Link leads to contacts and stages |
ManyToMany | Tags with through table |
@Model.ui_effect | Update probability when stage changes |
| Action methods | action_mark_won() callable from UI |
UiMenu | Menu items with parent hierarchy |
WindowAction | Actions linking views to menus |
Next Steps
Section titled “Next Steps”- Add email integration
- Create dashboard views
- Add reporting and analytics
- Implement lead scoring
- Add automated actions (e.g., assign leads automatically)