Content features

A content feature is a real domain feature that owns its own model and table — events, team, projects, tools — and renders public pages from it.

Table of contents

What is a content feature?

A content feature is a full archetype (see Archetypes) whose job is to own one content type and surface it on the public site. It bundles everything for that type: a model and table, a repository and service, public routes that render themed templates, an admin resource for back-office CRUD, and a seeder.

The canonical example is splent_feature_events. The rest of this page walks through it file by file.

One content type, one feature

The most important rule: each content type is its own feature. Events, team members, projects and tools are four separate features (splent_feature_events, splent_feature_team, …), each with its own table.

SPLENT deliberately does not model content as a single generic “collection” or WordPress-style custom-post-type table where everything shares one polymorphic row. Instead:

  • Each type has a typed model (Event, with speaker, room, starts_at …) — no meta blob, no string keys.
  • A type is shared across products by installing the feature into each product that needs it, not by copying rows between databases.
  • You compose a site by selecting features, the same way you select any other capability in the SPL.

This keeps every content type strongly typed, independently versioned, and independently installable.


The model

models.py defines a normal SQLAlchemy model on splent_framework.db.db. The columns are the real fields of the domain, not a generic key/value bag.

from datetime import datetime  # noqa: F401 — used by seeders/migrations

from splent_framework.db import db


class Event(db.Model):
    """An event/activity: a talk, workshop, competition or ceremony."""

    __tablename__ = "event"

    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String(255), nullable=False)
    slug = db.Column(db.String(255), nullable=False, unique=True, index=True)
    kind = db.Column(db.String(64), default="talk")  # talk|workshop|competition|ceremony
    summary = db.Column(db.Text, default="")
    description = db.Column(db.Text, default="")      # rich text / HTML
    speaker = db.Column(db.String(255), default="")
    room = db.Column(db.String(128), default="")
    starts_at = db.Column(db.DateTime)
    ends_at = db.Column(db.DateTime)
    image = db.Column(db.String(512), default="")
    link = db.Column(db.String(512), default="")
    published = db.Column(db.Boolean, default=True)
    order = db.Column(db.Integer, default=0)

    def __repr__(self):
        return f"Event<{self.slug}>"

A few conventions worth copying:

  • A unique, indexed slug drives clean public URLs (/events/<slug>).
  • A published flag lets editors hide drafts from the public site.
  • An order column gives editors manual control over listing order.

The repository

repositories.py extends BaseRepository and adds the queries this content type needs.

from __future__ import annotations

from splent_io.splent_feature_events.models import Event
from splent_framework.repositories.BaseRepository import BaseRepository


class EventsRepository(BaseRepository):
    def __init__(self):
        super().__init__(Event)

    def list_published(self) -> list[Event]:
        return (
            Event.query.filter_by(published=True)
            .order_by(Event.order.asc(), Event.starts_at.asc())
            .all()
        )

    def get_by_slug(self, slug: str) -> Event | None:
        return Event.query.filter_by(slug=slug).first()

Always start the file with from __future__ import annotations. BaseRepository defines a method named list(). Inside a class that inherits it, the bare name list resolves to that method, not the built-in — so an annotation like -> list[Event] would try to subscript the method and blow up. With from __future__ import annotations, all annotations become strings (PEP 563) and are never evaluated at definition time, so list[Event] is safe. Add this import to every repository.


The service

services.py extends BaseService and is the only layer routes are allowed to talk to. It delegates to the repository and is what gets registered in the service locator.

from splent_io.splent_feature_events.repositories import EventsRepository
from splent_framework.services.BaseService import BaseService


class EventsService(BaseService):
    def __init__(self):
        super().__init__(EventsRepository())

    def list_published(self):
        return self.repository.list_published()

    def get_by_slug(self, slug: str):
        return self.repository.get_by_slug(slug)

Public routes

A content feature is public-facing: its routes render templates that extend the theme’s public_base.html. They are resolved through the service locator with service_proxy, never by importing the service class directly.

from flask import abort, render_template

from splent_io.splent_feature_events import events_bp
from splent_framework.services.service_locator import service_proxy

events_service = service_proxy("EventsService")


@events_bp.route("/events", methods=["GET"])
def index():
    events = events_service.list_published()
    return render_template("events/list.html", events=events)


@events_bp.route("/events/<slug>", methods=["GET"])
def detail(slug):
    event = events_service.get_by_slug(slug)
    if event is None:
        abort(404)
    return render_template("events/detail.html", event=event)

The typical pair of routes is a list (/events) and a detail (/events/<slug>).


Templates extend the theme

Templates live under templates/<type>/ and {% extends "public_base.html" %} — the base layout provided by the theme feature. This is why a content feature requires theme (see the contract below): it relies on the theme’s blocks (title, hero, content), the render_block() helper, and the _() translation helper.

templates/events/list.html:

{% extends "public_base.html" %}

{% block title %}Events — {{ SPLENT_APP }}{% endblock %}

{% block hero %}
    {{ render_block('hero', eyebrow=_('Programme'), title=_('Events'),
                    subtitle=_('Talks, workshops and competitions.')) }}
{% endblock %}

