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,
)