Примеры · keepup-admin 0.5.0
Одно приложение, шаг за шагом
Небольшое приложение заметок: плагин со своей таблицей и маршрутами, фоновая чистка под блокировкой, события между репликами, живой сокет и вход через провайдер. Каждый пример опирается на предыдущий.
01
Приложение с плагинами
Каталог плагинов вычисляется от файла, а не от рабочего каталога. Таблицы, объявленные плагинами, ядро создаёт само при create_app (get_declared_tables()); первые учётные записи создаёт keepup-auth. Канал шины понадобится в четвёртом примере.
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
Плагин со своей таблицей и маршрутами
Таблица объявлена один раз и создаётся при каждом старте, если её нет. Маршруты — словари; маска params проверяет и приводит параметры до вызова обработчика. Запросы к базе синхронные, поэтому уходят в поток и не держат цикл событий.
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) # свои таблицы плагин ещё и объявляет: get_declared_tables()
return True
def get_handlers(self) -> Dict[str, Any]:
return {"count_notes": self.count_notes} # для других плагинов
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"]
Проверка из командной строки — после входа в панель или с токеном:
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: вне маски
03
Фоновая работа в одной реплике
Цикл запускается в каждой реплике, но чистка идёт только в той, что взяла блокировку; остальные пропускают проход. Блокировка продлевается, пока работа идёт, так что длинный проход не перехватят.
plugins/notes.py — дополнение
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) # свои таблицы плагин ещё и объявляет: 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, если чистит другая реплика
async def cleanup(self):
self._purger.cancel()
04
События, шина и живой сокет
Сокет пользователя держит одна реплика, а заметку могли добавить через другую. deliver() отдаёт конверт всем репликам, включая свою; каждая пишет в сокеты, которые держит сама. Конверт называет заметку, а не несёт её: доставка по возможности, и клиент перечитывает список сам.
plugins/notes.py — дополнение
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 -> сокеты В ЭТОЙ реплике
async def initialize(self) -> bool:
tables.ensure_tables(notes) # свои таблицы плагин ещё и объявляет: 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]:
# У сокетов вход по умолчанию выключен -- включаем явно.
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):
... # проверка и INSERT, как во втором примере
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}
Без notification_channel в настройках шины нет вовсе, и deliver() никому ничего не раздаёт. На SQLite шина есть, но между процессами не ходит — для одной реплики этого достаточно.
05
Вход через корпоративный провайдер
Учётная запись создаётся только для подтверждённых адресов своего домена; всем остальным у того же провайдера — отказ. Роли провайдера из roles_claim сопоставляются с правами приложения.
main.py — настройки
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
Свои правила: пароль, аудит, источники
То, что каркас не может знать, приложение говорит само: какой пароль годится, какие поля запроса можно хранить в аудите целиком и каким источникам можно читать ответы.
from keepup_audit.audit import names_only
MIN_PASSWORD_LENGTH = 12
#: Поля, которые не секретны и нужны при разборе инцидента.
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",),
)