End-to-End CRUD Example (VendorTable)

Developer guide for the Enezaframework. Part of the Developer documentation.

Table of Contents

  1. Overview
  2. The Data Model
  3. Models
  4. Views
  5. Inline Views
  6. Setup Views
  7. App Menu
  8. URLs
  9. Change History
  10. Related Guides

Overview

This guide walks a complete feature through the framework using a vendor master record in an accountpayable app. It shows the model, the CRUD views, the one-to-one detail inline, setup tables, navigation, and URLs — the same shape you should follow for any new entity.

flowchart LR
    MODEL[Define models] --> VIEW[Define CRUD views]
    VIEW --> INLINE[Define inline views]
    INLINE --> SETUP[Define setup views]
    SETUP --> MENU[Register menu in apps.py]
    MENU --> URL[Wire urls.py]
    URL --> MIG[Migrate]

The Data Model

VendorTable is a master record. VendorTableDetails extends it one-to-one. VendorGroup, VendorPostingGroup, VendorPriceGroup, and VendorBranch are setup/support tables.

erDiagram
    VendorTable ||--o| VendorTableDetails : "details"
    VendorTable }o--|| VendorGroup : "vendorgroup"
    VendorGroup }o--|| VendorPostingGroup : "vendorpostinggroup"
    VendorTable }o--|| Business : "optional organization profile"
    VendorTable }o--|| Person : "optional person profile"

Models

Master model

python
# accountpayable/models/vendortable.py
from django.db import models
from enezaframework.models import EnezaMaster
from enezacommon.models import VendorType


class VendorTable(EnezaMaster):
    vendorno = models.CharField(
        max_length=50,
        unique=True,
        verbose_name="Vendor No.",
    )
    searchname = models.CharField(
        max_length=255,
        verbose_name="Search Name",
    )
    vendortype = models.CharField(
        max_length=50,
        choices=VendorType.choices,
        verbose_name="Vendor Type",
    )
    vendorgroup = models.ForeignKey(
        "VendorGroup",
        on_delete=models.PROTECT,
        related_name="vendors",
        verbose_name="Vendor Group",
    )
    blocked = models.BooleanField(
        default=False,
        verbose_name="Blocked",
    )

    class Meta:
        verbose_name = "Vendor"
        verbose_name_plural = "Vendors"

    def __str__(self):
        return self.searchname

One-to-one detail model

python
# accountpayable/models/vendortabledetails.py
from django.db import models
from enezaframework.models import EnezaMasterDetails
from .vendortable import VendorTable


class VendorTableDetails(EnezaMasterDetails):
    vendortable = models.OneToOneField(
        VendorTable,
        on_delete=models.CASCADE,
        related_name="details",
        verbose_name="Vendor",
    )
    segment = models.CharField(
        max_length=100,
        null=True,
        blank=True,
        verbose_name="Segment",
    )

    class Meta:
        verbose_name = "Vendor Detail"
        verbose_name_plural = "Vendor Details"

    def __str__(self):
        return f"Details for {self.vendortable.searchname}"

Setup models

python
# accountpayable/models/vendorgroup.py
from django.db import models
from enezaframework.models import EnezaSetup


class VendorGroup(EnezaSetup):
    code = models.CharField(
        max_length=50,
        unique=True,
        verbose_name="Code",
    )
    description = models.CharField(
        max_length=255,
        verbose_name="Description",
    )
    vendorpostinggroup = models.ForeignKey(
        "VendorPostingGroup",
        on_delete=models.PROTECT,
        verbose_name="Vendor Posting Group",
    )

    class Meta:
        verbose_name = "Vendor Group"
        verbose_name_plural = "Vendor Groups"

    def __str__(self):
        return self.description

Apply the project field conventions from Model Field Conventions to every field.

Package re-exports

python
# accountpayable/models/__init__.py
from .vendortable import VendorTable
from .vendortabledetails import VendorTableDetails
from .vendorgroup import VendorGroup
from .vendorpostinggroup import VendorPostingGroup
from .vendorpricegroup import VendorPriceGroup
from .vendorbranch import VendorBranch

Views

python
# accountpayable/views/vendorviews.py
from enezaframework.views import (
    BaseListView,
    BaseDetailView,
    BaseCreateView,
    BaseDeleteView,
)
from enezaframework.utils import auto_url_register
from enezacommon.models.choices import VendorType
from accountpayable.models import VendorTable, VendorTableDetails


