Coverage for opt/mealie/lib/python3.12/site-packages/mealie/schema/user/user.py: 92%

205 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-11-25 17:29 +0000

1from datetime import UTC, datetime, timedelta 1a

2from pathlib import Path 1a

3from typing import Annotated, Any 1a

4from uuid import UUID 1a

5 

6from pydantic import UUID4, BaseModel, ConfigDict, Field, StringConstraints, field_validator 1a

7from sqlalchemy.orm import joinedload, selectinload 1a

8from sqlalchemy.orm.interfaces import LoaderOption 1a

9 

10from mealie.core.config import get_app_dirs, get_app_settings 1a

11from mealie.db.models.recipe.recipe import RecipeModel 1a

12from mealie.db.models.users import User 1a

13from mealie.db.models.users.user_to_recipe import UserToRecipe 1a

14from mealie.db.models.users.users import AuthMethod, LongLiveToken 1a

15from mealie.schema._mealie import MealieModel 1a

16from mealie.schema.group.group_preferences import ReadGroupPreferences 1a

17from mealie.schema.household.webhook import CreateWebhook, ReadWebhook 1a

18from mealie.schema.response.pagination import PaginationBase 1a

19 

20from ...db.models.group import Group 1a

21from ..recipe import CategoryBase 1a

22 

23DEFAULT_INTEGRATION_ID = "generic" 1a

24settings = get_app_settings() 1a

25 

26 

27class LongLiveTokenIn(MealieModel): 1a

28 name: str 1a

29 integration_id: str = DEFAULT_INTEGRATION_ID 1a

30 

31 

32class LongLiveTokenOut(MealieModel): 1a

33 name: str 1a

34 id: int 1a

35 created_at: datetime | None = None 1a

36 model_config = ConfigDict(from_attributes=True) 1a

37 

38 @classmethod 1a

39 def loader_options(cls) -> list[LoaderOption]: 1a

40 return [joinedload(LongLiveToken.user)] 

41 

42 

43class LongLiveTokenCreateResponse(LongLiveTokenOut): 1a

44 """Should ONLY be used when creating a new token, as the token field is sensitive""" 

45 

46 token: str 1a

47 

48 

49class CreateToken(LongLiveTokenIn): 1a

50 user_id: UUID4 1a

51 token: str 1a

52 model_config = ConfigDict(from_attributes=True) 1a

53 

54 

55class DeleteTokenResponse(MealieModel): 1a

56 token_delete: str 1a

57 model_config = ConfigDict(from_attributes=True) 1a

58 

59 

60class ChangePassword(MealieModel): 1a

61 current_password: str = "" 1a

62 new_password: str = Field(..., min_length=8) 1a

63 

64 

65class GroupBase(MealieModel): 1a

66 name: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] 1a

67 model_config = ConfigDict(from_attributes=True) 1a

68 

69 

70class UserRatingSummary(MealieModel): 1a

71 recipe_id: UUID4 1a

72 rating: float | None = None 1a

73 is_favorite: Annotated[bool, Field(validate_default=True)] = False 1a

74 

75 model_config = ConfigDict(from_attributes=True) 1a

76 

77 @field_validator("is_favorite", mode="before") 1a

78 def convert_is_favorite(cls, v: Any) -> bool: 1a

79 if v is None: 

80 return False 

81 else: 

82 return v 

83 

84 

85class UserRatingCreate(UserRatingSummary): 1a

86 user_id: UUID4 1a

87 

88 

89class UserRatingUpdate(MealieModel): 1a

90 rating: float | None = None 1a

91 is_favorite: bool | None = None 1a

92 

93 

94class UserRatingOut(UserRatingCreate): 1a

95 id: UUID4 1a

96 

97 @classmethod 1a

98 def loader_options(cls) -> list[LoaderOption]: 1a

99 return [ 

100 joinedload(UserToRecipe.recipe).joinedload(RecipeModel.user).load_only(User.household_id, User.group_id) 

101 ] 

102 

103 

104class UserRatings[DataT: BaseModel](BaseModel): 1a

105 ratings: list[DataT] 1a

106 

107 

108class UserBase(MealieModel): 1a

109 id: UUID4 | None = None 1a

110 username: str | None = None 1a

111 full_name: str | None = None 1a

112 email: Annotated[str, StringConstraints(to_lower=True, strip_whitespace=True)] 1a

113 auth_method: AuthMethod = AuthMethod.MEALIE 1a

114 admin: bool = False 1a

115 group: str | None = None 1a

116 household: str | None = None 1a

117 advanced: bool = False 1a

118 

119 can_invite: bool = False 1a

120 can_manage: bool = False 1a

121 can_manage_household: bool = False 1a

122 can_organize: bool = False 1a

123 model_config = ConfigDict( 1a

124 from_attributes=True, 

125 json_schema_extra={ 

126 "example": { 

127 "username": "ChangeMe", 

128 "fullName": "Change Me", 

129 "email": "changeme@example.com", 

130 "group": settings.DEFAULT_GROUP, 

131 "household": settings.DEFAULT_HOUSEHOLD, 

132 "admin": "false", 

133 } 

134 }, 

135 ) 

