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

1"""User model. 

2 

3Implements the AuthenticatedUserProtocol protocol without heavy dependencies. 

4""" 

5 

6from __future__ import annotations 

7 

8from dataclasses import dataclass 

9from datetime import UTC, datetime 

10from typing import Any, cast 

11import uuid 

12 

13from lexigram.domain import DomainModel 

14from lexigram.validation import Field 

15 

16 

17@dataclass(init=False) 

18class UserCredentials(DomainModel): 

19 """Sensitive credential data for a user. 

20 

21 This model holds fields that must **never** appear in API responses or 

22 public-facing representations. 

23 """ 

24 

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 ) 

30 

31 

32@dataclass(init=False) 

33class User(DomainModel): 

34 """User model representing an authenticated user. 

35 

36 This model serves as the core user representation within the auth package. 

37 It implements the AuthenticatedUserProtocol protocol. 

38 """ 

39 

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

49 

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 ) 

54 

55 # Profile and metadata 

56 profile: dict[str, Any] = Field( 

57 default_factory=dict, description="User profile data" 

58 ) 

59 

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

73 

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 ) 

83 

84 def has_role(self, role: str) -> bool: 

85 """Check if user has a specific role.""" 

86 return role in self.roles 

87 

88 def has_permission(self, permission: str) -> bool: 

89 """Check if user has a specific permission.""" 

90 return permission in self.permissions 

91 

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 ) 

105 

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 ) 

119 

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 ) 

133 

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 ) 

147 

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 ) 

160 

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"}) 

164 

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

172 

173 

174__all__ = [ 

175 "User", 

176 "UserCredentials", 

177]