{% block content %}
<div class="container section">
    <div class="card-grid">
        {% for e in events %}
        <a class="card" href="{{ url_for('events.detail', slug=e.slug) }}">
            {% if e.image %}<img class="card__img" src="{{ e.image }}" alt="">{% endif %}
            <span class="badge">{{ e.kind }}</span>
            <h3 class="card__title">{{ e.title }}</h3>
            {% if e.summary %}<p class="card__text">{{ e.summary }}</p>{% endif %}
        </a>
        {% else %}
        <p class="card__text">{{ _('No events yet.') }}</p>
        {% endfor %}
    </div>
</div>
{% endblock %}

templates/events/detail.html does the same for a single record, using the theme’s hero block and a prose container for the rich-text description:

{% extends "public_base.html" %}

{% block hero %}
    {{ render_block('hero', eyebrow=event.kind, title=event.title, subtitle=event.summary) }}
{% endblock %}

{% block content %}
<article class="container section">
    <div class="prose">{{ event.description | safe }}</div>
    <p><a href="{{ url_for('events.index') }}">{{ _('Back to events') }}</a></p>
</article>
{% endblock %}

Because the page styling comes from the theme, the same content feature looks native in any product that installs it — the product’s theme decides the visual identity.


Registering the admin resource

Public routes are read-only. Editors manage content through the admin panel (the wp-admin-style back-office). A content feature surfaces its model there by calling register_admin_resource in init_feature.

__init__.py:

from splent_framework.admin import register_admin_resource
from splent_framework.blueprints.base_blueprint import create_blueprint
from splent_framework.services.service_locator import register_service

from splent_io.splent_feature_events.models import Event
from splent_io.splent_feature_events.services import EventsService

events_bp = create_blueprint(__name__)


def init_feature(app):
    register_service(app, "EventsService", EventsService)

    # Surface Event in the admin panel (the wp-admin-style back-office).
    register_admin_resource(
        Event,
        name="event",
        label="Event",
        label_plural="Events",
        icon="calendar",
        group="Programme",
        order=10,
        list_columns=["title", "kind", "room", "starts_at"],
        field_widgets={
            "summary": "textarea",
            "description": "richtext",
            "image": "image",
            "link": "url",
            "kind": "select",
            "slug": "slug",
            "starts_at": "datetime",
            "ends_at": "datetime",
            "published": "bool",
        },
        feature="events",
    )

What the registration controls:

  • name / label / label_plural / icon / group / order — how the resource appears in the admin navigation.
  • list_columns — the columns shown in the admin list view.
  • field_widgets — which editor widget renders each field (richtext for HTML, slug, datetime, bool, image, …) so editors get the right input per column.

init_feature is also where the service is bound into the locator with register_service(app, "EventsService", EventsService), which is what makes service_proxy("EventsService") resolve.


Seeders

A BaseSeeder ships demo content so a freshly derived product has something to show immediately. The seeder constructs real model instances and hands them to self.seed().

seeders.py:

from datetime import datetime

from splent_framework.seeders.BaseSeeder import BaseSeeder

from splent_io.splent_feature_events.models import Event


class EventsSeeder(BaseSeeder):
    def run(self):
        self.seed(
            [
                Event(
                    slug="opening-ceremony", title="Opening Ceremony", kind="ceremony",
                    room="Salón de Actos", order=1, starts_at=datetime(2025, 11, 4, 9, 0),
                    summary="Kick-off of InnoSoft Days XIII at the ETSII.",
                    description="<p>Welcome to three days of talks, workshops, "
                                "competitions and fun.</p>",
                ),
                Event(
                    slug="ai-testing", title="New Ways to Test Software using AI", kind="talk",
                    room="Aula 0.1", order=2, starts_at=datetime(2025, 11, 4, 10, 0),
                    speaker="Andreas Zeller", link="https://andreas-zeller.info",
                    summary="Keynote on AI-assisted software testing.",
                ),
            ]
        )

The pyproject contract

The feature contract in pyproject.toml is what makes a content feature composable and validated by feature:check and product:derive. A content feature provides its routes, blueprint and model, and requires the theme feature it renders against.

[tool.splent.contract]
description = "Events — talks, workshops, competitions and ceremonies (public list/detail + admin CRUD)"

[tool.splent.contract.provides]
routes = ["/events", "/events/<slug>"]
blueprints = ["events_bp"]
models = ["Event"]
commands = []

[tool.splent.contract.requires]
features = ["theme"]
env_vars = []

[tool.splent.contract.extensible]
services = []
templates = ["events/list.html", "events/detail.html"]
models = []
hooks = []
routes = false

Key points:

  • provides.routes and provides.models declare the public surface and the Event table the feature introduces.
  • requires.features = ["theme"] is the formal version of “templates extend public_base.html.” Deriving a product that selects events without theme fails validation.
  • extensible.templates lists the templates other features may refine, so a product can override the look of events/list.html without forking the feature.

Checklist

To build a new content feature (say, team), copy the events shape:

  1. Model — a typed Team model with its own __tablename__, a unique indexed slug, published and order.
  2. Repository — extends BaseRepository; start the file with from __future__ import annotations; add list_published() / get_by_slug().
  3. Service — extends BaseService, delegates to the repository.
  4. Routes — public index + detail, resolved via service_proxy, rendering templates.
  5. Templates — under templates/team/, extending public_base.html.
  6. init_featureregister_service(...) and register_admin_resource(Team, ...).
  7. Seeder — a BaseSeeder with a few real records.
  8. Contractprovides the routes/blueprint/model, requires = ["theme"].

See also


Back to top

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