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:
Ejiro Asiuwhu
2026-03-24 22:23:11 +01:00
parent 6b4cd35875
commit 488e72a9cf
8 changed files with 442 additions and 0 deletions
@@ -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)