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

+15 -5
View File
@@ -21,6 +21,7 @@ Created by: Jess Rogerson (yelling commands at Claude.AI)
"""
import hashlib
import logging
import os
import re
import tempfile
@@ -30,6 +31,8 @@ from datetime import datetime, timedelta
import requests
from flask import current_app
logger = logging.getLogger(__name__)
from app import db
from app.models.instruction import Instruction
from app.models.instruction_fetch import InstructionFetchJob
@@ -375,8 +378,11 @@ def fetch_for_set(lego_set, delay_between_files=None):
pause = (file_delay_seconds() if delay_between_files is None
else delay_between_files)
api = BricksetAPI()
api = BricksetAPI(context='fetcher')
entries, source, note = _instruction_urls(api, lego_set)
logger.info('Set %s: Brickset lists %d instruction file(s)%s',
lego_set.set_number, len(entries),
f' ({note})' if note else '')
if not entries:
return 0, 0, source, note or 'no instructions listed on Brickset'
@@ -395,6 +401,8 @@ def fetch_for_set(lego_set, delay_between_files=None):
temp_path, size, sha = _download_pdf(entry['url'], max_bytes,
timeout)
except Exception as exc:
logger.warning('Set %s: download failed for %s - %s',
lego_set.set_number, entry['url'], exc)
problems.append(f'{entry["description"] or entry["url"]}: {exc}')
continue
@@ -427,6 +435,8 @@ def fetch_for_set(lego_set, delay_between_files=None):
known.add(sha)
page += 1
added += 1
logger.info('Set %s: saved %s (%d bytes)',
lego_set.set_number, filename, file_size or size)
except Exception as exc:
db.session.rollback()
@@ -519,8 +529,8 @@ def run_one(force=False):
job.status = 'empty'
except Exception as exc:
current_app.logger.exception('Instruction fetch failed for %s',
lego_set.set_number)
logger.exception('Instruction fetch failed for %s',
lego_set.set_number)
job.message = str(exc)[:500]
if job.attempts >= max_attempts():
job.status = 'failed'
@@ -533,8 +543,8 @@ def run_one(force=False):
job.finished_at = datetime.utcnow()
db.session.commit()
current_app.logger.info('Instruction fetch %s for %s: %s', job.status,
lego_set.set_number, job.message)
logger.info('Job #%s %s for %s: %s', job.id, job.status,
lego_set.set_number, job.message)
return job.to_dict()