Refinement

A refinement feature modifies another feature (overriding services, templates, hooks, extending models, or adding routes) without forking.

Table of contents

How it works

A refinement feature declares [tool.splent.refinement] in its pyproject.toml, specifying exactly what it overrides, extends, or adds. The framework applies these changes at startup in UVL order.


Real example of notes_tags refining notes

splent_feature_notes_tags adds a tags column to Notes and overrides NotesService.

# splent_feature_notes_tags/pyproject.toml
[tool.splent.refinement]
refines = "splent_feature_notes"

[tool.splent.refinement.extends]
models = [{ target = "Notes", mixin = "NotesTagsMixin" }]

[tool.splent.refinement.overrides]
services = [{ target = "NotesService", replacement = "NotesServiceWithTags" }]

The mixin

# splent_feature_notes_tags/models.py
from splent_framework.db import db

class NotesTagsMixin:
    tags = db.Column(db.String(500), nullable=True, default="")

    def get_tags_list(self):
        if not self.tags:
            return []
        return [t.strip() for t in self.tags.split(",") if t.strip()]

    def has_tag(self, tag):
        return tag.lower() in [t.lower() for t in self.get_tags_list()]

At startup, FeatureIntegrator._apply_model_extensions() injects tags, get_tags_list(), and has_tag() into the Notes model. No changes to splent_feature_notes needed.

The service override

# splent_feature_notes_tags/__init__.py
from splent_framework.refinement import refine_model, refine_service
from .models import NotesTagsMixin
from .services import NotesServiceWithTags

def init_feature(app):
    refine_model("Notes", NotesTagsMixin)
    refine_service(app, "NotesService", NotesServiceWithTags)

Routes using service_proxy("NotesService") automatically get the merged class.


Complete example of auth_2fa refining auth

# splent_feature_auth_2fa/pyproject.toml
[tool.splent.refinement]
refines = "splent_feature_auth"

[tool.splent.refinement.overrides]
services  = [{ target = "AuthenticationService", replacement = "AuthenticationService2FA" }]
templates = [{ target = "auth/login_form.html" }]
hooks     = [{ target = "layout.navbar.authenticated" }]

[tool.splent.refinement.extends]
models = [{ target = "User", mixin = "User2FAMixin" }]
routes = [{ blueprint = "auth", module = "routes_2fa" }]

Override service

def init_feature(app):
    refine_model("User", User2FAMixin)
    refine_service(app, "AuthenticationService", AuthenticationService2FA)

refine_service builds a merged class inheriting from both, so super() works.

Override template

Place the replacement at the same path.

splent_feature_auth_2fa/templates/auth/login_form.html

Flask searches blueprints in reverse registration order. The refiner registers after the base, so its template wins.

Extend model

# splent_feature_auth_2fa/models.py
class User2FAMixin:
    totp_secret = db.Column(db.String(32), nullable=True)
    totp_enabled = db.Column(db.Boolean, default=False)

    def verify_totp(self, token):
        import pyotp
        return pyotp.TOTP(self.totp_secret).verify(token)

Migrations for extended models

The refiner owns its own migrations with manual ALTER TABLE operations.

# migrations/versions/001_add_totp.py
def upgrade():
    op.add_column("user", sa.Column("totp_secret", sa.String(32), nullable=True))
    op.add_column("user", sa.Column("totp_enabled", sa.Boolean, default=False))

With FEATURE_TABLES = set() in env.py, autogenerate ignores shared tables.

UVL constraint

splent_feature_auth_2fa => splent_feature_auth

The refiner always loads after the base.


Shipped example of editions refining events

splent_feature_editions is a full content feature (its own Edition model, public archive at /editions, admin screen) that ALSO refines splent_feature_events, so a recurring event gets a memory without the events feature knowing about editions.

[tool.splent.refinement]
refines = "splent_feature_events"

[tool.splent.refinement.extends]
models = [{ target = "Event", mixin = "EventEditionMixin" }]

[tool.splent.refinement.overrides]
templates = [
    { target = "events/list.html" },        # programme of the current edition, by day
    { target = "events/detail.html" },      # the edition the event belongs to
    { target = "events/admin/list.html" },  # edition badge per event
    { target = "events/admin/form.html" },  # edition selector
]

Three details worth copying:

  • The mixin adds a nullable FK (edition_id, ON DELETE SET NULL). A product with events and no editions never sees the column; a product that removes an edition keeps its events.
  • The base admin form maps by model columns. events builds the saved data by iterating Event.__table__.columns, so the selector the override adds to the form is persisted with no route change in the refiner (projects does the same for research_projects).
  • Templates read a lazy context object. inject_context_vars returns {"editions": EditionsContext()}; the overridden events templates ask it for the current edition, its programme grouped by day or the edition of an event, and each answer costs a query only when a template asks.

The hand-written migration creates edition and alters event, because autogenerate for the refiner only sees its own table.


See also


Back to top

splent. Distributed by an LGPL license v3. Contact us: drorganvidez@us.es