Examples · keepup-admin 0.5.0

One application, step by step

A small notes application: a plugin with its own table and routes, a background purge under a lock, events between replicas, a live socket and sign-in through a provider. Each example builds on the one before.

01

An application with plugins

The plugins directory is derived from the file, not from the working directory. The tables plugins declare are created by the kernel at create_app (get_declared_tables()); the first accounts are created by keepup-auth. The bus channel is needed in the fourth example.

main.py
import os

from keepup import KeepupSettings, create_app

HERE = os.path.dirname(os.path.abspath(__file__))
PLUGINS_DIR = os.path.join(HERE, "plugins")

app = create_app(KeepupSettings(
    title="Notes",
    project_name="notes",
    plugins_dir=PLUGINS_DIR,
    notification_channel="notes_events",
    static_mounts=(),
))
config/modules.json
{
  "plugins": [
    {"id": "notes", "name": "Notes", "enabled": true, "priority": 100, "config": {}}
  ],
  "roles": [
    {"name": "ADMIN", "modules": [], "plugins": ["notes"]},
    {"name": "CLIENT", "modules": [], "plugins": ["notes"]}
  ]
}

02

A plugin with its own table and routes

The table is declared once and created at every start if it is missing. Routes are dictionaries; the params mask checks and converts parameters before the handler is called. Database calls are synchronous, so they go to a thread and do not hold the event loop.

plugins/notes.py
import asyncio
from typing import Any, Dict, List

from fastapi import HTTPException
from sqlalchemy import Column, DateTime, Integer, Text
from sqlalchemy import text as sql_text

from keepup_db import tables
from keepup_db import DatabaseManagerV2
from keepup.plugins.base import BasePlugin

MAX_NOTE_LENGTH = 2000

notes = tables.table(
    "notes",
    Column("id", Integer, primary_key=True, autoincrement=True),
    Column("owner_id", Integer, nullable=False),
    Column("text", Text, nullable=False),
    Column("created_at", DateTime, server_default=sql_text("CURRENT_TIMESTAMP")),
    tables.foreign_key("owner_id", "users", ("id",), ondelete="CASCADE"),
)


class NotesPlugin(BasePlugin):          # id "notes" -> NotesPlugin
    def __init__(self, config: Dict = None):
        super().__init__("notes", "Notes", config or {})

    async def initialize(self) -> bool:
        tables.ensure_tables(notes)   # a plugin also declares its tables: get_declared_tables()
        return True

    def get_handlers(self) -> Dict[str, Any]:
        return {"count_notes": self.count_notes}   # for other plugins

    def get_api_routes(self) -> List[Dict]:
        return [
            {"path": "/api/notes", "methods": ["GET"], "handler": self.list_notes,
             "params": {"limit": {"type": "int", "min": 1, "max": 100, "required": False}}},
            {"path": "/api/notes", "methods": ["POST"], "handler": self.add_note},
            {"path": "/api/notes/{note_id}", "methods": ["DELETE"], "handler": self.delete_note,
             "params": {"note_id": {"type": "int"}}},
        ]

    async def list_notes(self, current_user: dict, limit: int = 20):
        rows = await asyncio.to_thread(
            DatabaseManagerV2.execute,
            "SELECT id, text, created_at FROM notes WHERE owner_id = :owner "
            "ORDER BY id DESC LIMIT :limit",
            {"owner": current_user["id"], "limit": limit})
        return {"notes": rows}

    async def add_note(self, current_user: dict, request: dict = None):
        text = str((request or {}).get("text", "")).strip()
        if not text or len(text) > MAX_NOTE_LENGTH:
            raise HTTPException(status_code=400, detail="text is empty or too long")
        await asyncio.to_thread(
            DatabaseManagerV2.execute_commit,
            "INSERT INTO notes (owner_id, text) VALUES (:owner, :text)",
            {"owner": current_user["id"], "text": text})
        return {"ok": True}

    async def delete_note(self, current_user: dict, note_id: int, request: dict = None):
        removed = await asyncio.to_thread(
            DatabaseManagerV2.execute_commit,
            "DELETE FROM notes WHERE id = :id AND owner_id = :owner",
            {"id": note_id, "owner": current_user["id"]})
        if not removed:
            raise HTTPException(status_code=404, detail="no such note")
        return {"ok": True}

    def count_notes(self, owner_id: int) -> int:
        row = DatabaseManagerV2.execute_one(
            "SELECT COUNT(*) AS n FROM notes WHERE owner_id = :owner", {"owner": owner_id})
        return row["n"]

Trying it from the command line — after signing into the panel, or with a token:

curl -H "Authorization: Bearer $TOKEN" "http://localhost:8000/api/notes?limit=5"
curl -H "Authorization: Bearer $TOKEN" "http://localhost:8000/api/notes?limit=500"   # 400: outside the mask

03

Background work in one replica

The loop starts in every replica, but the purge runs only in the one that took the lock; the others skip the pass. The lock is renewed while the work goes on, so a long pass is not taken over.

plugins/notes.py — additions
from datetime import datetime, timedelta

