Files
lego-instructions-manager/app/services/ownership.py
T
jessikitty f7c20de5ac 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
2026-09-27 10:42:49 +10:00

87 lines
2.7 KiB
Python

"""
Who owns this LIM instance.
LIM only had is_admin, which is fine for day-to-day administration but too
broad for things that expose the whole instance: the log viewer shows every
request, every error and every API call, including other admins' activity.
The owner is a single account:
* whatever `owner_user_id` in site_settings points at, if that user exists;
* otherwise the lowest-numbered admin (in practice the account created by
`flask init_db`), which is then remembered.
Change it with `flask owner set <username>`.
Created by: Jess Rogerson (yelling commands at Claude.AI)
"""
from functools import wraps
from flask import flash, redirect, url_for
from flask_login import current_user
from app import db
from app.models.site_setting import SiteSetting
from app.models.user import User
OWNER_SETTING = 'owner_user_id'
def get_owner():
"""The User who owns this instance, or None on an empty database."""
raw = SiteSetting.get(OWNER_SETTING, '')
if raw and str(raw).strip().isdigit():
owner = db.session.get(User, int(str(raw).strip()))
if owner is not None:
return owner
# Nothing stored (or it points at a deleted account): fall back to the
# first admin and remember the answer.
owner = (User.query.filter_by(is_admin=True).order_by(User.id).first()
or User.query.order_by(User.id).first())
if owner is not None:
try:
SiteSetting.set(OWNER_SETTING, str(owner.id))
db.session.commit()
except Exception:
db.session.rollback()
return owner
def get_owner_id():
owner = get_owner()
return owner.id if owner else None
def set_owner(user):
"""Hand ownership to a user. Returns the user."""
SiteSetting.set(OWNER_SETTING, str(user.id))
if not user.is_admin:
user.is_admin = True
db.session.commit()
return user
def is_owner(user=None):
"""Is this user (default: the logged-in one) the owner?"""
user = user or current_user
if user is None or not getattr(user, 'is_authenticated', False):
return False
owner_id = get_owner_id()
return owner_id is not None and user.id == owner_id
def owner_required(f):
"""Stricter than admin_required: this account only."""
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated:
flash('Please log in to access this page.', 'warning')
return redirect(url_for('auth.login'))
if not is_owner(current_user):
flash('That area is restricted to the instance owner.', 'danger')
return redirect(url_for('main.index'))
return f(*args, **kwargs)
return decorated_function