Source code for regstack.models.user

from __future__ import annotations

from datetime import UTC, datetime
from typing import Annotated, Any

from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator

from regstack.models._objectid import IdStr


def _utcnow() -> datetime:
    return datetime.now(UTC)


PasswordStr = Annotated[str, Field(min_length=8, max_length=128)]


[docs] class BaseUser(BaseModel): """Persisted user document. The default field set covers what both winebox and putplace need today. Hosts add their own fields by subclassing or by registering an extension mixin via ``RegStack.extend_user_model``. """ # `extra="allow"` here (and only here) is deliberate: hosts that # add their own user fields via subclassing or # `RegStack.extend_user_model` need those fields to round-trip # through the DB cleanly. Every request/response model # (UserCreate, UserUpdate, UserPublic) uses `extra="forbid"` # because those are the external API contract. model_config = ConfigDict(populate_by_name=True, extra="allow") id: IdStr | None = Field(default=None, alias="_id") email: EmailStr hashed_password: str | None = None """Argon2id hash of the user's password, or ``None`` for users who only ever signed in via OAuth and never set a password. OAuth-only users can use the password-reset flow as a "set initial password" step.""" is_active: bool = True is_verified: bool = False is_superuser: bool = False full_name: str | None = None phone_number: str | None = None is_mfa_enabled: bool = False created_at: datetime = Field(default_factory=_utcnow) updated_at: datetime = Field(default_factory=_utcnow) last_login: datetime | None = None tokens_invalidated_after: datetime | None = None @property def is_admin(self) -> bool: """Alias kept for parity with putplace's ``is_admin`` field name.""" return self.is_superuser
[docs] def to_mongo(self) -> dict[str, Any]: data = self.model_dump(by_alias=True, exclude_none=True) if data.get("_id") is None: data.pop("_id", None) return data
[docs] class UserCreate(BaseModel): model_config = ConfigDict(extra="forbid") email: EmailStr password: PasswordStr full_name: str | None = Field(default=None, max_length=200) @field_validator("password") @classmethod def _strip(cls, v: str) -> str: return v
class UserUpdate(BaseModel): model_config = ConfigDict(extra="forbid") full_name: str | None = Field(default=None, max_length=200)
[docs] class UserPublic(BaseModel): """Safe-to-serialise projection of a user (no password hash). The on-wire JSON contract uses ``id`` — the conventional HTTP API field name — even though the underlying Mongo document keys the user by ``_id``. ``BaseUser`` keeps the alias (it round-trips to Mongo via ``to_mongo()``), but ``UserPublic`` never touches Mongo so there's no reason to leak the BSON convention into the API contract every host has to build its clients against. """ id: str email: EmailStr is_active: bool is_verified: bool is_superuser: bool full_name: str | None = None phone_number: str | None = None is_mfa_enabled: bool = False created_at: datetime updated_at: datetime last_login: datetime | None = None tokens_invalidated_after: datetime | None = None """Bulk-revoke cutoff. SPAs comparing this to their session JWT's `iat` can tell when the token they hold has been invalidated by a password change / email change without making another request."""
[docs] @classmethod def from_user(cls, user: BaseUser) -> UserPublic: if user.id is None: raise ValueError("Cannot serialise a user without an id") return cls( id=user.id, email=user.email, is_active=user.is_active, is_verified=user.is_verified, is_superuser=user.is_superuser, full_name=user.full_name, phone_number=user.phone_number, is_mfa_enabled=user.is_mfa_enabled, created_at=user.created_at, updated_at=user.updated_at, last_login=user.last_login, tokens_invalidated_after=user.tokens_invalidated_after, )