from keepup.locks import with_distributed_lock

PURGE_EVERY_SECONDS = 3600
KEEP_NOTES_DAYS = 90


@with_distributed_lock("notes_purge", timeout=60)
async def purge_old_notes():
    cutoff = datetime.utcnow() - timedelta(days=KEEP_NOTES_DAYS)
    await asyncio.to_thread(
        DatabaseManagerV2.execute_commit,
        "DELETE FROM notes WHERE created_at < :cutoff", {"cutoff": cutoff})


class NotesPlugin(BasePlugin):
    async def initialize(self) -> bool:
        tables.ensure_tables(notes)   # a plugin also declares its tables: get_declared_tables()
        self._purger = asyncio.create_task(self._purge_forever())
        return True

    async def _purge_forever(self):
        while True:
            await asyncio.sleep(PURGE_EVERY_SECONDS)
            await purge_old_notes()      # None when another replica purges

    async def cleanup(self):
        self._purger.cancel()

04

Events, the bus and a live socket

A user's socket is held by one replica, while the note may have been added through another. deliver() hands the envelope to every replica, this one included; each writes to the sockets it holds itself. The envelope names the note rather than carrying it: delivery is best-effort, and the client re-reads the list itself.

plugins/notes.py — additions
from fastapi import WebSocketDisconnect

from keepup import notification_bus
from keepup_audit.events import emit_event


class NotesPlugin(BasePlugin):
    def __init__(self, config: Dict = None):
        super().__init__("notes", "Notes", config or {})
        self.sockets: Dict[int, set] = {}   # user id -> sockets in THIS replica

    async def initialize(self) -> bool:
        tables.ensure_tables(notes)   # a plugin also declares its tables: get_declared_tables()
        bus = notification_bus.get_notification_bus()
        if bus is not None:
            bus.subscribe(self.on_envelope)
        return True

    def get_websocket_routes(self) -> List[Dict]:
        # Sign-in is off for sockets by default -- switch it on.
        return [{"path": "/ws/notes", "handler": self.notes_socket, "require_auth": True}]

    async def notes_socket(self, websocket, current_user: dict):
        await websocket.accept()
        mine = self.sockets.setdefault(current_user["id"], set())
        mine.add(websocket)
        try:
            while True:
                await websocket.receive_text()
        except WebSocketDisconnect:
            pass
        finally:
            mine.discard(websocket)

    async def on_envelope(self, envelope: dict):
        if envelope.get("kind") != "note_added":
            return
        for socket in list(self.sockets.get(envelope["owner_id"], ())):
            await socket.send_json({"changed": "notes"})

    async def add_note(self, current_user: dict, request: dict = None):
        ...   # the check and the INSERT, as in the second example
        await notification_bus.deliver({"kind": "note_added", "owner_id": current_user["id"]})
        await emit_event("note_added", f"{current_user['username']} added a note",
                         {"owner_id": current_user["id"]})
        return {"ok": True}

Without notification_channel in the settings there is no bus at all, and deliver() hands nothing to anybody. On SQLite the bus exists but does not cross processes — for a single replica that is enough.

05

Sign-in through a company provider

An account is created only for verified addresses of your own domain; everyone else at the same provider is refused. The provider's roles from roles_claim are mapped onto the application's permissions.

main.py — settings
import os

from keepup import KeepupSettings
from keepup.settings import OidcSettings
from keepup_auth.oidc_policy import create_if_email_domain

settings = KeepupSettings(
    title="Notes",
    project_name="notes",
    plugins_dir=PLUGINS_DIR,
    oidc=OidcSettings(
        issuer="https://id.example.com/realms/staff",
        client_id="notes",
        client_secret=os.environ["OIDC_CLIENT_SECRET"],
        redirect_uri="https://notes.example.com/api/auth/oidc/callback",
        roles_claim="groups",
        after_login_path="/selfcare",
    ),
    oidc_account_policy=create_if_email_domain(["example.com"]),
)

06

Your own rules: password, audit, origins

What the framework cannot know, the application states itself: which password will do, which request fields the audit may keep in full, and which origins may read the answers.

from keepup_audit.audit import names_only

MIN_PASSWORD_LENGTH = 12
#: Fields that are not secret and help when reading an incident.
AUDIT_PLAIN_FIELDS = {"limit", "note_id"}


def password_rule(password: str, username: str):
    if len(password) < MIN_PASSWORD_LENGTH:
        return f"The password is shorter than {MIN_PASSWORD_LENGTH} characters"
    if username and username.lower() in password.lower():
        return "The password contains the user name"
    return None


def audit_redaction(body):
    if isinstance(body, dict):
        return {key: value if key in AUDIT_PLAIN_FIELDS else names_only(value)
                for key, value in body.items()}
    return names_only(body)


settings = KeepupSettings(
    title="Notes",
    project_name="notes",
    plugins_dir=PLUGINS_DIR,
    password_rule=password_rule,
    audit_redaction=audit_redaction,
    cors_origins=("https://notes.example.com",),
)
Documentation Source on GitHub