@auto_url_register
class VendorTableListView(BaseListView):
    model = VendorTable

    def set_list_fields(self):
        return ["vendorno", "searchname", "vendortype", "vendorgroup", "blocked"]

    def set_search_fields(self):
        return ["vendorno", "searchname"]


@auto_url_register
class VendorTableDetailView(BaseDetailView):
    model = VendorTable

    def detail_ui_view(self):
        return [
            {
                "name": "Basic Information",
                "icon": "Home",
                "disable_fields": ["vendorno", "blocked", "vendortype"],
                "Fieldcolumns": [
                    {"fields": ["vendorno", "searchname", "vendorgroup"]},
                    {"fields": ["vendortype", "blocked"]},
                ],
            },
        ]

    def get_inline_related_objects(self):
        return [
            {
                "name": "vendortabledetails_inline",
                "caption": "Vendor Table Details",
                "active": 1,
                "relation_type": "one_to_one",
                "model": "VendorTableDetails",
                "relation_name": "details",
                "position": 2,
                "icon": "Folder",
            },
        ]


@auto_url_register
class VendorTableCreateView(BaseCreateView):
    model = VendorTable

    def detail_ui_view(self):
        return [
            {
                "name": "Basic Information",
                "icon": "Home",
                "disable_fields": ["vendorno"],
                "Fieldcolumns": [
                    {"fields": ["searchname", "vendorgroup", "vendortype"]},
                    {"fields": ["blocked"]},
                ],
            },
        ]


@auto_url_register
class VendorTableDeleteView(BaseDeleteView):
    model = VendorTable

set_list_fields, set_search_fields, and detail_ui_view are methods the base views call, not class attributes. The older list_fields = [...] / Fieldtabs = [...] style is not read by the framework. See CRUD Base Views.

Conditional inlines

When a vendor is an organization or a person, show the matching profile inline. Resolve which object to load through get_related_object_id (see Field & Inline UI Configuration for the full method and signature caveat).

python
    def get_inline_related_objects(self):
        static_inlines = [
            {
                "name": "vendortabledetails_inline",
                "caption": "Vendor Table Details",
                "active": 1,
                "relation_type": "one_to_one",
                "model": "VendorTableDetails",
                "relation_name": "details",
                "position": 2,
                "icon": "Folder",
            },
        ]

        conditional_inlines = []
        if hasattr(self, "object"):
            if self.object.vendortype == VendorType.ORGANIZATION:
                conditional_inlines.append(
                    {
                        "name": "business_details_inline",
                        "caption": "Business Details",
                        "active": 1,
                        "relation_type": "one_to_one",
                        "model": "Business",
                        "relation_name": "business_content_type",
                        "position": 1,
                        "icon": "DocumentPlus",
                    }
                )
            elif self.object.vendortype == VendorType.PERSON:
                conditional_inlines.append(
                    {
                        "name": "person_details_inline",
                        "caption": "Personal Details",
                        "active": 1,
                        "relation_type": "one_to_one",
                        "model": "Person",
                        "relation_name": "person_content_type",
                        "position": 1,
                        "icon": "DocumentPlus",
                    }
                )

        return sorted(
            conditional_inlines + static_inlines,
            key=lambda item: item.get("position", 99),
        )

Inline Views

python
from enezaframework.views import BaseInlineOneToOneDetailView
from enezaframework.utils import auto_url_register
from accountpayable.models import VendorTable, VendorTableDetails
from enezacommon.models import Business, Person


@auto_url_register
class VendorTableDetailsInlineView(BaseInlineOneToOneDetailView):
    model = VendorTableDetails
    parent_model = VendorTable
    related_name = "details"

    def detail_ui_view(self):
        return [
            {
                "name": "Purchase demographics",
                "icon": "Home",
                "Fieldcolumns": [{"fields": ["segment"]}],
            },
        ]


@auto_url_register
class VendorBusinessView(BaseInlineOneToOneDetailView):
    model = Business
    parent_model = VendorTable
    related_name = "business_content_type"


@auto_url_register
class VendorPersonView(BaseInlineOneToOneDetailView):
    model = Person
    parent_model = VendorTable
    related_name = "person_content_type"

Keep each inline view's related_name aligned with the relation_name declared in the parent's get_inline_related_objects.


Setup Views