136 

137 @field_validator("group", mode="before") 1a

138 def convert_group_to_name(cls, v): 1a

139 if not v or isinstance(v, str): 2a c o Y M Z d 0 1 e 2 3 f 4 5 6 7 8 9 N O P ! Q # $ % ' ( ) g * + , - . p / : ; = h ? @ [ ] ^ _ ` i { | } ~ abbbcbdbebj fbgbhbibjbkbk lbmbnbobpbqbrbl sbtbubq r s R t u v w m x S y z A B C D E F G H I J K vbn wbxbL T U V W X ybzbAbb

140 return v 1pb

141 

142 try: 2a c o Y M Z d 0 1 e 2 3 f 4 5 6 7 8 9 N O P ! Q # $ % ' ( ) g * + , - . p / : ; = h ? @ [ ] ^ _ ` i { | } ~ abbbcbdbebj fbgbhbibjbkbk lbmbnbobpbqbrbl sbtbubq r s R t u v w m x S y z A B C D E F G H I J K vbn wbxbL T U V W X ybzbAbb

143 return v.name 2a c o Y M Z d 0 1 e 2 3 f 4 5 6 7 8 9 N O P ! Q # $ % ' ( ) g * + , - . p / : ; = h ? @ [ ] ^ _ ` i { | } ~ abbbcbdbebj fbgbhbibjbkbk lbmbnbobpbqbrbl sbtbubq r s R t u v w m x S y z A B C D E F G H I J K vbn wbxbL T U V W X ybzbAbb

144 except AttributeError: 

145 return v 

146 

147 @field_validator("household", mode="before") 1a

148 def convert_household_to_name(cls, v): 1a

149 if not v or isinstance(v, str): 2a c o Y M Z d 0 1 e 2 3 f 4 5 6 7 8 9 N O P ! Q # $ % ' ( ) g * + , - . p / : ; = h ? @ [ ] ^ _ ` i { | } ~ abbbcbdbebj fbgbhbibjbkbk lbmbnbobpbqbrbl sbtbubq r s R t u v w m x S y z A B C D E F G H I J K vbn wbxbL T U V W X ybzbAbb

150 return v 1pb

151 

152 try: 2a c o Y M Z d 0 1 e 2 3 f 4 5 6 7 8 9 N O P ! Q # $ % ' ( ) g * + , - . p / : ; = h ? @ [ ] ^ _ ` i { | } ~ abbbcbdbebj fbgbhbibjbkbk lbmbnbobpbqbrbl sbtbubq r s R t u v w m x S y z A B C D E F G H I J K vbn wbxbL T U V W X ybzbAbb

153 return v.name 2a c o Y M Z d 0 1 e 2 3 f 4 5 6 7 8 9 N O P ! Q # $ % ' ( ) g * + , - . p / : ; = h ? @ [ ] ^ _ ` i { | } ~ abbbcbdbebj fbgbhbibjbkbk lbmbnbobpbqbrbl sbtbubq r s R t u v w m x S y z A B C D E F G H I J K vbn wbxbL T U V W X ybzbAbb

154 except AttributeError: 

155 return v 

156 

157 

158class UserIn(UserBase): 1a

159 username: str 1a

160 full_name: str 1a

161 password: str 1a

162 

163 

164class UserOut(UserBase): 1a

165 id: UUID4 1a

166 group: str 1a

167 group_id: UUID4 1a

168 group_slug: str 1a

169 household: str 1a

170 household_id: UUID4 1a

171 household_slug: str 1a

172 tokens: list[LongLiveTokenOut] | None = None 1a

173 cache_key: str 1a

174 model_config = ConfigDict(from_attributes=True) 1a

175 

176 @property 1a

177 def is_default_user(self) -> bool: 1a

178 return self.email == settings._DEFAULT_EMAIL.strip().lower() 

179 

180 @classmethod 1a

181 def loader_options(cls) -> list[LoaderOption]: 1a

182 return [joinedload(User.group), joinedload(User.household), joinedload(User.tokens)] 1qstuvwxyzABCEFGHIJKLTXb

183 

184 

185class UserSummary(MealieModel): 1a

186 id: UUID4 1a

187 group_id: UUID4 1a

188 household_id: UUID4 1a

189 username: str 1a

190 full_name: str 1a

191 model_config = ConfigDict(from_attributes=True) 1a

192 

193 

194class UserPagination(PaginationBase): 1a

195 items: list[UserOut] 1a

196 

197 

198class UserSummaryPagination(PaginationBase): 1a

199 items: list[UserSummary] 1a

200 

201 

202class PrivateUser(UserOut): 1a

203 password: str 1a

204 login_attemps: int = 0 1a

205 locked_at: datetime | None = None 1a

206 model_config = ConfigDict(from_attributes=True) 1a

