Files
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

229 lines
9.3 KiB
Python

"""
Instruction fetcher diagnostics - read-only.
Drop this in your LIM folder (next to run.py) and run it. It changes nothing;
it just reports what the fetcher can see, in the order things have to be true
for a download to happen.
E:\\LIM> venv\\Scripts\\activate
E:\\LIM> python diagnose_fetcher.py
Optionally test a live Brickset lookup for one set (no files are saved):
E:\\LIM> python diagnose_fetcher.py 10497
Docker:
docker compose exec app python diagnose_fetcher.py
docker compose exec app python diagnose_fetcher.py 10497
Created by: Jess Rogerson (yelling commands at Claude.AI)
"""
import os
import sys
os.environ.setdefault('INSTRUCTION_FETCH_WORKER', '0')
def line(char='-'):
print(char * 68)
def head(text):
print()
line('=')
print(f' {text}')
line('=')
def main():
test_set = sys.argv[1] if len(sys.argv) > 1 else None
try:
from app import create_app, db
except Exception as exc:
print(f'FAILED to import the app: {exc}')
print('Are you in the LIM folder with the virtualenv activated?')
return 1
app = create_app(os.environ.get('FLASK_ENV', 'production'))
with app.app_context():
# -- is the update even installed? ---------------------------------
head('1. Is the fetcher installed?')
try:
from app.models.instruction_fetch import InstructionFetchJob
from app.services import instruction_fetcher as fetcher
print(' code : present')
except Exception as exc:
print(f' code : MISSING ({exc})')
print('\n The update is not installed in this folder.')
return 1
from sqlalchemy import inspect
inspector = inspect(db.engine)
tables = inspector.get_table_names()
print(f" jobs table : "
f"{'present' if 'instruction_fetch_jobs' in tables else 'MISSING - restart LIM'}")
cols = [c['name'] for c in inspector.get_columns('instructions')]
print(f" instructions.sha256: {'present' if 'sha256' in cols else 'MISSING - restart LIM'}")
routes = [r.rule for r in app.url_map.iter_rules()
if 'instruction-fetch' in r.rule]
print(f' admin routes : {len(routes)} registered')
# -- credentials ----------------------------------------------------
head('2. Brickset credentials')
from app.services.brickset_api import BricksetAPI
configured = BricksetAPI.is_configured()
for key in ('BRICKSET_API_KEY', 'BRICKSET_USERNAME', 'BRICKSET_PASSWORD'):
value = app.config.get(key)
shown = ('set (%d chars)' % len(value)) if value else 'NOT SET'
print(f' {key:<18} : {shown}')
print(f' is_configured() : {configured}')
# -- settings --------------------------------------------------------
head('3. Fetcher settings')
from app.models.site_setting import SiteSetting
for key in sorted(fetcher.SETTING_DEFAULTS):
print(f' {key:<26} : {SiteSetting.get(key, "(unset)")!r}')
print()
print(f' is_enabled() : {fetcher.is_enabled()}')
print(f' interval_minutes() : {fetcher.interval_minutes()}')
print(f' is_due() : {fetcher.is_due()}')
print(f' seconds_until_due() : {fetcher.seconds_until_due()}')
print(f' last_run() : {fetcher.last_run()}')
# -- the library ------------------------------------------------------
head('4. What the queue builder can see')
from app.models.set import Set
from app.models.instruction import Instruction
total = Set.query.count()
official = fetcher.official_sets_query().count()
mocs = Set.query.filter(Set.is_moc.is_(True)).count()
null_moc = Set.query.filter(Set.is_moc.is_(None)).count()
missing = fetcher.sets_missing_pdfs().count()
print(f' sets total : {total}')
print(f' official (is_moc = false) : {official}')
print(f' MOCs (is_moc = true) : {mocs}')
print(f' is_moc IS NULL : {null_moc} '
f'{"<-- these are invisible to the fetcher!" if null_moc else ""}')
print(f' official with no PDF : {missing} <-- "Queue sets missing instructions" queues this many')
print()
print(' file_type values in the instructions table:')
rows = db.session.query(
Instruction.file_type, db.func.count(Instruction.id)
).group_by(Instruction.file_type).all()
if rows:
for file_type, count in rows:
flag = '' if file_type == 'PDF' else ''
print(f' {file_type!r:<12} {count}{flag}')
else:
print(' (no instruction files at all)')
if official:
print()
print(' first few official sets:')
for lego_set in fetcher.official_sets_query().limit(5):
pdfs = lego_set.instructions.filter_by(file_type='PDF').count()
print(f' {lego_set.set_number:<12} {(lego_set.set_name or "")[:32]:<34} '
f'PDFs={pdfs} brickset_id={lego_set.brickset_id}')
# -- the queue ---------------------------------------------------------
head('5. The queue')
counts = InstructionFetchJob.counts()
for key, value in counts.items():
print(f' {key:<12} : {value}')
recent = InstructionFetchJob.query.order_by(
InstructionFetchJob.id.desc()).limit(10).all()
if recent:
print()
print(' most recent jobs:')
for job in recent:
number = job.lego_set.set_number if job.lego_set else '(deleted)'
print(f' #{job.id:<5} {number:<12} {job.status:<10} '
f'files={job.files_added} tries={job.attempts}')
if job.message:
print(f' {job.message[:100]}')
else:
print()
print(' No jobs have ever been queued.')
# -- verdict -------------------------------------------------------------
head('6. Verdict')
problems = []
if not configured:
problems.append('Brickset credentials are not set in .env')
if null_moc:
problems.append(f'{null_moc} set(s) have is_moc = NULL and are skipped')
if not official:
problems.append('there are no official sets in the library')
elif not missing and counts['total'] == 0:
problems.append('every official set already has a PDF, so '
'"queue missing" finds nothing - use '
'"Queue all official sets" instead')
if not fetcher.is_enabled():
problems.append('the fetcher is switched OFF (admin page, '
'"Work through the queue automatically")')
if counts['queued'] and not fetcher.is_enabled():
problems.append(f"{counts['queued']} job(s) are waiting but "
f"nothing will run until it is switched on")
if not app.config.get('INSTRUCTION_FETCH_WORKER', True):
problems.append('INSTRUCTION_FETCH_WORKER is 0, so the web '
'process runs no worker thread')
if problems:
for problem in problems:
print(f' * {problem}')
else:
print(' Nothing obviously wrong. If the queue has jobs and the '
'fetcher is on,')
print(' the next one runs in '
f'{fetcher.seconds_until_due()} seconds.')
# -- live test ------------------------------------------------------------
if test_set:
head(f'7. Live Brickset lookup for {test_set}')
number = fetcher.brickset_number(test_set)
print(f' asking Brickset for : {number}')
api = BricksetAPI()
try:
entries = api.get_instructions(number)
print(f' getInstructions2 : {len(entries)} result(s)')
for entry in entries:
print(f' {entry.get("description", "")}')
print(f' {entry.get("URL", "")}')
if not entries:
print(' Nothing returned. Either Brickset has no '
'instructions for this set,')
print(' or the credentials/number are wrong.')
else:
import requests
url = entries[0].get('URL')
print()
print(' testing the first download (headers only)...')
response = requests.get(
url, stream=True, timeout=30,
headers={'User-Agent': fetcher.USER_AGENT})
print(f' HTTP {response.status_code} '
f'{response.headers.get("Content-Type")} '
f'{response.headers.get("Content-Length")} bytes')
response.close()
except Exception as exc:
print(f' FAILED: {type(exc).__name__}: {exc}')
print()
line('=')
print(' end of report')
line('=')
return 0
if __name__ == '__main__':
sys.exit(main())