python
from enezaframework.views import (
    BaseSetupsListview,
    BaseSetupsDetailView,
    BaseSetupsCreateView,
    BaseSetupsDeleteView,
)
from enezaframework.utils import auto_url_register
from accountpayable.models import VendorGroup


@auto_url_register
class VendorGroupListView(BaseSetupsListview):
    model = VendorGroup

    def set_list_fields(self):
        return ["code", "description"]

    def set_search_fields(self):
        return ["code", "description"]


@auto_url_register
class VendorGroupDetailView(BaseSetupsDetailView):
    model = VendorGroup

    def detail_ui_view(self):
        return [
            {
                "name": "Basic Information",
                "icon": "Home",
                "Fieldcolumns": [{"fields": ["code"]}, {"fields": ["description"]}],
            },
            {
                "name": "Posting Group",
                "icon": "Home",
                "Fieldcolumns": [{"fields": ["vendorpostinggroup"]}],
            },
        ]


@auto_url_register
class VendorGroupCreateView(BaseSetupsCreateView):
    model = VendorGroup


@auto_url_register
class VendorGroupDeleteView(BaseSetupsDeleteView):
    model = VendorGroup

Repeat the pattern for VendorPostingGroup, VendorPriceGroup, and VendorBranch. Note the exact framework spelling BaseSetupsListview (lowercase v).


App Menu

python
# accountpayable/apps.py
from django.apps import AppConfig
from enezaframework.utils import load_icon

name = "accountpayable"


class AccountpayableConfig(AppConfig):
    default_auto_field = "django.db.models.BigAutoField"
    name = "accountpayable"

    def ready(self):
        import accountpayable.signals  # noqa: F401

    menus = [
        {
            "fasttab": "ap_masterdata",
            "name": "AP Master Data",
            "icon": load_icon("users_icon"),
            "menuitems": [
                {
                    "url": f"{name}:vendortable_list",
                    "name": "Vendors",
                    "icon": load_icon("vendor_icon"),
                },
            ],
        },
        {
            "fasttab": "ap_setup",
            "name": "AP Setup",
            "icon": load_icon("settings_icon"),
            "menuitems": [
                {
                    "url": f"{name}:vendorpostinggroup_list",
                    "name": "Vendor Posting Groups",
                    "icon": load_icon("posting_group_icon"),
                },
                {
                    "url": f"{name}:vendorgroup_list",
                    "name": "Vendor Groups",
                    "icon": load_icon("group_icon"),
                },
                {
                    "url": f"{name}:vendorpricegroup_list",
                    "name": "Vendor Price Groups",
                    "icon": load_icon("price_tag_icon"),
                },
                {
                    "url": f"{name}:vendorbranch_list",
                    "name": "Vendor Branches",
                    "icon": load_icon("branch_icon"),
                },
            ],
        },
    ]

URLs

python
# accountpayable/urls.py
from django.urls import path

from . import views
from enezaframework.utils import get_registered_urls

app_name = "accountpayable"

dynamic_urls = get_registered_urls(app_name)

urlpatterns = [
    path("dashboard", views.accountpayable_dashboard, name="accountpayable_dashboard"),
    *dynamic_urls,
]

Generated names for this example include accountpayable:vendortable_list, accountpayable:vendortable_detail, accountpayable:vendortable_create, accountpayable:vendortable_delete, and equivalent vendorgroup_* names. See URL Auto-Registration.

Finally generate and apply migrations:

bash
python manage.py makemigrations accountpayable
python manage.py migrate

The user is solely responsible for testing and validating the resulting feature; the developer/agent does not run the application.


Change History

Dated changes that affect this guide, taken from docs/Release note/CHANGELOG.md and the release notes. For the complete history, see the changelog.

Date Version What changed
2026-06-27 — Nested inline children support; disable_if/hide_if extended to parent/grandparent fields.
2026-06-30 0.2.8 Field-level full_width, column width: "full", display: "label", rich text, and button-group widgets added.
2026-07-02 0.2.11 disable_if accepts multiple conditions and multiple values.
2026-07-02 0.2.12 Fieldtab default_open added; inline column width/full_width fixed.
2026-07-05 0.2.13 Dynamic get_tab_groups() replaced hard-coded header/lines tabs; inline tab_group added.
2026-07-10 0.2.16 Optional url_name added so generated URL names can differ from model names.
2026-08-23 0.2.19 Boolean fields render as a Yes/No select; TomSelect auto-initializes after HTMX swaps.