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

126 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-11 00:40 +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 

82 # Timestamps 

83 scraped_at = models.DateTimeField(auto_now_add=True) 

84 updated_at = models.DateTimeField(auto_now=True) 

85 

86 class Meta: 

87 indexes = [ 

88 models.Index(fields=['host']), 

89 models.Index(fields=['is_remix']), 

90 models.Index(fields=['scraped_at']), 

91 models.Index(fields=['profile']), 

92 ] 

93 

94 def __str__(self): 

95 return self.title 

96 

97 

98class SearchSource(models.Model): 

99 """Curated recipe search source.""" 

100 

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

102 name = models.CharField(max_length=255) 

103 is_enabled = models.BooleanField(default=True) 

104 search_url_template = models.CharField(max_length=500) 

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

106 logo_url = models.URLField(blank=True) 

107 

108 # Maintenance tracking 

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

110 consecutive_failures = models.PositiveIntegerField(default=0) 

111 needs_attention = models.BooleanField(default=False) 

112 

113 class Meta: 

114 ordering = ['name'] 

115 

116 def __str__(self): 

117 return self.name 

118 

119 

120class RecipeFavorite(models.Model): 

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

122 

123 profile = models.ForeignKey( 

124 'profiles.Profile', 

125 on_delete=models.CASCADE, 

126 related_name='favorites', 

127 ) 

128 recipe = models.ForeignKey( 

129 Recipe, 

130 on_delete=models.CASCADE, 

131 related_name='favorited_by', 

132 ) 

133 created_at = models.DateTimeField(auto_now_add=True) 

134 

135 class Meta: 

136 unique_together = ['profile', 'recipe'] 

137 ordering = ['-created_at'] 

138 

139 def __str__(self): 

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

141 

142 

143class RecipeCollection(models.Model): 

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

145 

146 profile = models.ForeignKey( 

147 'profiles.Profile', 

148 on_delete=models.CASCADE, 

149 related_name='collections', 

150 ) 

151 name = models.CharField(max_length=100) 

152 description = models.TextField(blank=True) 

153 created_at = models.DateTimeField(auto_now_add=True) 

154 updated_at = models.DateTimeField(auto_now=True) 

155 

156 class Meta: 

157 unique_together = ['profile', 'name'] 

158 ordering = ['-updated_at'] 

159 

160 def __str__(self): 

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

162 

163 

164class RecipeCollectionItem(models.Model): 

165 """A recipe within a collection.""" 

166 

167 collection = models.ForeignKey( 

168 RecipeCollection, 

169 on_delete=models.CASCADE, 

170 related_name='items', 

171 ) 

172 recipe = models.ForeignKey( 

173 Recipe, 

174 on_delete=models.CASCADE, 

175 ) 

176 order = models.PositiveIntegerField(default=0) 

177 added_at = models.DateTimeField(auto_now_add=True) 

178 

179 class Meta: 

180 unique_together = ['collection', 'recipe'] 

181 ordering = ['order', '-added_at'] 

182 

183 def __str__(self): 

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

185 

186 

187class RecipeViewHistory(models.Model): 

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

189 

190 profile = models.ForeignKey( 

191 'profiles.Profile', 

192 on_delete=models.CASCADE, 

193 related_name='view_history', 

194 ) 

195 recipe = models.ForeignKey( 

196 Recipe, 

197 on_delete=models.CASCADE, 

198 ) 

199 viewed_at = models.DateTimeField(auto_now=True) 

200 

201 class Meta: 

202 unique_together = ['profile', 'recipe'] 

203 ordering = ['-viewed_at'] 

204 

205 def __str__(self): 

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

207 

208 

209class CachedSearchImage(models.Model): 

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

211 

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

213 image = models.ImageField(upload_to='search_images/', blank=True) 

214 created_at = models.DateTimeField(auto_now_add=True) 

215 last_accessed_at = models.DateTimeField(auto_now=True) 

216 

217 STATUS_PENDING = 'pending' 

218 STATUS_SUCCESS = 'success' 

219 STATUS_FAILED = 'failed' 

220 STATUS_CHOICES = [ 

221 (STATUS_PENDING, 'Pending'), 

222 (STATUS_SUCCESS, 'Success'), 

223 (STATUS_FAILED, 'Failed'), 

224 ] 

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

226 

227 class Meta: 

228 indexes = [ 

229 models.Index(fields=['status']), 

230 models.Index(fields=['created_at']), 

231 ] 

232 

233 def __str__(self): 

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

235 

236 

237class ServingAdjustment(models.Model): 

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

239 

240 UNIT_SYSTEM_CHOICES = [ 

241 ('metric', 'Metric'), 

242 ('imperial', 'Imperial'), 

243 ] 

244 

245 recipe = models.ForeignKey( 

246 Recipe, 

247 on_delete=models.CASCADE, 

248 related_name='serving_adjustments', 

249 ) 

250 profile = models.ForeignKey( 

251 'profiles.Profile', 

252 on_delete=models.CASCADE, 

253 related_name='serving_adjustments', 

254 ) 

255 target_servings = models.PositiveIntegerField() 

256 unit_system = models.CharField( 

257 max_length=10, 

258 choices=UNIT_SYSTEM_CHOICES, 

259 default='metric', 

260 ) 

261 ingredients = models.JSONField(default=list) 

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

263 notes = models.JSONField(default=list) 

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

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

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

267 created_at = models.DateTimeField(auto_now_add=True) 

268 

269 class Meta: 

270 unique_together = ['recipe', 'profile', 'target_servings', 'unit_system'] 

271 indexes = [ 

272 models.Index(fields=['recipe', 'profile']), 

273 ] 

274 

275 def __str__(self): 

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

← Back to Dashboard