207 

208 @field_validator("login_attemps", mode="before") 1a

209 @classmethod 1a

210 def none_to_zero(cls, v): 1a

211 return 0 if v is None else v 2a c o Y M Z d 0 1 e 2 3 f 4 5 6 7 8 9 N O P ! Q # $ % ' ( ) g * + , - . p / : ; = h ? @ [ ] ^ _ ` i { | } ~ abbbcbdbebj fbgbhbibjbkbk lbmbnbobpbqbrbl sbtbubq r s R t u v w m x S y z A B C D E F G H I J K vbn wbxbL T U V W X ybzbAbb

212 

213 @staticmethod 1a

214 def get_directory(user_id: UUID4 | str) -> Path: 1a

215 user_dir = get_app_dirs().USER_DIR / str(user_id) 1ac

216 user_dir.mkdir(parents=True, exist_ok=True) 1ac

217 return user_dir 1ac

218 

219 @property 1a

220 def is_locked(self) -> bool: 1a

221 if self.locked_at is None: 221 ↛ 224line 221 didn't jump to line 224 because the condition on line 221 was always true1odefghijklrmDnb

222 return False 1odefghijklrmDnb

223 

224 lockout_expires_at = self.locked_at + timedelta(hours=get_app_settings().SECURITY_USER_LOCKOUT_TIME) 

225 return lockout_expires_at > datetime.now(UTC) 

226 

227 def directory(self) -> Path: 1a

228 return PrivateUser.get_directory(self.id) 1ac

229 

230 @classmethod 1a

231 def loader_options(cls) -> list[LoaderOption]: 1a

232 return [joinedload(User.group), joinedload(User.household), joinedload(User.tokens)] 2a c o Y M Z d 0 1 e BbCbDb2 3 f 4 5 6 7 8 9 N O P ! Q # $ % ' ( ) g * + , - . p / : ; = h ? @ [ ] ^ _ ` i { | } ~ abbbcbdbebj fbgbhbibjbkbk lbmbnbobpbqbrbl sbtbubq r s R t u v w m x S y z A B C D E F G H I J K vbn wbxbL T U V W X ybzbb

233 

234 

235class UpdateGroup(GroupBase): 1a

236 id: UUID4 1a

237 name: str 1a

238 slug: str 1a

239 categories: list[CategoryBase] | None = [] 1a

240 

241 webhooks: list[CreateWebhook] = [] 1a

242 

243 

244class GroupHouseholdSummary(MealieModel): 1a

245 id: UUID4 1a

246 name: str 1a

247 model_config = ConfigDict(from_attributes=True) 1a

248 

249 

250class GroupInDB(UpdateGroup): 1a

251 households: list[GroupHouseholdSummary] | None = None 1a

252 users: list[UserSummary] | None = None 1a

253 preferences: ReadGroupPreferences | None = None 1a

254 webhooks: list[ReadWebhook] = [] 1a

255 

256 model_config = ConfigDict(from_attributes=True) 1a

257 

258 @staticmethod 1a

259 def get_directory(id: UUID4) -> Path: 1a

260 group_dir = get_app_dirs().GROUPS_DIR / str(id) 

261 group_dir.mkdir(parents=True, exist_ok=True) 

262 return group_dir 

263 

264 @staticmethod 1a

265 def get_export_directory(id: UUID) -> Path: 1a

266 export_dir = GroupInDB.get_directory(id) / "export" 

267 export_dir.mkdir(parents=True, exist_ok=True) 

268 return export_dir 

269 

270 @property 1a

271 def directory(self) -> Path: 1a

272 return GroupInDB.get_directory(self.id) 

273 

274 @property 1a

275 def exports(self) -> Path: 1a

276 return GroupInDB.get_export_directory(self.id) 

277 

278 @classmethod 1a

279 def loader_options(cls) -> list[LoaderOption]: 1a

280 return [ 2EbFbM d e f N O P Q g h i j k l q s R t u v w m x S y z A B C E F G H I J K n L U V W b

281 joinedload(Group.categories), 

282 joinedload(Group.webhooks), 

283 joinedload(Group.preferences), 

284 joinedload(Group.households), 

285 selectinload(Group.users).joinedload(User.group), 

286 selectinload(Group.users).joinedload(User.tokens), 

287 ] 

288 

289 

290class GroupSummary(GroupBase): 1a

291 id: UUID4 1a

292 name: str 1a

293 slug: str 1a

294 preferences: ReadGroupPreferences | None = None 1a

295 

296 @classmethod 1a

297 def loader_options(cls) -> list[LoaderOption]: 1a

298 return [ 

299 joinedload(Group.preferences), 

300 ] 

301 

302 

303class GroupPagination(PaginationBase): 1a

304 items: list[GroupInDB] 1a

305 

306 

307class LongLiveTokenInDB(CreateToken): 1a

308 id: int 1a

309 user: PrivateUser 1a

310 model_config = ConfigDict(from_attributes=True) 1a