mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
feat: add user, team, session, contact channel, and permission pydantic models
- BaseUser and ServerUser with camelCase aliases and millis-to-datetime properties - Team, ServerTeam, TeamMemberProfile, TeamInvitation with timestamp conversions - ActiveSession with GeoInfo nested model - ContactChannel and TeamPermission/ProjectPermission - Tests for user and team model parsing, inheritance, and field conversion
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
"""Contact channel models for Stack Auth."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from stack_auth.models._base import StackAuthModel
|
||||
|
||||
|
||||
class ContactChannel(StackAuthModel):
|
||||
"""A contact channel (email address) associated with a user."""
|
||||
|
||||
id: str
|
||||
value: str
|
||||
type: str = "email"
|
||||
is_primary: bool = Field(False, alias="isPrimary")
|
||||
is_verified: bool = Field(False, alias="isVerified")
|
||||
used_for_auth: bool = Field(False, alias="usedForAuth")
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Permission models for Stack Auth."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from stack_auth.models._base import StackAuthModel
|
||||
|
||||
|
||||
class TeamPermission(StackAuthModel):
|
||||
"""A permission granted to a user within a team."""
|
||||
|
||||
id: str
|
||||
|
||||
|
||||
class ProjectPermission(StackAuthModel):
|
||||
"""A project-level permission granted to a user."""
|
||||
|
||||
id: str
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Session models for Stack Auth."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from stack_auth.models._base import StackAuthModel
|
||||
|
||||
|
||||
class GeoInfo(StackAuthModel):
|
||||
"""Geographic information derived from IP address."""
|
||||
|
||||
city: str | None = None
|
||||
region: str | None = None
|
||||
country: str | None = None
|
||||
country_name: str | None = Field(None, alias="countryName")
|
||||
latitude: float | None = None
|
||||
longitude: float | None = None
|
||||
|
||||
|
||||
class ActiveSession(StackAuthModel):
|
||||
"""Represents an active login session for a user."""
|
||||
|
||||
id: str
|
||||
user_id: str = Field(alias="userId")
|
||||
created_at_millis: int = Field(alias="createdAtMillis")
|
||||
is_impersonation: bool = Field(False, alias="isImpersonation")
|
||||
last_used_at_millis: int | None = Field(None, alias="lastUsedAtMillis")
|
||||
is_current_session: bool = Field(False, alias="isCurrentSession")
|
||||
geo_info: GeoInfo | None = Field(None, alias="geoInfo")
|
||||
|
||||
@property
|
||||
def created_at(self) -> datetime:
|
||||
"""Convert created_at_millis to a UTC datetime."""
|
||||
return self._millis_to_datetime(self.created_at_millis) # type: ignore[return-value]
|
||||
|
||||
@property
|
||||
def last_used_at(self) -> datetime | None:
|
||||
"""Convert last_used_at_millis to a UTC datetime, or None."""
|
||||
return self._millis_to_datetime(self.last_used_at_millis)
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Team models for Stack Auth."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from stack_auth.models._base import StackAuthModel
|
||||
|
||||
|
||||
class Team(StackAuthModel):
|
||||
"""A team/organization that users can belong to."""
|
||||
|
||||
id: str
|
||||
display_name: str = Field(alias="displayName")
|
||||
profile_image_url: str | None = Field(None, alias="profileImageUrl")
|
||||
client_metadata: dict[str, Any] = Field(default_factory=dict, alias="clientMetadata")
|
||||
client_read_only_metadata: dict[str, Any] = Field(
|
||||
default_factory=dict, alias="clientReadOnlyMetadata"
|
||||
)
|
||||
|
||||
|
||||
class ServerTeam(Team):
|
||||
"""Server-side team with additional management capabilities."""
|
||||
|
||||
server_metadata: dict[str, Any] = Field(default_factory=dict, alias="serverMetadata")
|
||||
created_at_millis: int = Field(alias="createdAtMillis")
|
||||
|
||||
@property
|
||||
def created_at(self) -> datetime:
|
||||
"""Convert created_at_millis to a UTC datetime."""
|
||||
return self._millis_to_datetime(self.created_at_millis) # type: ignore[return-value]
|
||||
|
||||
|
||||
class TeamMemberProfile(StackAuthModel):
|
||||
"""A user's profile within a specific team."""
|
||||
|
||||
display_name: str | None = Field(None, alias="displayName")
|
||||
profile_image_url: str | None = Field(None, alias="profileImageUrl")
|
||||
|
||||
|
||||
class TeamInvitation(StackAuthModel):
|
||||
"""An invitation to join a team."""
|
||||
|
||||
id: str
|
||||
recipient_email: str | None = Field(None, alias="recipientEmail")
|
||||
expires_at_millis: int = Field(alias="expiresAtMillis")
|
||||
|
||||
@property
|
||||
def expires_at(self) -> datetime:
|
||||
"""Convert expires_at_millis to a UTC datetime."""
|
||||
return self._millis_to_datetime(self.expires_at_millis) # type: ignore[return-value]
|
||||
@@ -0,0 +1,55 @@
|
||||
"""User models for Stack Auth."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from stack_auth.models._base import StackAuthModel
|
||||
|
||||
|
||||
class BaseUser(StackAuthModel):
|
||||
"""Base user type with publicly safe properties.
|
||||
|
||||
All fields use camelCase aliases matching the Stack Auth API JSON format.
|
||||
"""
|
||||
|
||||
id: str
|
||||
display_name: str | None = Field(None, alias="displayName")
|
||||
primary_email: str | None = Field(None, alias="primaryEmail")
|
||||
primary_email_verified: bool = Field(False, alias="primaryEmailVerified")
|
||||
profile_image_url: str | None = Field(None, alias="profileImageUrl")
|
||||
signed_up_at_millis: int = Field(alias="signedUpAtMillis")
|
||||
last_active_at_millis: int | None = Field(None, alias="lastActiveAtMillis")
|
||||
client_metadata: dict[str, Any] = Field(default_factory=dict, alias="clientMetadata")
|
||||
client_read_only_metadata: dict[str, Any] = Field(
|
||||
default_factory=dict, alias="clientReadOnlyMetadata"
|
||||
)
|
||||
has_password: bool = Field(False, alias="hasPassword")
|
||||
otp_auth_enabled: bool = Field(False, alias="otpAuthEnabled")
|
||||
passkey_auth_enabled: bool = Field(False, alias="passkeyAuthEnabled")
|
||||
is_multi_factor_required: bool = Field(False, alias="isMultiFactorRequired")
|
||||
is_anonymous: bool = Field(False, alias="isAnonymous")
|
||||
is_restricted: bool = Field(False, alias="isRestricted")
|
||||
restricted_reason: dict[str, Any] | None = Field(None, alias="restrictedReason")
|
||||
|
||||
@property
|
||||
def signed_up_at(self) -> datetime:
|
||||
"""Convert signed_up_at_millis to a UTC datetime."""
|
||||
return self._millis_to_datetime(self.signed_up_at_millis) # type: ignore[return-value]
|
||||
|
||||
@property
|
||||
def last_active_at(self) -> datetime | None:
|
||||
"""Convert last_active_at_millis to a UTC datetime, or None."""
|
||||
return self._millis_to_datetime(self.last_active_at_millis)
|
||||
|
||||
|
||||
class ServerUser(BaseUser):
|
||||
"""Server-side user with full access to sensitive fields.
|
||||
|
||||
Extends BaseUser with server-only metadata.
|
||||
"""
|
||||
|
||||
server_metadata: dict[str, Any] = Field(default_factory=dict, alias="serverMetadata")
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Tests for team models (Team, ServerTeam, TeamMemberProfile, TeamInvitation)."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestTeam:
|
||||
"""Tests for the Team model."""
|
||||
|
||||
def test_parse_from_camel_case_json(self) -> None:
|
||||
from stack_auth.models.teams import Team
|
||||
|
||||
data = {
|
||||
"id": "t1",
|
||||
"displayName": "Team A",
|
||||
"clientMetadata": {},
|
||||
}
|
||||
team = Team.model_validate(data)
|
||||
assert team.id == "t1"
|
||||
assert team.display_name == "Team A"
|
||||
assert team.client_metadata == {}
|
||||
|
||||
def test_optional_fields(self) -> None:
|
||||
from stack_auth.models.teams import Team
|
||||
|
||||
data = {
|
||||
"id": "t1",
|
||||
"displayName": "Team A",
|
||||
"profileImageUrl": "https://example.com/img.png",
|
||||
"clientMetadata": {"key": "val"},
|
||||
"clientReadOnlyMetadata": {"ro": True},
|
||||
}
|
||||
team = Team.model_validate(data)
|
||||
assert team.profile_image_url == "https://example.com/img.png"
|
||||
assert team.client_read_only_metadata == {"ro": True}
|
||||
|
||||
|
||||
class TestServerTeam:
|
||||
"""Tests for the ServerTeam model."""
|
||||
|
||||
def test_inherits_team(self) -> None:
|
||||
from stack_auth.models.teams import ServerTeam, Team
|
||||
|
||||
assert issubclass(ServerTeam, Team)
|
||||
|
||||
def test_parse_with_server_fields(self) -> None:
|
||||
from stack_auth.models.teams import ServerTeam
|
||||
|
||||
data = {
|
||||
"id": "t1",
|
||||
"displayName": "Team A",
|
||||
"clientMetadata": {},
|
||||
"serverMetadata": {"internal": True},
|
||||
"createdAtMillis": 1711296000000,
|
||||
}
|
||||
team = ServerTeam.model_validate(data)
|
||||
assert team.server_metadata == {"internal": True}
|
||||
assert team.created_at == datetime(2024, 3, 24, 16, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
class TestTeamMemberProfile:
|
||||
"""Tests for the TeamMemberProfile model."""
|
||||
|
||||
def test_parse_from_camel_case(self) -> None:
|
||||
from stack_auth.models.teams import TeamMemberProfile
|
||||
|
||||
data = {
|
||||
"displayName": "John",
|
||||
"profileImageUrl": "https://example.com/img.png",
|
||||
}
|
||||
profile = TeamMemberProfile.model_validate(data)
|
||||
assert profile.display_name == "John"
|
||||
assert profile.profile_image_url == "https://example.com/img.png"
|
||||
|
||||
def test_nullable_fields(self) -> None:
|
||||
from stack_auth.models.teams import TeamMemberProfile
|
||||
|
||||
data = {}
|
||||
profile = TeamMemberProfile.model_validate(data)
|
||||
assert profile.display_name is None
|
||||
assert profile.profile_image_url is None
|
||||
|
||||
|
||||
class TestTeamInvitation:
|
||||
"""Tests for the TeamInvitation model."""
|
||||
|
||||
def test_parse_invitation(self) -> None:
|
||||
from stack_auth.models.teams import TeamInvitation
|
||||
|
||||
data = {
|
||||
"id": "inv1",
|
||||
"recipientEmail": "[email protected]",
|
||||
"expiresAtMillis": 1711296000000,
|
||||
}
|
||||
inv = TeamInvitation.model_validate(data)
|
||||
assert inv.id == "inv1"
|
||||
assert inv.recipient_email == "[email protected]"
|
||||
assert inv.expires_at == datetime(2024, 3, 24, 16, 0, tzinfo=timezone.utc)
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Tests for user models (BaseUser, ServerUser)."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestBaseUser:
|
||||
"""Tests for the BaseUser model."""
|
||||
|
||||
def test_parse_from_camel_case_json(self) -> None:
|
||||
from stack_auth.models.users import BaseUser
|
||||
|
||||
data = {
|
||||
"id": "u1",
|
||||
"displayName": "John",
|
||||
"primaryEmail": "[email protected]",
|
||||
"primaryEmailVerified": True,
|
||||
"signedUpAtMillis": 1711296000000,
|
||||
"hasPassword": True,
|
||||
"otpAuthEnabled": False,
|
||||
"passkeyAuthEnabled": False,
|
||||
"isMultiFactorRequired": False,
|
||||
"isAnonymous": False,
|
||||
"isRestricted": False,
|
||||
}
|
||||
user = BaseUser.model_validate(data)
|
||||
assert user.id == "u1"
|
||||
assert user.display_name == "John"
|
||||
assert user.primary_email == "[email protected]"
|
||||
assert user.primary_email_verified is True
|
||||
assert user.has_password is True
|
||||
assert user.otp_auth_enabled is False
|
||||
assert user.passkey_auth_enabled is False
|
||||
assert user.is_multi_factor_required is False
|
||||
assert user.is_anonymous is False
|
||||
assert user.is_restricted is False
|
||||
|
||||
def test_signed_up_at_millis_converts_to_datetime(self) -> None:
|
||||
from stack_auth.models.users import BaseUser
|
||||
|
||||
data = {
|
||||
"id": "u1",
|
||||
"signedUpAtMillis": 1711296000000,
|
||||
"hasPassword": True,
|
||||
"otpAuthEnabled": False,
|
||||
"passkeyAuthEnabled": False,
|
||||
"isMultiFactorRequired": False,
|
||||
"isAnonymous": False,
|
||||
"isRestricted": False,
|
||||
}
|
||||
user = BaseUser.model_validate(data)
|
||||
assert user.signed_up_at == datetime(2024, 3, 24, 16, 0, tzinfo=timezone.utc)
|
||||
|
||||
def test_nullable_timestamp_returns_none(self) -> None:
|
||||
from stack_auth.models.users import BaseUser
|
||||
|
||||
data = {
|
||||
"id": "u1",
|
||||
"signedUpAtMillis": 1711296000000,
|
||||
"hasPassword": True,
|
||||
"otpAuthEnabled": False,
|
||||
"passkeyAuthEnabled": False,
|
||||
"isMultiFactorRequired": False,
|
||||
"isAnonymous": False,
|
||||
"isRestricted": False,
|
||||
}
|
||||
user = BaseUser.model_validate(data)
|
||||
assert user.last_active_at is None
|
||||
|
||||
def test_extra_fields_ignored(self) -> None:
|
||||
from stack_auth.models.users import BaseUser
|
||||
|
||||
data = {
|
||||
"id": "u1",
|
||||
"signedUpAtMillis": 1711296000000,
|
||||
"hasPassword": True,
|
||||
"otpAuthEnabled": False,
|
||||
"passkeyAuthEnabled": False,
|
||||
"isMultiFactorRequired": False,
|
||||
"isAnonymous": False,
|
||||
"isRestricted": False,
|
||||
"futureField": 123,
|
||||
"anotherUnknown": "hello",
|
||||
}
|
||||
user = BaseUser.model_validate(data)
|
||||
assert user.id == "u1"
|
||||
|
||||
def test_populate_by_name_snake_case(self) -> None:
|
||||
from stack_auth.models.users import BaseUser
|
||||
|
||||
user = BaseUser(
|
||||
id="u1",
|
||||
display_name="John",
|
||||
signed_up_at_millis=1711296000000,
|
||||
has_password=True,
|
||||
otp_auth_enabled=False,
|
||||
passkey_auth_enabled=False,
|
||||
is_multi_factor_required=False,
|
||||
is_anonymous=False,
|
||||
is_restricted=False,
|
||||
)
|
||||
assert user.display_name == "John"
|
||||
|
||||
def test_restricted_reason_parses(self) -> None:
|
||||
from stack_auth.models.users import BaseUser
|
||||
|
||||
data = {
|
||||
"id": "u1",
|
||||
"signedUpAtMillis": 1711296000000,
|
||||
"hasPassword": False,
|
||||
"otpAuthEnabled": False,
|
||||
"passkeyAuthEnabled": False,
|
||||
"isMultiFactorRequired": False,
|
||||
"isAnonymous": False,
|
||||
"isRestricted": True,
|
||||
"restrictedReason": {"type": "email_not_verified"},
|
||||
}
|
||||
user = BaseUser.model_validate(data)
|
||||
assert user.is_restricted is True
|
||||
assert user.restricted_reason == {"type": "email_not_verified"}
|
||||
|
||||
def test_millis_to_datetime_none(self) -> None:
|
||||
from stack_auth.models._base import StackAuthModel
|
||||
|
||||
assert StackAuthModel._millis_to_datetime(None) is None
|
||||
|
||||
|
||||
class TestServerUser:
|
||||
"""Tests for the ServerUser model."""
|
||||
|
||||
def test_inherits_base_user(self) -> None:
|
||||
from stack_auth.models.users import BaseUser, ServerUser
|
||||
|
||||
assert issubclass(ServerUser, BaseUser)
|
||||
|
||||
def test_parse_with_server_fields(self) -> None:
|
||||
from stack_auth.models.users import ServerUser
|
||||
|
||||
data = {
|
||||
"id": "u1",
|
||||
"displayName": "John",
|
||||
"primaryEmail": "[email protected]",
|
||||
"primaryEmailVerified": True,
|
||||
"signedUpAtMillis": 1711296000000,
|
||||
"lastActiveAtMillis": 1711296000000,
|
||||
"hasPassword": True,
|
||||
"otpAuthEnabled": False,
|
||||
"passkeyAuthEnabled": False,
|
||||
"isMultiFactorRequired": False,
|
||||
"isAnonymous": False,
|
||||
"isRestricted": False,
|
||||
"serverMetadata": {"role": "admin"},
|
||||
}
|
||||
user = ServerUser.model_validate(data)
|
||||
assert user.server_metadata == {"role": "admin"}
|
||||
assert user.last_active_at == datetime(2024, 3, 24, 16, 0, tzinfo=timezone.utc)
|
||||
Reference in New Issue
Block a user