from datetime import date, datetime, timezone from sqlalchemy import DateTime, Text from sqlalchemy.orm import Mapped, mapped_column def iso(value: datetime | date | None) -> str | None: """ISO-8601 for a payload, None for an unset column. Every model's to_dict serialises timestamps through this one helper so a row read before flush (created_at still None) and a nullable column both come out as null instead of raising on `.isoformat()`. """ return value.isoformat() if value else None class SoftDeleteMixin: """Recoverable-delete columns. NULL deleted_at = live row. deleted_batch_id groups rows soft-deleted in one operation so a cascade restores as a unit.""" deleted_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True, default=None ) deleted_batch_id: Mapped[str | None] = mapped_column(Text, nullable=True, default=None) class TimestampMixin: created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), ) class CreatedAtMixin: created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) )