Coverage for apps / recipes / models.py: 96%

127 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-02-14 19:13 +0000

1from django.db import models 

2 

3 

4class Recipe(models.Model): 

5 """Recipe model with full recipe-scrapers field support.""" 

6 

7 # Source information 

8 source_url = models.URLField(max_length=2000, null=True, blank=True, db_index=True) 

9 canonical_url = models.URLField(max_length=2000, blank=True) 

10 host = models.CharField(max_length=255) # e.g., "allrecipes.com" 

11 site_name = models.CharField(max_length=255, blank=True) 

12 

13 # Core content 

14 title = models.CharField(max_length=500) 

15 author = models.CharField(max_length=255, blank=True) 

16 description = models.TextField(blank=True) 

17 

18 # Images (stored locally) 

19 image = models.ImageField(upload_to="recipe_images/", blank=True) 

20 image_url = models.URLField(max_length=2000, blank=True) 

21 

22 # Ingredients 

23 ingredients = models.JSONField(default=list) 

24 ingredient_groups = models.JSONField(default=list) 

25 

26 # Instructions 

27 instructions = models.JSONField(default=list) 

28 instructions_text = models.TextField(blank=True) 

29 

30 # Timing (in minutes) 

31 prep_time = models.PositiveIntegerField(null=True, blank=True) 

32 cook_time = models.PositiveIntegerField(null=True, blank=True) 

33 total_time = models.PositiveIntegerField(null=True, blank=True) 

34 

35 # Servings 

36 yields = models.CharField(max_length=100, blank=True) 

37 servings = models.PositiveIntegerField(null=True, blank=True) 

38 

39 # Categorization 

40 category = models.CharField(max_length=100, blank=True) 

41 cuisine = models.CharField(max_length=100, blank=True) 

42 cooking_method = models.CharField(max_length=100, blank=True) 

43 keywords = models.JSONField(default=list) 

44 dietary_restrictions = models.JSONField(default=list) 

45 

46 # Equipment and extras 

47 equipment = models.JSONField(default=list) 

48 

49 # Nutrition (scraped from source) 

50 nutrition = models.JSONField(default=dict) 

51 

52 # Ratings 

53 rating = models.FloatField(null=True, blank=True) 

54 rating_count = models.PositiveIntegerField(null=True, blank=True) 

55 

56 # Language 

57 language = models.CharField(max_length=10, blank=True) 

58 

59 # Links 

60 links = models.JSONField(default=list) 

61 

62 # AI-generated content 

63 ai_tips = models.JSONField(default=list, blank=True) 

64 

65 # Profile ownership - each recipe belongs to a profile 

66 profile = models.ForeignKey( 

67 "profiles.Profile", 

68 on_delete=models.CASCADE, 

69 related_name="recipes", 

70 ) 

71 

72 # Remix tracking 

73 is_remix = models.BooleanField(default=False) 

74 remix_profile = models.ForeignKey( 

75 "profiles.Profile", 

76 on_delete=models.CASCADE, 

77 null=True, 

78 blank=True, 

79 related_name="remixes", 

80 ) 

81 remixed_from = models.ForeignKey( 

82 "self", 

83 on_delete=models.SET_NULL, 

84 null=True, 

85 blank=True, 

86 related_name="remix_children", 

87 ) 

88 

89 # Timestamps 

90 scraped_at = models.DateTimeField(auto_now_add=True) 

91 updated_at = models.DateTimeField(auto_now=True) 

92 

93 class Meta: 

94 indexes = [ 

95 models.Index(fields=["host"]), 

96 models.Index(fields=["is_remix"]), 

97 models.Index(fields=["scraped_at"]), 

98 models.Index(fields=["profile"]), 

99 models.Index(fields=["remixed_from"]), 

100 ] 

101 

102 def __str__(self): 

103 return self.title 

104 

105 

106class SearchSource(models.Model): 

107 """Curated recipe search source.""" 

108 

109 host = models.CharField(max_length=255, unique=True) 

110 name = models.CharField(max_length=255) 

111 is_enabled = models.BooleanField(default=True) 

112 search_url_template = models.CharField(max_length=500) 

113 result_selector = models.CharField(max_length=255, blank=True) 

114 logo_url = models.URLField(blank=True) 

115 

116 # Maintenance tracking 

117 last_validated_at = models.DateTimeField(null=True, blank=True) 

118 consecutive_failures = models.PositiveIntegerField(default=0) 

119 needs_attention = models.BooleanField(default=False) 

120 

121 class Meta: 

122 ordering = ["name"] 

123 

124 def __str__(self): 

125 return self.name 

126 

127 

128class RecipeFavorite(models.Model): 

129 """User's favorite recipes, scoped to profile.""" 

130 

131 profile = models.ForeignKey( 

132 "profiles.Profile", 

133 on_delete=models.CASCADE, 

134 related_name="favorites", 

135 ) 

136 recipe = models.ForeignKey( 

137 Recipe, 

138 on_delete=models.CASCADE, 

139 related_name="favorited_by", 

140 ) 

141 created_at = models.DateTimeField(auto_now_add=True) 

142 

143 class Meta: 

144 unique_together = ["profile", "recipe"] 

