Add owner-only logging and Brickset API call tracking

LIM configured no logging at all, so the instruction fetcher had no trail to follow. Adds a rotating file handler on the root logger plus two owner-only admin pages.

- Admin > Logs: filter by level, source and text; tracebacks kept with their error; auto-refresh, raw download, rotated-file browsing and rotate-now

- Admin > Logs > Brickset API calls: every outbound call recorded with method, params, caller, duration, result count and error, plus daily quota accounting (only getSets counts) and a comparison against getKeyUsageStats

- New owner concept (services/ownership.py): a single account, distinct from is_admin, since these pages expose other admins activity. Defaults to the first admin; flask owner show/set to change

- Services now log through module loggers; retention setting and pruning for call records

- New CLI: flask owner show/set, flask logs path/prune

- New table brickset_api_calls, created automatically at boot; LOG_* env vars, /data/logs in Docker
This commit is contained in:
jessikitty committed 2026-09-27 10:42:49 +10:00
1 parent 89f65e2d74
commit f7c20de5ac
27 files changed
+1914 -35

No files matched your search

+87
View File
@@ -12,6 +12,11 @@ Flask CLI commands for LIM backup and restore.
flask instructions run [--count N] [--force]
flask instructions status
flask owner show
flask owner set USERNAME
flask logs path [--tail N]
flask logs prune [--days N]
`restore` is the path to use on a fresh Docker instance: no login required,
and it works before any admin user exists.
@@ -267,6 +272,88 @@ def instructions_status():
f"{summary['sets_missing_pdfs']} without a PDF")
owner_cli = AppGroup('owner', help='Who owns this instance.')
@owner_cli.command('show')
def owner_show():
"""Show the owner account (the only one who can read the logs)."""
from app.services.ownership import get_owner
owner = get_owner()
if owner is None:
click.secho('No users exist yet.', fg='yellow')
return
click.secho(f'Owner: {owner.username} <{owner.email}> (id {owner.id})',
fg='green')
@owner_cli.command('set')
@click.argument('username')
def owner_set(username):
"""Hand ownership to USERNAME (promotes them to admin if needed)."""
from app.models.user import User
from app.services.ownership import set_owner
user = User.query.filter_by(username=username).first()
if user is None:
click.secho(f'No user called {username!r}.', fg='red')
raise SystemExit(1)
set_owner(user)
click.secho(f'{user.username} is now the owner.', fg='green')
logs_cli = AppGroup('logs', help='Application log file.')
@logs_cli.command('path')
@click.option('--tail', default=0, type=int,
help='Also print the last N lines.')
def logs_path(tail):
"""Where the log lives, and optionally its tail."""
import os
from flask import current_app
from app.services import logging_setup
path = logging_setup.log_path(current_app)
exists = os.path.isfile(path)
click.echo(f' {path}')
click.echo(f' exists: {exists}'
+ (f' size: {os.path.getsize(path)} bytes' if exists else ''))
click.echo(f' handler active: {logging_setup.get_handler() is not None}')
if tail and exists:
entries, total = logging_setup.read_entries(path, max_entries=tail)
click.echo(f'\n last {len(entries)} of {total} entries:\n')
for entry in entries:
click.echo(f" {entry['time']} {entry['level']:<8} "
f"{entry['logger']} | {entry['message']}")
@logs_cli.command('prune')
@click.option('--days', default=None, type=int,
help='Override the configured retention.')
def logs_prune(days):
"""Delete old Brickset API call records."""
from app.models.api_call import BricksetApiCall
from app.models.site_setting import SiteSetting
if days is None:
try:
days = int(SiteSetting.get('api_log_retention_days', '90'))
except (TypeError, ValueError):
days = 90
removed = BricksetApiCall.prune(days)
click.secho(f'Removed {removed} record(s) older than {days} days.',
fg='green')
def register_cli(app):
app.cli.add_command(backup_cli)
app.cli.add_command(instructions_cli)
app.cli.add_command(owner_cli)
app.cli.add_command(logs_cli)