"""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)