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
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-14 19:13 +0000
1from django.db import models
4class Recipe(models.Model):
5 """Recipe model with full recipe-scrapers field support."""
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)
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)
18 # Images (stored locally)
19 image = models.ImageField(upload_to="recipe_images/", blank=True)
20 image_url = models.URLField(max_length=2000, blank=True)
22 # Ingredients
23 ingredients = models.JSONField(default=list)
24 ingredient_groups = models.JSONField(default=list)
26 # Instructions
27 instructions = models.JSONField(default=list)
28 instructions_text = models.TextField(blank=True)
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)
35 # Servings
36 yields = models.CharField(max_length=100, blank=True)
37 servings = models.PositiveIntegerField(null=True, blank=True)
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)
46 # Equipment and extras
47 equipment = models.JSONField(default=list)
49 # Nutrition (scraped from source)
50 nutrition = models.JSONField(default=dict)
52 # Ratings
53 rating = models.FloatField(null=True, blank=True)
54 rating_count = models.PositiveIntegerField(null=True, blank=True)
56 # Language
57 language = models.CharField(max_length=10, blank=True)
59 # Links
60 links = models.JSONField(default=list)
62 # AI-generated content
63 ai_tips = models.JSONField(default=list, blank=True)
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 )
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 )
89 # Timestamps
90 scraped_at = models.DateTimeField(auto_now_add=True)
91 updated_at = models.DateTimeField(auto_now=True)
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 ]
102 def __str__(self):
103 return self.title
106class SearchSource(models.Model):
107 """Curated recipe search source."""
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)
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)
121 class Meta:
122 ordering = ["name"]
124 def __str__(self):
125 return self.name
128class RecipeFavorite(models.Model):
129 """User's favorite recipes, scoped to profile."""
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)
143 class Meta:
144 unique_together = ["profile", "recipe"]
145 ordering = ["-created_at"]
147 def __str__(self):
148 return f"{self.profile.name} - {self.recipe.title}"
151class RecipeCollection(models.Model):
152 """User-created collection of recipes, scoped to profile."""
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)
164 class Meta:
165 unique_together = ["profile", "name"]
166 ordering = ["-updated_at"]
168 def __str__(self):
169 return f"{self.profile.name} - {self.name}"
172class RecipeCollectionItem(models.Model):
173 """A recipe within a collection."""
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)
187 class Meta:
188 unique_together = ["collection", "recipe"]
189 ordering = ["order", "-added_at"]
191 def __str__(self):
192 return f"{self.collection.name} - {self.recipe.title}"
195class RecipeViewHistory(models.Model):
196 """Tracks recently viewed recipes, scoped to profile."""
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)
209 class Meta:
210 unique_together = ["profile", "recipe"]
211 ordering = ["-viewed_at"]
213 def __str__(self):
214 return f"{self.profile.name} viewed {self.recipe.title}"
217class CachedSearchImage(models.Model):
218 """Cached search result image for offline/iOS 9 compatibility."""
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)
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)
235 class Meta:
236 indexes = [
237 models.Index(fields=["status"]),
238 models.Index(fields=["created_at"]),
239 ]
241 def __str__(self):
242 return f"Cached: {self.external_url}"
245class ServingAdjustment(models.Model):
246 """Cached AI-generated serving adjustments per profile."""
248 UNIT_SYSTEM_CHOICES = [
249 ("metric", "Metric"),
250 ("imperial", "Imperial"),
251 ]
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)
277 class Meta:
278 unique_together = ["recipe", "profile", "target_servings", "unit_system"]
279 indexes = [
280 models.Index(fields=["recipe", "profile"]),
281 ]
283 def __str__(self):
284 return f"{self.recipe.title} - {self.target_servings} servings ({self.profile.name})"