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

127 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-12 10:49 +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 models.Index(fields=["title"]), 

101 ] 

102 

103 def __str__(self): 

104 return self.title 

105 

106 

107class SearchSource(models.Model): 

108 """Curated recipe search source.""" 

109 

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

111 name = models.CharField(max_length=255) 

112 is_enabled = models.BooleanField(default=True) 

113 search_url_template = models.CharField(max_length=500) 

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

115 logo_url = models.URLField(blank=True) 

116 

117 # Maintenance tracking 

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

119 consecutive_failures = models.PositiveIntegerField(default=0) 

120 needs_attention = models.BooleanField(default=False) 

121 

122 class Meta: 

123 ordering = ["name"] 

124 

125 def __str__(self): 

126 return self.name 

127 

128 

129class RecipeFavorite(models.Model): 

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

131 

132 profile = models.ForeignKey( 

133 "profiles.Profile", 

134 on_delete=models.CASCADE, 

135 related_name="favorites", 

136 ) 

137 recipe = models.ForeignKey( 

138 Recipe, 

139 on_delete=models.CASCADE, 

140 related_name="favorited_by", 

141 ) 

142 created_at = models.DateTimeField(auto_now_add=True) 

143 

144 class Meta: 

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

146 ordering = ["-created_at"] 

147 

148 def __str__(self): 

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

150 

151 

152class RecipeCollection(models.Model): 

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

154 

155 profile = models.ForeignKey( 

156 "profiles.Profile", 

157 on_delete=models.CASCADE, 

158 related_name="collections", 

159 ) 

160 name = models.CharField(max_length=100) 

161 description = models.TextField(blank=True) 

162 created_at = models.DateTimeField(auto_now_add=True) 

163 updated_at = models.DateTimeField(auto_now=True) 

164 

165 class Meta: 

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

167 ordering = ["-updated_at"] 

168 

169 def __str__(self): 

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

171 

172 

173class RecipeCollectionItem(models.Model): 

174 """A recipe within a collection.""" 

175 

176 collection = models.ForeignKey( 

177 RecipeCollection, 

178 on_delete=models.CASCADE, 

179 related_name="items", 

180 ) 

181 recipe = models.ForeignKey( 

182 Recipe, 

183 on_delete=models.CASCADE, 

184 ) 

185 order = models.PositiveIntegerField(default=0) 

186 added_at = models.DateTimeField(auto_now_add=True) 

187 

188 class Meta: 

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

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

191 

192 def __str__(self): 

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

194 

195 

196class RecipeViewHistory(models.Model): 

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

198 

199 profile = models.ForeignKey( 

200 "profiles.Profile", 

201 on_delete=models.CASCADE, 

202 related_name="view_history", 

203 ) 

204 recipe = models.ForeignKey( 

205 Recipe, 

206 on_delete=models.CASCADE, 

207 ) 

208 viewed_at = models.DateTimeField(auto_now=True) 

209 

210 class Meta: 

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

212 ordering = ["-viewed_at"] 

213 

214 def __str__(self): 

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

216 

217 

218class CachedSearchImage(models.Model): 

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

220 

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

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

223 created_at = models.DateTimeField(auto_now_add=True) 

224 last_accessed_at = models.DateTimeField(auto_now=True) 

225 

226 STATUS_PENDING = "pending" 

227 STATUS_SUCCESS = "success" 

228 STATUS_FAILED = "failed" 

229 STATUS_CHOICES = [ 

230 (STATUS_PENDING, "Pending"), 

231 (STATUS_SUCCESS, "Success"), 

232 (STATUS_FAILED, "Failed"), 

233 ] 

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

235 

236 class Meta: 

237 indexes = [ 

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

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

240 ] 

241 

242 def __str__(self): 

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

244 

245 

246class ServingAdjustment(models.Model): 

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

248 

249 UNIT_SYSTEM_CHOICES = [ 

250 ("metric", "Metric"), 

251 ("imperial", "Imperial"), 

252 ] 

253 

254 recipe = models.ForeignKey( 

255 Recipe, 

256 on_delete=models.CASCADE, 

257 related_name="serving_adjustments", 

258 ) 

259 profile = models.ForeignKey( 

260 "profiles.Profile", 

261 on_delete=models.CASCADE, 

262 related_name="serving_adjustments", 

263 ) 

264 target_servings = models.PositiveIntegerField() 

265 unit_system = models.CharField( 

266 max_length=10, 

267 choices=UNIT_SYSTEM_CHOICES, 

268 default="metric", 

269 ) 

270 ingredients = models.JSONField(default=list) 

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

272 notes = models.JSONField(default=list) 

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

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

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

276 created_at = models.DateTimeField(auto_now_add=True) 

277 

278 class Meta: 

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

280 indexes = [ 

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

282 ] 

283 

284 def __str__(self): 

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

← Back to Dashboard