Create a feature

Build splent_feature_notes from scratch, a note-taking feature with models, services, routes, templates, hooks, and UVL integration.


Table of contents

  1. Prerequisites
  2. Step 1. Scaffold the feature
  3. Step 2. Define the model
  4. Step 3. Add a custom query to the service
  5. Step 4. Define the routes
  6. Step 5. Create the templates
  7. Step 6. Register hooks
  8. Step 7. Integrate into the SPL
  9. Step 8. Add the feature to the product
  10. Step 9. Generate and apply the migration
  11. Step 10. Verify in the browser
  12. Step 11. Inspect the composition
  13. Step 12. View the feature contract
  14. What you learned
  15. Next

Prerequisites

You completed Tutorial 2 and have my_first_app running with auth, public, theme, settings, redis, session_redis and profile.


Step 1. Scaffold the feature

Deselect the product first. feature:create works either way and always scaffolds at the workspace root, but spl:add-feature in Step 7 refuses to run with a product selected, so the tutorial stays detached until then.

splent product:deselect

Then create the feature.

splent feature:create splent-io/splent_feature_notes
  ✅ Feature 'splent-io/splent_feature_notes' created (full)
     /workspace/splent_feature_notes
     git  a repository on main, the scaffold its first commit (<hex>)

The feature is also a git repository, with the scaffold as its first commit on main. The scaffold at /workspace/splent_feature_notes/ looks like this.

splent_feature_notes/
├── pyproject.toml             # Package metadata + the feature contract
├── README.md                  # What the feature is, how to use, test and release it
└── src/
    └── splent_io/
        └── splent_feature_notes/
            ├── __init__.py        # Blueprint + init_feature()
            ├── models.py          # SQLAlchemy models
            ├── repositories.py    # Data access layer
            ├── services.py        # Business logic
            ├── routes.py          # URL endpoints
            ├── hooks.py           # Template hook registrations
            ├── seeders.py         # Test data seeders
            ├── forms.py           # WTForms definitions
            ├── commands.py        # CLI commands (a sample `hello`)
            ├── config.py          # Env vars read into app.config
            ├── signals.py         # Signals emitted / listened to
            ├── templates/         # Jinja2 templates
            ├── assets/            # JS (scripts.js and webpack.config.js)
            ├── translations/      # i18n catalogs
            ├── migrations/        # Alembic migrations
            │   └── env.py
            └── tests/
                ├── conftest.py
                ├── unit/
                ├── integration/
                ├── functional/
                ├── e2e/
                └── load/

Every file has working boilerplate. The next steps fill in the real logic.


Step 2. Define the model

Edit splent_feature_notes/src/splent_io/splent_feature_notes/models.py.

from datetime import datetime

import pytz
from splent_framework.db import db


