Files
shopdb-flask/shopdb/core/models/base.py
cproudlock 405a2ef919 Cap request bodies, close a hole in the gate, fix to_dict and two settings pages
Six fixes that were ours regardless of what the sister site does. Four came out
of our own reviews; two you reported from the settings pages.

MAX_CONTENT_LENGTH was set nowhere. Every upload route caps its own payload, but
each of those checks runs after the body has arrived: the first touch of
request.form or request.files makes Werkzeug parse the whole multipart and spool
the file to a temp file, so a route can reject the content and still have paid
for the transfer and the disk. Three separate reviews found that shape in three
different routes, which makes it a property of the app rather than of any
handler. 128 MB, clearing the 64 MB agent binary with room for the envelope.

The naming gate excluded devnotes with --exclude-dir, which matches a directory
of that NAME at any depth - so a force-added plugins/<plugin>/devnotes/ was
invisible to every check in the file, ADR-015 included. Replaced with a path
anchored post-filter. Verified by planting the same site literal in root
devnotes/ AND in plugins/endpoints/devnotes/: the gate now reports exactly one,
the nested one. Before, it reported none.

BaseModel.to_dict walked __table__.columns and published every one, so a model
without an override published whatever it carried - including columns added
later by someone not thinking about this method. User has no override, so
User.to_dict() returned its bcrypt hash. Nothing calls it in a response path
today, which is luck rather than design. A blanket allowlist would empty the
output of all 34 models relying on the default, so this pins a floor instead:
named secret-bearing columns are never emitted, and a model may extend the set.
Eight tests, including a sweep over every mapped model.

ActionPicker's Esc handlers had .prevent but not .stop, so the event bubbled to
the wrapper and closed the whole picker instead of returning to the monitor list.

The branding page reported "error loading image" on the site and badge logos.
Two bugs stacked. The load failure was real: the preview used the stored value
as a raw src, and that value is root-relative, so under /shopdb or /ops it
resolved against the SERVER root and 404d - siteSettings.js wraps every logo in
withBase() and this page was the one place that did not. With that fixed two of
three were still invisible, because the shipped GE mark is fill="white" for the
dark sidebar and was rendering white-on-white. The preview tile now sits on a
mid-tone checker so artwork of any colour reads against it.

The floor-map levels table ran off the right with its cells crushed - nine
columns of inputs, no scroll wrapper, and .data-table is styled nowhere. My
first attempt reached for the global .table-container and made it WORSE: that
class also sets white-space: nowrap, and a full sentence of hint text lives in
one of the cells, so nowrap forced the table wider still. It now scrolls in a
local wrapper, the hint wraps, and the name input has a width instead of
collapsing to one character.

Suite 2377, frontend 324, gate green.

Scope: product
2026-09-03 08:38:47 -04:00

99 lines
3.1 KiB
Python

"""Base model class with common fields."""
from datetime import datetime, timezone
from shopdb.extensions import db
def _utcnow():
# naive UTC for DB columns (stored without tzinfo)
return datetime.now(timezone.utc).replace(tzinfo=None)
class BaseModel(db.Model):
"""
Abstract base model with common fields.
All models should inherit from this.
"""
__abstract__ = True
createddate = db.Column(
db.DateTime,
default=_utcnow,
nullable=False
)
modifieddate = db.Column(
db.DateTime,
default=_utcnow,
onupdate=_utcnow,
nullable=False
)
isactive = db.Column(db.Boolean, default=True, nullable=False)
# Columns the default to_dict NEVER emits, whatever model carries them.
#
# The default serialiser walks __table__.columns and publishes every one, so
# a model that does not override it publishes whatever it has - including
# columns added to it later, by someone who was not thinking about this
# method. User is the live example: it does not override to_dict, so
# User.to_dict() returns passwordhash. Nothing calls it in a response path
# today, which is luck rather than design.
#
# A blanket allowlist would be the stronger rule, but it would also silently
# empty the output of all 34 models that rely on this default. This is the
# part that can be fixed without breaking anything: name the columns that
# must never be published, and let a model add its own.
#
# A model with something sensitive should still override to_dict and say
# exactly what it emits. This is a floor, not permission to skip that.
HIDDEN_COLUMNS = frozenset({
'passwordhash',
'tokenhash',
'apikey',
'clientsecret',
'collectorkey',
})
def to_dict(self):
"""Convert model to dictionary, minus anything secret-bearing."""
hidden = self.HIDDEN_COLUMNS
result = {}
for c in self.__table__.columns:
if c.name in hidden:
continue
value = getattr(self, c.name)
if isinstance(value, datetime):
value = value.isoformat() + 'Z'
result[c.name] = value
return result
def update(self, **kwargs):
"""Update model attributes."""
for key, value in kwargs.items():
if hasattr(self, key):
setattr(self, key, value)
@classmethod
def get_active(cls):
"""Return query for active records only."""
return cls.query.filter_by(isactive=True)
class SoftDeleteMixin:
"""Mixin for soft delete functionality."""
deleteddate = db.Column(db.DateTime, nullable=True)
deletedby = db.Column(db.String(100), nullable=True)
def soft_delete(self, deleted_by: str = None):
"""Mark record as deleted."""
self.isactive = False
self.deleteddate = datetime.now(timezone.utc).replace(tzinfo=None)
self.deletedby = deleted_by
class AuditMixin:
"""Mixin for audit fields."""
createdby = db.Column(db.String(100), nullable=True)
modifiedby = db.Column(db.String(100), nullable=True)