End-to-End CRUD Example (VendorTable)
Developer guide for the Enezaframework. Part of the architecture series.
Table of Contents
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
# 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
# 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
# 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 Base Models & Model Types to every field.
Package re-exports
# 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
# 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):
list_fields = ["vendorno", "searchname", "vendortype", "vendorgroup", "blocked"]
return list_fields
def set_search_fields(self):
search_fields = ["vendorno", "searchname"]
return search_fields
@auto_url_register
class VendorTableDetailView(BaseDetailView):
model = VendorTable
def detail_ui_view(self):
Fieldtabs = [
{
"name": "Basic Information",
"icon": "Home",
"disable_fields": ["vendorno", "blocked", "vendortype"],
"Fieldcolumns": [
{"fields": ["vendorno", "searchname", "vendorgroup"]},
{"fields": ["vendortype", "blocked"]},
],
},
]
return Fieldtabs
def get_inline_related_objects(self):
inline_related_object = [
{
"name": "vendortabledetails_inline",
"caption": "Vendor Table Details",
"active": 1,
"relation_type": "one_to_one",
"model": "VendorTableDetails",
"relation_name": "details",
"position": 2,
"icon": "Folder",
},
]
return inline_related_object
@auto_url_register
class VendorTableCreateView(BaseCreateView):
model = VendorTable
def detail_ui_view(self):
Fieldtabs = [
{
"name": "Basic Information",
"icon": "Home",
"disable_fields": ["vendorno"],
"Fieldcolumns": [
{"fields": ["searchname", "vendorgroup", "vendortype"]},
{"fields": ["blocked"]},
],
},
]
return Fieldtabs
@auto_url_register
class VendorTableDeleteView(BaseDeleteView):
model = VendorTable
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).
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
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):
Fieldtabs = [
{
"name": "Purchase demographics",
"icon": "Home",
"Fieldcolumns": [{"fields": ["segment"]}],
},
]
return Fieldtabs
@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
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):
Fieldtabs = [
{
"name": "Basic Information",
"icon": "Home",
"Fieldcolumns": [{"fields": ["code"]}, {"fields": ["description"]}],
},
{
"name": "Posting Group",
"icon": "Home",
"Fieldcolumns": [{"fields": ["vendorpostinggroup"]}],
},
]
return Fieldtabs
@auto_url_register
class VendorGroupCreateView(BaseSetupsCreateView):
model = VendorGroup
@auto_url_register
class VendorGroupDeleteView(BaseSetupsDeleteView):
model = VendorGroup
Repeat the pattern for VendorPostingGroup, VendorPriceGroup, and VendorBranch.
App Menu
# accountpayable/apps.py
from django.apps import AppConfig
from enezaframework.utils import load_icon
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
# 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:
python manage.py makemigrations accountpayable
python manage.py migrate