Coverage for src/lexigram/auth/models/user.py: 76%
59 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-26 00:58 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-26 00:58 +0800
1"""User model.
3Implements the AuthenticatedUserProtocol protocol without heavy dependencies.
4"""
6from __future__ import annotations
8from dataclasses import dataclass
9from datetime import UTC, datetime
10from typing import Any, cast
11import uuid
13from lexigram.domain import DomainModel
14from lexigram.validation import Field
17@dataclass(init=False)
18class UserCredentials(DomainModel):
19 """Sensitive credential data for a user.
21 This model holds fields that must **never** appear in API responses or
22 public-facing representations.
23 """
25 user_id: str = Field(description="The owning user's identifier")
26 hashed_password: str | None = Field(default=None, description="Hashed password")
27 previous_hashes: list[str] = Field(
28 default_factory=list, description="Password history for reuse prevention"
29 )
32@dataclass(init=False)
33class User(DomainModel):
34 """User model representing an authenticated user.
36 This model serves as the core user representation within the auth package.
37 It implements the AuthenticatedUserProtocol protocol.
38 """
40 user_id: str = Field(
41 default_factory=lambda: str(uuid.uuid4()),
42 description="Primary user identifier",
43 )
44 email: str = Field(default="", description="User email address")
45 name: str | None = Field(default=None, description="Display name")
46 is_active: bool = Field(default=True, description="Account active status")
47 is_verified: bool = Field(default=False, description="Email verification status")
48 is_superuser: bool = Field(default=False, description="Administrative status")
50 roles: list[str] = Field(default_factory=list, description="Assigned roles")
51 permissions: list[str] = Field(
52 default_factory=list, description="Directly assigned permissions"
53 )
55 # Profile and metadata
56 profile: dict[str, Any] = Field(
57 default_factory=dict, description="User profile data"
58 )
60 # Timestamps and tracking
61 created_at: datetime | None = Field(
62 default_factory=lambda: datetime.now(UTC),
63 description="Account creation timestamp",
64 )
65 updated_at: datetime | None = Field(
66 default_factory=lambda: datetime.now(UTC),
67 description="Last update timestamp",
68 )
69 last_login_at: datetime | None = Field(
70 default=None, description="Last successful login timestamp"
71 )
72 login_count: int = Field(default=0, description="Total number of logins")
74 # Optional metadata for specialized use cases
75 delegations: list[Any] = Field(
76 default_factory=list, description="Active identity delegations"
77 )
78 _request_metadata: dict[str, Any] = Field(
79 default_factory=dict,
80 exclude=True,
81 description="Internal request metadata (excluded from dump)",
82 )
84 def has_role(self, role: str) -> bool:
85 """Check if user has a specific role."""
86 return role in self.roles
88 def has_permission(self, permission: str) -> bool:
89 """Check if user has a specific permission."""
90 return permission in self.permissions
92 def with_role(self, role: str) -> User:
93 """Return a new User with the given role added."""
94 if role in self.roles:
95 return self
96 return cast(
97 "User",
98 self.model_copy(
99 update={
100 "roles": [*self.roles, role],
101 "updated_at": datetime.now(UTC),
102 }
103 ),
104 )
106 def without_role(self, role: str) -> User:
107 """Return a new User with the given role removed."""
108 if role not in self.roles:
109 return self
110 return cast(
111 "User",
112 self.model_copy(
113 update={
114 "roles": [r for r in self.roles if r != role],
115 "updated_at": datetime.now(UTC),
116 }
117 ),
118 )
120 def with_permission(self, permission: str) -> User:
121 """Return a new User with the given permission added."""
122 if permission in self.permissions:
123 return self
124 return cast(
125 "User",
126 self.model_copy(
127 update={
128 "permissions": [*self.permissions, permission],
129 "updated_at": datetime.now(UTC),
130 }
131 ),
132 )
134 def without_permission(self, permission: str) -> User:
135 """Return a new User with the given permission removed."""
136 if permission not in self.permissions:
137 return self
138 return cast(
139 "User",
140 self.model_copy(
141 update={
142 "permissions": [p for p in self.permissions if p != permission],
143 "updated_at": datetime.now(UTC),
144 }
145 ),
146 )
148 def record_login(self) -> User:
149 """Return a new User with the current login recorded."""
150 return cast(
151 "User",
152 self.model_copy(
153 update={
154 "last_login_at": datetime.now(UTC),
155 "login_count": self.login_count + 1,
156 "updated_at": datetime.now(UTC),
157 }
158 ),
159 )
161 def to_public_dict(self) -> dict[str, Any]:
162 """Return a safe public representation of the user."""
163 return self.model_dump(exclude={"_request_metadata"})
165 @classmethod
166 def from_dict(cls, data: dict[str, Any]) -> User:
167 """Create from dictionary."""
168 # Handle user_id/id alias if needed (DomainModel often handles aliases if configured)
169 if "user_id" not in data and "id" in data:
170 data["user_id"] = data["id"]
171 return cast("User", cls.model_validate(data))
174__all__ = [
175 "User",
176 "UserCredentials",
177]