class Notes(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
    title = db.Column(db.String(200), nullable=False)
    content = db.Column(db.Text, nullable=True)
    created_at = db.Column(db.DateTime, default=lambda: datetime.now(pytz.utc))

    user = db.relationship("User", backref=db.backref("notes", lazy=True))

    def __repr__(self):
        return f"<Notes {self.title}>"

The foreign key user.id references the User model of splent_feature_auth. The UVL constraint you add in Step 7 makes sure auth is migrated before notes.


Step 3. Add a custom query to the service

The scaffold’s NotesService inherits CRUD from BaseService. Add one query that filters notes by user to splent_feature_notes/src/splent_io/splent_feature_notes/services.py.

from splent_io.splent_feature_notes.repositories import NotesRepository
from splent_framework.services.BaseService import BaseService


class NotesService(BaseService):
    def __init__(self):
        super().__init__(NotesRepository())

    def get_by_user(self, user_id):
        return self.repository.get_by_column("user_id", user_id)

BaseService already provides create(), get_by_id(), update(), delete() and count(). The routes use get_by_user() to show the logged-in user’s notes.


Step 4. Define the routes

Edit splent_feature_notes/src/splent_io/splent_feature_notes/routes.py.

from flask import render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user

from splent_io.splent_feature_notes import notes_bp
from splent_io.splent_feature_notes.services import NotesService

notes_service = NotesService()


@notes_bp.route("/notes/", methods=["GET"])
@login_required
def index():
    notes = notes_service.get_by_user(current_user.id)
    return render_template("notes/index.html", notes=notes)


@notes_bp.route("/notes/create", methods=["GET", "POST"])
@login_required
def create():
    if request.method == "POST":
        title = request.form.get("title", "").strip()
        content = request.form.get("content", "").strip()

        if not title:
            flash("Title is required.", "danger")
            return render_template("notes/create.html")

        notes_service.create(
            user_id=current_user.id,
            title=title,
            content=content,
        )
        flash("Note created.", "success")
        return redirect(url_for("notes.index"))

    return render_template("notes/create.html")


@notes_bp.route("/notes/<int:note_id>/delete", methods=["POST"])
@login_required
def delete(note_id):
    note = notes_service.get_or_404(note_id)
    if note.user_id != current_user.id:
        flash("Access denied.", "danger")
        return redirect(url_for("notes.index"))

    notes_service.delete(note_id)
    flash("Note deleted.", "success")
    return redirect(url_for("notes.index"))

Step 5. Create the templates

Create splent_feature_notes/src/splent_io/splent_feature_notes/templates/notes/index.html.

{% extends "base_template.html" %}

{% block title %}My Notes{% endblock %}

{% block content %}
<div class="container">
    <div class="d-flex justify-content-between align-items-center mb-4">
        <h1 class="h2">My Notes</h1>
        <a href="{{ url_for('notes.create') }}" class="btn btn-primary">New Note</a>
    </div>

    {% with messages = get_flashed_messages(with_categories=true) %}
        {% for category, message in messages %}
            <div class="alert alert-{{ category }}">{{ message }}</div>
        {% endfor %}
    {% endwith %}

    {% if notes %}
        <div class="list-group">
            {% for note in notes %}
            <div class="list-group-item">
                <div class="d-flex justify-content-between align-items-start">
                    <div>
                        <h5 class="mb-1">{{ note.title }}</h5>
                        <p class="mb-1 text-muted">{{ note.content or "No content" }}</p>
                        <small class="text-muted">{{ note.created_at.strftime('%Y-%m-%d %H:%M') }}</small>
                    </div>
                    <form method="POST" action="{{ url_for('notes.delete', note_id=note.id) }}">
                        <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
                        <button type="submit" class="btn btn-sm btn-outline-danger">Delete</button>
                    </form>
                </div>
            </div>
            {% endfor %}
        </div>
    {% else %}
        <p class="text-muted">No notes yet. Create your first one.</p>
    {% endif %}
</div>
{% endblock %}

{% block scripts %}
{% endblock %}

Create splent_feature_notes/src/splent_io/splent_feature_notes/templates/notes/create.html.

{% extends "base_template.html" %}

{% block title %}New Note{% endblock %}

{% block content %}
<div class="container">
    <h1 class="h2 mb-3">New Note</h1>

    {% with messages = get_flashed_messages(with_categories=true) %}
        {% if messages %}
            {% for category, message in messages %}
                <div class="alert alert-{{ category }}">{{ message }}</div>
            {% endfor %}
        {% endif %}
    {% endwith %}

    <form method="POST" action="{{ url_for('notes.create') }}">
        <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
        <div class="mb-3">
            <label for="title" class="form-label">Title</label>
            <input type="text" class="form-control" id="title" name="title" required>
        </div>
        <div class="mb-3">
            <label for="content" class="form-label">Content</label>
            <textarea class="form-control" id="content" name="content" rows="5"></textarea>
        </div>
        <a href="{{ url_for('notes.index') }}" class="btn btn-secondary me-2">Cancel</a>
        <button type="submit" class="btn btn-primary">Save</button>
    </form>
</div>
{% endblock %}

{% block scripts %}
{% endblock %}

Every POST form carries a hidden csrf_token field. CSRF protection is on for the whole app, so a form without it gets 400 Bad Request.


Step 6. Register hooks

Edit splent_feature_notes/src/splent_io/splent_feature_notes/hooks.py.

from splent_framework.hooks.template_hooks import register_template_hook
from flask import render_template


def notes_sidebar_item():
    return render_template("hooks/sidebar_notes.html")


register_template_hook("layout.sidebar.authenticated.items", notes_sidebar_item)

Create the hook template at splent_feature_notes/src/splent_io/splent_feature_notes/templates/hooks/sidebar_notes.html.

<li class="sidebar-item {{ 'active' if request.endpoint and request.endpoint.startswith('notes.') else '' }}">
    <a class="sidebar-link" href="{{ url_for('notes.index') }}">
        <i class="align-middle" data-feather="file-text"></i> <span class="align-middle">Notes</span>
    </a>
</li>

It adds a sidebar link for authenticated users. layout.sidebar.authenticated.items is a slot of the product layout, and any feature can add items to it.


Step 7. Integrate into the SPL

Notes has a foreign key to auth’s user.id, so the SPL must say that notes depends on auth. Without that constraint, product:validate would not enforce the dependency and feature:order could load notes before auth. You are still detached since Step 1, as SPL commands need.

splent spl:add-feature sample_splent_spl splent_feature_notes
  Adding notes to sample_splent_spl

  Scanning for dependencies...

  Detected dependencies:
    notes => auth

  Add 'notes' to sample_splent_spl? [Y/n]: y

  sample_splent_spl came from the cache (.splent_cache/spls/sample_splent_spl@v4/sample_splent_spl.uvl), which is never edited.
  Created your working copy at splent_spl_sample_splent/ and changed it there. It now wins over the cache for every product that uses this line.

  ✅ Feature 'notes' added to sample_splent_spl.
     Model: splent_spl_sample_splent/sample_splent_spl.uvl
     Constraints: notes => auth

spl:add-feature scanned splent_feature_notes, found db.ForeignKey("user.id") in models.py, looked up the feature that owns the user table (auth) and added notes => auth.

It never edits the model in .splent_cache/, since a later download or a cleared cache would undo the change. The first time you change a model, it copies it to your own working copy, splent_spl_sample_splent/ at the workspace root, and changes that. From now on every command that reads sample_splent_spl reads your working copy.

splent spl:features sample_splent_spl

notes appears in the list. Check its dependency.

splent spl:deps notes sample_splent_spl
SPL deps
SPL      : sample_splent_spl
UVL      : /workspace/splent_spl_sample_splent/sample_splent_spl.uvl
Features : 18

Dependencies of 'notes' (features it requires):
- auth

Select the product again.

splent product:select my_first_app

Step 8. Add the feature to the product

splent feature:add splent-io/splent_feature_notes
  notes added to features
    reinstalling in web container...
  done.

It declares the feature in pyproject.toml and creates its link in my_first_app/features/splent_io/, pointing at the editable checkout at the workspace root.


Step 9. Generate and apply the migration

splent db:migrate splent_feature_notes

SPLENT finds the feature’s tables from its models. After Alembic’s log lines you see this (the revision ID is random).

INFO  [alembic.autogenerate.compare.tables] Detected added table 'notes'
Generating /workspace/my_first_app/features/splent_io/splent_feature_notes/src/splent_io/splent_feature_notes/migrations/versions/ee9262d3b6c0_splent_feature_notes.py ...  done
  📝 splent_feature_notes: new migration generated

db:migrate only generates the migration. Check the status.

splent db:status
📊 Migration Status

  Feature                  Applied         Latest          Status
  -----------------------  --------------  --------------  ------------
  splent_feature_auth      auth0009_email  auth0009_email  ✔ synced
  splent_feature_notes     —               ee9262d3b6c0    ⚠ not applied
  splent_feature_profile   29f0c82ca134    29f0c82ca134    ✔ synced
  splent_feature_settings  settings0001    settings0001    ✔ synced

  1 feature(s) out of sync. Run 'splent db:upgrade' to apply pending migrations.

Apply it.

splent db:upgrade

The status is now synced.

splent db:status
  splent_feature_notes     ee9262d3b6c0    ee9262d3b6c0    ✔ synced

Migrations run in UVL order, so auth’s user table was already there for the foreign key.


Step 10. Verify in the browser

feature:add already reinstalled the feature and restarted Flask. Open http://localhost:5591/login (splent product:port prints the URL) and log in with the seeded test user.

  • Email is user1@example.com
  • Password is 1234

The login lands on the public home page, whose site shell has no sidebar. Go to http://localhost:5591/notes/. It extends base_template.html, the product’s app layout, whose sidebar now lists Notes. Click New Note and save one, and it appears with “Note created.”. Delete it and you get “Note deleted.”.


Step 11. Inspect the composition

splent feature:xray

It warns that the contract of splent_feature_notes is stale.

  ⚠ 1 feature(s) with potentially stale contracts:
    - splent_feature_notes

  Update stale contracts now? [Y/n]:

You added models, routes and hooks after the scaffold, so the contract that describes what the feature provides is out of date. Press Y to update it.

    ✔ splent_feature_notes contract updated.

The x-ray shows notes in the layout’s sidebar slot, next to profile.

  my_first_app (product layout)
      template: base_template.html
          ...
          hook: layout.sidebar.authenticated.items  ← splent_feature_profile, splent_feature_notes
          ...

  splent_feature_notes
          hook: layout.sidebar.authenticated.items  ← splent_feature_profile

Step 12. View the feature contract

splent feature:contract splent_feature_notes
  feature:contract — splent_feature_notes
  ────────────────────────────────────────────────────────────
  🔍 Scanning source code…
  Inferred contract:

  [tool.splent.contract]
  archetype  = "full"

  [tool.splent.contract.provides]
  routes     = ["/notes/", "/notes/<int:note_id>/delete", "/notes/create"]
  blueprints = ["notes_bp"]
  models     = ["Notes"]
  commands   = ["hello"]
  hooks      = ["layout.sidebar.authenticated.items"]
  services   = ["NotesService"]
  signals    = []
  translations = []
  docker     = []

  [tool.splent.contract.requires]
  features   = ["auth"]
  features_optional = []
  env_vars   = []
  signals    = []

  [tool.splent.contract.extensible]
  services   = ["NotesService"]
  templates  = ["hooks/sidebar_notes.html", "notes/create.html", "notes/index.html"]
  models     = ["Notes"]
  hooks      = ["layout.sidebar.authenticated.items"]
  routes     = True

  ✅ Contract is already up to date.

The contract is the feature’s formal interface. It lists what the feature provides, what it requires and what it lets refinement features extend. requires.features = ["auth"] comes from the user.id foreign key, as in Step 7.

commands = ["hello"] is the sample command of the scaffold’s commands.py. Replace it with your own command or delete the file, then run splent feature:contract splent_feature_notes --write. feature:clean --apply deletes an untouched commands.py along with every other unused stub. Run it without --apply first to see the list, then refresh the contract.


What you learned

Concept Summary
Scaffold feature:create generates a complete feature package
Model Standard SQLAlchemy models with db.Model. Foreign keys across features are fine
Repository Extends BaseRepository for data access (CRUD + custom queries)
Service Extends BaseService for business logic, delegates to repository
Routes Standard Flask routes on a BaseBlueprint, protected with Flask-Login’s @login_required (auth provides the login flow and the user model)
Hooks register_template_hook() injects content into product layout extension points
UVL Features and constraints live in the SPL’s .uvl file, edited in your working copy (splent_spl_sample_splent/), never in the cache
Migration db:migrate generates, db:upgrade applies, and UVL order keeps foreign keys safe

Next

Continue with Tutorial 4, Testing, to test the notes feature.


Back to top

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