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.yaml├── models/│ ├── __init__.py│ ├── crm_lead.py│ └── crm_stage.py├── views/│ ├── lead_views.yaml│ ├── stage_views.yaml│ └── menus.yaml├── security/│ └── security.yaml└── data/ └── stages.yamlStep 1: Module Manifest
Section titled “Step 1: Module Manifest”Create manifest.yaml:
name: CRMidentifier: crmdescription: Customer Relationship Managementcategory: module_category_crmapplication: truedependencies: - coreicon: FilterStep 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", 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")Step 3: Security
Section titled “Step 3: Security”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: trueStep 4: Initial Data
Section titled “Step 4: Initial Data”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: trueStep 5: Views
Section titled “Step 5: Views”Stage Views
Section titled “Stage Views”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: SwitchLead Views
Section titled “Lead Views”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: SalespersonActions
Section titled “Actions”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_viewCreate 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_managerConfiguration 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.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_actionResult
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)