import uuid from datetime import datetime from typing import Optional from sqlalchemy import DateTime, ForeignKey, Index, String, Text, JSON, func from sqlalchemy import Uuid from sqlalchemy.orm import Mapped, mapped_column, relationship from app.models.base import Base, UUIDMixin class AuditLog(Base, UUIDMixin): """Immutable record of member activity and application errors.""" __tablename__ = "audit_logs" __table_args__ = ( Index("ix_audit_logs_timestamp", "timestamp"), Index("ix_audit_logs_member_id", "member_id"), Index("ix_audit_logs_action_type", "action_type"), Index("ix_audit_logs_status", "status"), ) timestamp: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) # Nullable FK — SET NULL if member is deleted so the log is preserved. member_id: Mapped[Optional[uuid.UUID]] = mapped_column( Uuid(as_uuid=True), ForeignKey("members.id", ondelete="SET NULL"), nullable=True, ) # Denormalised for readability after member deletion. member_email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) # One of: login, logout, page_visit, booking_created, booking_cancelled, # profile_updated, onboarding_updated, contract_signed, # account_claimed, message_read, error action_type: Mapped[str] = mapped_column(String(64), nullable=False) # Identifies the page / feature area, e.g. "members/dashboard" area: Mapped[str] = mapped_column(String(255), nullable=False) # Human-readable one-liner description: Mapped[str] = mapped_column(String(500), nullable=False) # success | warning | error status: Mapped[str] = mapped_column(String(16), nullable=False, default="success") # Optional related booking — SET NULL if booking is deleted. booking_id: Mapped[Optional[uuid.UUID]] = mapped_column( Uuid(as_uuid=True), ForeignKey("bookings.id", ondelete="SET NULL"), nullable=True, ) # Error detail — populated for action_type='error' records. error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True) error_detail: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # Request metadata ip_address: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) user_agent: Mapped[Optional[str]] = mapped_column(String(512), nullable=True) # Catch-all JSON for any extra context (e.g. booking service_type) extra: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True) member: Mapped[Optional["Member"]] = relationship( # type: ignore[name-defined] "Member", foreign_keys=[member_id] )