Content features

A content feature owns one content type, such as events, team members, projects or tools, with its own model and table, and renders public pages and admin screens for it.

Table of contents

What is a content feature?

A content feature is a full archetype. It bundles everything for its type, a model and table, a repository and a service, public routes with themed templates, admin screens of its own, and a seeder. The canonical example is splent_feature_events, and this page walks through it file by file.

The layers of the events content feature Model Event its own table Repository EventsRepository the queries Service EventsService in the locator Public routes /events /events/<slug> Admin routes /admin/events/... behind login extends public_base.html from the theme extends base_template.html the admin shell hooks.py links the admin screens from the sidebar, and seeders.py ships demo content.
Routes talk only to the service, and the templates take their look from the product's theme, so the feature looks native in any product.

One content type, one feature

Each content type is its own feature. Events, team members, projects and tools are four features (splent_feature_events, splent_feature_team, …), each with its own table, never one generic collection or custom-post-type table.

  • Each type has a typed model (Event, with speaker, room, starts_at …), with no meta blob and no string keys.
  • Products share a type by installing the feature, not by copying rows between databases.
  • You compose a site by selecting features, like any other capability of the SPL.

Every content type stays typed, versioned on its own and installable on its own.


The model

models.py defines a plain SQLAlchemy model on splent_framework.db.db, with the real fields of the domain as columns.

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}>"

Three conventions are worth copying.

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

The repository

repositories.py extends BaseRepository with the queries the 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()

Start every repository with from __future__ import annotations. BaseRepository defines a method named list(). Inside a subclass the bare name list is that method, not the built-in, so an annotation like -> list[Event] fails when the class is defined. With the import, annotations stay strings (PEP 563) and are never evaluated.


The service

services.py extends BaseService, delegates to the repository, and is the only layer routes talk to. It is what the feature registers 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

Public routes render templates that extend the theme’s public_base.html, and reach the service through service_proxy, never by importing the class.

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 usual pair is a list (/events) and a detail (/events/<slug>).


Templates extend the theme

Templates live under templates/<type>/ and {% extends "public_base.html" %}, the layout of the theme feature. They use the theme’s blocks (title, hero, content), the render_block() helper and _() for translations. The contract does not record this dependency on the theme, as explained below.

Here is 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 one record, with the theme’s hero block and a prose container for the rich-text description. The description is stored HTML, so it goes through the rich_text filter, which cuts it down to an allowlist, and never through | safe, which would run any script stored in it.

{% 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 | rich_text }}</div>
    <p><a href="{{ url_for('events.index') }}">← {{ _('Back to events') }}</a></p>
</article>
{% endblock %}

The styling comes from the theme, so the same feature looks native in any product that installs it.


Admin screens of its own

Public routes are read-only, and editors work in the admin panel. The shipped content features (events, team, projects, tools, about) ship their own admin screens under /admin/<type>, the WordPress-plugin pattern, and link them through the layout.authenticated_sidebar hook. None of them uses the admin resource registry.

Here is __init__.py, trimmed to what every content feature has.

from splent_framework.blueprints.base_blueprint import create_blueprint
from splent_framework.nav.nav_registry import register_nav_item
from splent_framework.services.service_locator import register_service

from splent_io.splent_feature_events.services import EventsService

events_bp = create_blueprint(__name__)


def init_feature(app):
    from splent_framework.assets.asset_registry import register_asset

    # Events are managed through their OWN custom admin screens (routes.py
    # and hooks.py), the WordPress-plugin pattern, instead of the generic
    # admin resource, so it does not call register_admin_resource.
    register_service(app, "EventsService", EventsService)
    label = app.config.get("EVENTS_NAV_LABEL", "") or "Events"
    register_nav_item(key="events", label=label, href="/events", order=10)
    # Public event-detail stylesheet (token-driven; styled by the active skin).
    register_asset(
        "css", "events.assets", order=100, subfolder="css", filename="events.css"
    )

register_service is what makes service_proxy("EventsService") resolve. init_feature also registers the public nav entry and the feature’s stylesheet, and the real file calls register_settings to put two editorial settings in the admin panel (see Feature settings).

The admin screens are plain routes in routes.py, behind @login_required, rendering templates under templates/events/admin/ that extend base_template.html, the product’s admin shell. Here are the list and create screens.

@events_bp.route("/admin/events", methods=["GET"])
@login_required
def admin_index():
    return render_template(
        "events/admin/list.html",
        groups=_ordered_groups(),
        known_groups=_known_groups(),
    )