145 ordering = ["-created_at"] 

146 

147 def __str__(self): 

148 return f"{self.profile.name} - {self.recipe.title}" 

149 

150 

151class RecipeCollection(models.Model): 

152 """User-created collection of recipes, scoped to profile.""" 

153 

154 profile = models.ForeignKey( 

155 "profiles.Profile", 

156 on_delete=models.CASCADE, 

157 related_name="collections", 

158 ) 

159 name = models.CharField(max_length=100) 

160 description = models.TextField(blank=True) 

161 created_at = models.DateTimeField(auto_now_add=True) 

162 updated_at = models.DateTimeField(auto_now=True) 

163 

164 class Meta: 

165 unique_together = ["profile", "name"] 

166 ordering = ["-updated_at"] 

167 

168 def __str__(self): 

169 return f"{self.profile.name} - {self.name}" 

170 

171 

172class RecipeCollectionItem(models.Model): 

173 """A recipe within a collection.""" 

174 

175 collection = models.ForeignKey( 

176 RecipeCollection, 

177 on_delete=models.CASCADE, 

178 related_name="items", 

179 ) 

180 recipe = models.ForeignKey( 

181 Recipe, 

182 on_delete=models.CASCADE, 

183 ) 

184 order = models.PositiveIntegerField(default=0) 

185 added_at = models.DateTimeField(auto_now_add=True) 

186 

187 class Meta: 

188 unique_together = ["collection", "recipe"] 

189 ordering = ["order", "-added_at"] 

190 

191 def __str__(self): 

192 return f"{self.collection.name} - {self.recipe.title}" 

193 

194 

195class RecipeViewHistory(models.Model): 

196 """Tracks recently viewed recipes, scoped to profile.""" 

197 

198 profile = models.ForeignKey( 

199 "profiles.Profile", 

200 on_delete=models.CASCADE, 

201 related_name="view_history", 

202 ) 

203 recipe = models.ForeignKey( 

204 Recipe, 

205 on_delete=models.CASCADE, 

206 ) 

207 viewed_at = models.DateTimeField(auto_now=True) 

208 

209 class Meta: 

210 unique_together = ["profile", "recipe"] 

211 ordering = ["-viewed_at"] 

212 

213 def __str__(self): 

214 return f"{self.profile.name} viewed {self.recipe.title}" 

215 

216 

217class CachedSearchImage(models.Model): 

218 """Cached search result image for offline/iOS 9 compatibility.""" 

219 

220 external_url = models.URLField(max_length=2000, unique=True, db_index=True) 

221 image = models.ImageField(upload_to="search_images/", blank=True) 

222 created_at = models.DateTimeField(auto_now_add=True) 

223 last_accessed_at = models.DateTimeField(auto_now=True) 

224 

225 STATUS_PENDING = "pending" 

226 STATUS_SUCCESS = "success" 

227 STATUS_FAILED = "failed" 

228 STATUS_CHOICES = [ 

229 (STATUS_PENDING, "Pending"), 

230 (STATUS_SUCCESS, "Success"), 

231 (STATUS_FAILED, "Failed"), 

232 ] 

233 status = models.CharField(max_length=10, choices=STATUS_CHOICES, default=STATUS_PENDING) 

234 

235 class Meta: 

236 indexes = [ 

237 models.Index(fields=["status"]), 

238 models.Index(fields=["created_at"]), 

239 ] 

240 

241 def __str__(self): 

242 return f"Cached: {self.external_url}" 

243 

244 

245class ServingAdjustment(models.Model): 

246 """Cached AI-generated serving adjustments per profile.""" 

247 

248 UNIT_SYSTEM_CHOICES = [ 

249 ("metric", "Metric"), 

250 ("imperial", "Imperial"), 

251 ] 

252 

253 recipe = models.ForeignKey( 

254 Recipe, 

255 on_delete=models.CASCADE, 

256 related_name="serving_adjustments", 

257 ) 

258 profile = models.ForeignKey( 

259 "profiles.Profile", 

260 on_delete=models.CASCADE, 

261 related_name="serving_adjustments", 

262 ) 

263 target_servings = models.PositiveIntegerField() 

264 unit_system = models.CharField( 

265 max_length=10, 

266 choices=UNIT_SYSTEM_CHOICES, 

267 default="metric", 

268 ) 

269 ingredients = models.JSONField(default=list) 

270 instructions = models.JSONField(default=list) # QA-031: Scaled instructions 

271 notes = models.JSONField(default=list) 

272 prep_time_adjusted = models.PositiveIntegerField(null=True, blank=True) # QA-032 

273 cook_time_adjusted = models.PositiveIntegerField(null=True, blank=True) # QA-032 

274 total_time_adjusted = models.PositiveIntegerField(null=True, blank=True) # QA-032 

275 created_at = models.DateTimeField(auto_now_add=True) 

276 

277 class Meta: 

278 unique_together = ["recipe", "profile", "target_servings", "unit_system"] 

279 indexes = [ 

280 models.Index(fields=["recipe", "profile"]), 

281 ] 

282 

283 def __str__(self): 

284 return f"{self.recipe.title} - {self.target_servings} servings ({self.profile.name})" 

← Back to Dashboard