Source code for app.db.models._user

from __future__ import annotations

from datetime import UTC, date, datetime
from typing import TYPE_CHECKING

from advanced_alchemy.base import UUIDv7AuditBase
from advanced_alchemy.types import EncryptedString
from sqlalchemy import String
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import Mapped, mapped_column, relationship

from app.lib.settings import get_settings

if TYPE_CHECKING:
    from app.db.models._email_verification_token import EmailVerificationToken
    from app.db.models._oauth_account import UserOAuthAccount
    from app.db.models._password_reset_token import PasswordResetToken
    from app.db.models._refresh_token import RefreshToken
    from app.db.models._team_member import TeamMember
    from app.db.models._user_role import UserRole


settings = get_settings()


class User(UUIDv7AuditBase):
    __tablename__ = "user_account"
    __table_args__ = {"comment": "User accounts for application access"}
    __pii_columns__ = {"name", "email", "username", "phone", "avatar_url", "totp_secret"}

    email: Mapped[str] = mapped_column(unique=True, index=True, nullable=False)
    name: Mapped[str | None] = mapped_column(nullable=True, default=None)
    username: Mapped[str | None] = mapped_column(
        String(length=30), unique=True, index=True, nullable=True, default=None
    )
    phone: Mapped[str | None] = mapped_column(String(length=20), nullable=True, default=None)
    hashed_password: Mapped[str | None] = mapped_column(
        String(length=255),
        nullable=True,
        default=None,
        deferred=True,
        deferred_group="security_sensitive",
    )
    avatar_url: Mapped[str | None] = mapped_column(String(length=500), nullable=True, default=None)
    is_active: Mapped[bool] = mapped_column(default=True, nullable=False)
    is_superuser: Mapped[bool] = mapped_column(default=False, nullable=False)
    is_verified: Mapped[bool] = mapped_column(default=False, nullable=False)
    verified_at: Mapped[date] = mapped_column(nullable=True, default=None)
    joined_at: Mapped[date] = mapped_column(default=lambda: datetime.now(UTC).date())
    login_count: Mapped[int] = mapped_column(default=0)

    password_reset_at: Mapped[datetime | None] = mapped_column(nullable=True, default=None)
    failed_reset_attempts: Mapped[int] = mapped_column(default=0, nullable=False)
    reset_locked_until: Mapped[datetime | None] = mapped_column(nullable=True, default=None)

    totp_secret: Mapped[str | None] = mapped_column(
        EncryptedString(key=settings.app.SECRET_KEY),
        nullable=True,
        default=None,
        deferred=True,
        deferred_group="security_sensitive",
    )
    """Encrypted TOTP secret for authenticator apps."""
    is_two_factor_enabled: Mapped[bool] = mapped_column(default=False, nullable=False)
    """Whether two-factor authentication is enabled for this user."""
    two_factor_confirmed_at: Mapped[datetime | None] = mapped_column(nullable=True, default=None)
    """When MFA was confirmed/enabled."""
    backup_codes: Mapped[list[str | None] | None] = mapped_column(
        JSONB,
        nullable=True,
        default=None,
        deferred=True,
        deferred_group="security_sensitive",
    )
    """Hashed backup codes for MFA recovery."""

    roles: Mapped[list[UserRole]] = relationship(
        back_populates="user",
        lazy="selectin",
        uselist=True,
        cascade="all, delete",
    )
    teams: Mapped[list[TeamMember]] = relationship(
        back_populates="user",
        lazy="selectin",
        uselist=True,
        cascade="all, delete",
        viewonly=True,
    )
    oauth_accounts: Mapped[list[UserOAuthAccount]] = relationship(
        back_populates="user",
        lazy="noload",
        cascade="all, delete",
        uselist=True,
    )
    verification_tokens: Mapped[list[EmailVerificationToken]] = relationship(
        back_populates="user",
        lazy="noload",
        cascade="all, delete",
        uselist=True,
    )
    reset_tokens: Mapped[list[PasswordResetToken]] = relationship(
        back_populates="user",
        lazy="noload",
        cascade="all, delete",
        uselist=True,
    )
    refresh_tokens: Mapped[list[RefreshToken]] = relationship(
        back_populates="user",
        lazy="noload",
        cascade="all, delete-orphan",
        uselist=True,
    )

    @hybrid_property
    def has_password(self) -> bool:
        return self.hashed_password is not None

    @hybrid_property
    def has_mfa(self) -> bool:
        return self.is_two_factor_enabled