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
113 lines
3.3 KiB
Python
113 lines
3.3 KiB
Python
"""
|
|
Key/value store for admin-configurable site settings.
|
|
|
|
Values are strings on disk; the typed helpers below do the coercion. Reads
|
|
are cached per process and the cache is dropped on write, so the settings
|
|
page is authoritative without hitting the database on every request.
|
|
|
|
Created by: Jess Rogerson (yelling commands at Claude.AI)
|
|
"""
|
|
|
|
from datetime import datetime
|
|
import threading
|
|
|
|
from app import db
|
|
|
|
# Defaults are the behaviour LIM had before settings existed, except where
|
|
# noted — anything not yet stored falls back to these.
|
|
DEFAULTS = {
|
|
'allow_registration': 'true',
|
|
# Visibility given to newly created sets.
|
|
'default_visibility': 'private',
|
|
# Send logged-in users straight to their dashboard instead of the
|
|
# marketing homepage.
|
|
'home_redirect_dashboard': 'true',
|
|
|
|
# Automatic instruction fetcher (see services/instruction_fetcher.py).
|
|
# Off by default: an admin has to switch it on deliberately.
|
|
'fetch_enabled': 'false',
|
|
'fetch_interval_minutes': '10',
|
|
'fetch_file_delay_seconds': '5',
|
|
'fetch_max_attempts': '3',
|
|
'fetch_skip_sets_with_pdfs': 'true',
|
|
'fetch_allow_setid_lookup': 'true',
|
|
'fetch_last_run': '',
|
|
|
|
# Logging / API call history
|
|
'owner_user_id': '',
|
|
'api_log_retention_days': '90',
|
|
}
|
|
|
|
VISIBILITIES = ('private', 'shared', 'public')
|
|
|
|
_cache = {}
|
|
_cache_lock = threading.Lock()
|
|
|
|
|
|
class SiteSetting(db.Model):
|
|
"""One configurable setting."""
|
|
|
|
__tablename__ = 'site_settings'
|
|
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
key = db.Column(db.String(64), unique=True, nullable=False, index=True)
|
|
value = db.Column(db.Text, nullable=True)
|
|
updated_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow,
|
|
onupdate=datetime.utcnow)
|
|
|
|
def __repr__(self):
|
|
return f'<SiteSetting {self.key}={self.value!r}>'
|
|
|
|
# -- reads --------------------------------------------------------------
|
|
|
|
@classmethod
|
|
def get(cls, key, default=None):
|
|
with _cache_lock:
|
|
if key in _cache:
|
|
return _cache[key]
|
|
try:
|
|
row = cls.query.filter_by(key=key).first()
|
|
except Exception:
|
|
# Table may not exist yet (first boot, before create_all).
|
|
return DEFAULTS.get(key, default)
|
|
value = row.value if row else DEFAULTS.get(key, default)
|
|
with _cache_lock:
|
|
_cache[key] = value
|
|
return value
|
|
|
|
@classmethod
|
|
def get_bool(cls, key, default=False):
|
|
value = cls.get(key)
|
|
if value is None:
|
|
return default
|
|
return str(value).strip().lower() in ('1', 'true', 'yes', 'on')
|
|
|
|
@classmethod
|
|
def all_settings(cls):
|
|
merged = dict(DEFAULTS)
|
|
try:
|
|
for row in cls.query.all():
|
|
merged[row.key] = row.value
|
|
except Exception:
|
|
pass
|
|
return merged
|
|
|
|
# -- writes -------------------------------------------------------------
|
|
|
|
@classmethod
|
|
def set(cls, key, value):
|
|
if isinstance(value, bool):
|
|
value = 'true' if value else 'false'
|
|
row = cls.query.filter_by(key=key).first()
|
|
if row:
|
|
row.value = value
|
|
else:
|
|
db.session.add(cls(key=key, value=value))
|
|
cls.clear_cache()
|
|
return value
|
|
|
|
@classmethod
|
|
def clear_cache(cls):
|
|
with _cache_lock:
|
|
_cache.clear()
|