@events_bp.route("/admin/events/new", methods=["GET", "POST"])
@login_required
def admin_new():
    if request.method == "POST":
        data = _form_to_data(request.form)
        if not data["title"]:
            flash("Title is required.", "danger")
            return redirect(url_for("events.admin_new"))
        data["slug"] = _unique_slug(data["title"])
        db.session.add(Event(**data))
        db.session.commit()
        flash(f"Added {data['title']}.", "success")
        return redirect(url_for("events.admin_index"))
    return render_template(
        "events/admin/form.html", event=None, known_groups=_known_groups()
    )

/admin/events/<int:event_id>/edit, /move and /delete complete the set. _form_to_data builds the saved data from Event.__table__.columns, so a column a refiner adds to the model is saved by the same route. That is how editions gets its edition selector saved, after putting it in the form through the events.admin.form.fields slot, which admin/form.html renders with hook(event).

The link in the admin sidebar comes from hooks.py.

def events_admin_link():
    """Sidebar entry for the Events management screen (the WP-plugin pattern)."""
    active = (
        "active"
        if request.endpoint and request.endpoint.startswith("events.admin")
        else ""
    )
    return (
        f'<li class="sidebar-item {active}">'
        f'<a class="sidebar-link" href="{url_for("events.admin_index")}">'
        '<i class="align-middle" data-feather="calendar"></i> '
        '<span class="align-middle">Events</span>'
        "</a>"
        "</li>"
    )


register_template_hook("layout.authenticated_sidebar", events_admin_link)

Seeders

A BaseSeeder ships demo content, so a freshly derived product has something to show. It builds real model instances and hands them to self.seed().

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 contract makes a content feature composable. splent feature:contract --write generates it from the source, so it records what the code does, and check:contracts, product:validate and the product:derive pre-flight check it. This is the contract of events.

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

[tool.splent.contract.provides]
routes     = ["/admin/events", "/admin/events/<int:event_id>/delete", "/admin/events/<int:event_id>/edit", "/admin/events/<int:event_id>/move", "/admin/events/new", "/events", "/events/<slug>"]
blueprints = ["events_bp"]
models     = ["Event"]
commands   = ["hello"]
hooks      = ["home.section", "layout.authenticated_sidebar"]
services   = ["EventsService"]
signals    = []
translations = ["en", "es"]
docker     = []

[tool.splent.contract.requires]
features = ["media"]
features_optional = ["team"]
env_vars = ["EVENTS_NAV_LABEL", "EVENTS_SHOW_PAST"]
signals  = []

[tool.splent.contract.extensible]
services  = ["EventsService"]
templates = ["events/admin/form.html", "events/admin/list.html", "events/detail.html", "events/hooks/home_section.html", "events/index.html", "events/list.html"]
models    = ["Event"]
hooks     = ["events.admin.form.fields", "events.admin.list.actions", "events.admin.list.event_meta", "events.detail.before_meta", "events.list.content", "events.list.hero", "events.list.title", "home.section", "layout.authenticated_sidebar"]
routes    = true
  • provides.routes and provides.models are the public and admin surface and the Event table.
  • requires.features = ["media"] comes from the seeder, which imports MediaService to store the seed posters. features_optional = ["team"] comes from a service_proxy("TeamService") call wrapped in try, which resolves a speaker to a team member when team is installed.
  • Extending public_base.html is invisible to the generator, which reads imports and service_proxy calls, not template inheritance, so nothing here names theme. A dependency that must be enforced and that static analysis cannot find goes in requires.features_manual, which survives regeneration (team declares media there).
  • extensible.templates lists the templates a refiner may override, and extensible.hooks the slots the feature opens or fills. editions fills the seven slots events opens on its pages, without overriding any template. See Refine, extend or compose.

Checklist

To build a new content feature, team say, copy the shape of events.

  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. Admin list, new, edit and delete under /admin/team, behind @login_required.
  5. Templates. Public ones under templates/team/, extending public_base.html. Admin ones under templates/team/admin/, extending base_template.html.
  6. init_feature and hooks.py. Call register_service(...) and register_nav_item(...), and register a layout.authenticated_sidebar hook for the admin link.
  7. Seeder. A BaseSeeder with a few real records.
  8. Contract. Run splent feature:contract --write and read what it inferred. Put in requires.features_manual anything static analysis cannot see.

See also


Back to top

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