1. The extension module
An extension module is not a separate species. It is an ordinary module, with a manifest, models, views and permissions. Two things set it apart: it depends on the one it extends, and it never imports it.
The manifest
modules/pipeline_rivals/module.json:
{ "name": "pipeline_rivals", "depends": ["core", "pipeline"], "summary": "Competitor tracking on opportunities: who else was bidding, and who won.", "version": "0.1.0", "category": "sales"}depends does two things, and the second is the one that matters. It orders the loading,
so pipeline_lead already exists when our module speaks. And it declares the relation:
uninstalling pipeline while pipeline_rivals is installed is no longer possible by
accident.
The name says what it is: <extended module>_<what you add>. A reader seeing
pipeline_rivals in a module list knows who it is talking about without opening the folder.
The entry point
"""``pipeline_rivals`` -- competitor tracking, added to ``pipeline``.
An extension module. It adds two fields to ``pipeline_lead``, ships its ownmodel, layers one field onto an existing screen, and listens to the kernel'sevents rather than calling into the module it extends.
Nothing here imports ``modules.pipeline``, and uninstalling this module leaves``pipeline`` exactly as it was."""
from __future__ import annotations
from pathlib import Pathfrom typing import TYPE_CHECKING
from kewos.orm import apply_module_aclsfrom kewos.views import register_views_from_dir
from . import listen, models
if TYPE_CHECKING: from kewos.orm.registry import Registry
_MODULE_DIR = Path(__file__).parent
def register(registry: Registry) -> None: """Declare the extension: our model, the added fields, rights, screens.""" models.register_models(registry) apply_module_acls(registry, _MODULE_DIR / "acl.json") # Reads our own screens AND the `*.patch.json` files that layer two fields # onto `pipeline`'s screens without replacing them. register_views_from_dir(_MODULE_DIR / "views") listen.register_listeners()Nothing unusual. The interesting line is the one that is not there: no
import modules.pipeline, and there never will be.
Our own model
Extending a module does not mean writing into its tables. The competitor is our notion, so it lives in our model:
TIERS = [ ("direct", "Direct"), ("adjacent", "Adjacent"), ("inhouse", "In-house build"), ("nothing", "Doing nothing"),]
def register_competitor(registry: Registry) -> None: """Declare ``rivals_competitor``.""" registry.register_model( "rivals_competitor", module="pipeline_rivals", fields=[ CharField(name="name", nullable=False, max_length=120, unique=True), # "Doing nothing" is a competitor. Leaving it out of the list is how # a pipeline review ends up explaining every loss by price. SelectionField(name="tier", choices=TIERS, default="direct"), CharField(name="notes", nullable=True), ], )The table prefix follows the module, not the extended one: rivals_competitor, not
pipeline_competitor. What is ours carries our name, which is what makes an uninstall
readable.

Next
The module exists and has its model. What remains is touching somebody else’s: Adding fields.