"""Пользователи, роли и refresh-токены.""" from __future__ import annotations import enum import uuid from datetime import datetime from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, String from sqlalchemy.orm import Mapped, mapped_column, relationship from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin class UserRole(enum.StrEnum): """Роли фиксированы кодом: набор прав завязан на фискальную ответственность.""" ADMIN = "admin" # всё, включая админку, кассы и ключи CASHIER = "cashier" # очередь и пробитие чеков VIEWER = "viewer" # только чтение class User(UUIDPrimaryKeyMixin, TimestampMixin, Base): __tablename__ = "users" email: Mapped[str] = mapped_column(String(320), unique=True, index=True, nullable=False) password_hash: Mapped[str] = mapped_column(String(255), nullable=False) full_name: Mapped[str] = mapped_column(String(255), nullable=False) role: Mapped[UserRole] = mapped_column( Enum(UserRole, name="user_role", values_callable=lambda e: [i.value for i in e]), nullable=False, default=UserRole.CASHIER, ) is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) refresh_tokens: Mapped[list[RefreshToken]] = relationship( back_populates="user", cascade="all, delete-orphan" ) def __repr__(self) -> str: return f"" class RefreshToken(UUIDPrimaryKeyMixin, TimestampMixin, Base): """Выданные refresh-токены. Храним SHA-256 хеш, а не сам токен: утечка дампа БД не должна давать возможность войти. Ротация — выдача нового токена с проставлением `replaced_by_id` у старого, что позволяет обнаружить повторное использование уже израсходованного токена. """ __tablename__ = "refresh_tokens" user_id: Mapped[uuid.UUID] = mapped_column( ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False ) token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False) expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) replaced_by_id: Mapped[uuid.UUID | None] = mapped_column( ForeignKey("refresh_tokens.id", ondelete="SET NULL") ) user_agent: Mapped[str | None] = mapped_column(String(512)) ip: Mapped[str | None] = mapped_column(String(64)) user: Mapped[User] = relationship(back_populates="refresh_tokens") @property def is_active(self) -> bool: from datetime import UTC return self.revoked_at is None and self.expires_at > datetime.now(UTC)