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
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-12 10:49 +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 models.Index(fields=["title"]),
101 ]
103 def __str__(self):
104 return self.title
107class SearchSource(models.Model):
108 """Curated recipe search source."""
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)
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)
122 class Meta:
123 ordering = ["name"]
125 def __str__(self):
126 return self.name
129class RecipeFavorite(models.Model):
130 """User's favorite recipes, scoped to profile."""
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)
144 class Meta:
145 unique_together = ["profile", "recipe"]
146 ordering = ["-created_at"]
148 def __str__(self):
149 return f"{self.profile.name} - {self.recipe.title}"
152class RecipeCollection(models.Model):
153 """User-created collection of recipes, scoped to profile."""
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)
165 class Meta:
166 unique_together = ["profile", "name"]
167 ordering = ["-updated_at"]
169 def __str__(self):
170 return f"{self.profile.name} - {self.name}"
173class RecipeCollectionItem(models.Model):
174 """A recipe within a collection."""
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)
188 class Meta:
189 unique_together = ["collection", "recipe"]
190 ordering = ["order", "-added_at"]
192 def __str__(self):
193 return f"{self.collection.name} - {self.recipe.title}"
196class RecipeViewHistory(models.Model):
197 """Tracks recently viewed recipes, scoped to profile."""
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)
210 class Meta:
211 unique_together = ["profile", "recipe"]
212 ordering = ["-viewed_at"]
214 def __str__(self):
215 return f"{self.profile.name} viewed {self.recipe.title}"
218class CachedSearchImage(models.Model):
219 """Cached search result image for offline/iOS 9 compatibility."""
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)
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)
236 class Meta:
237 indexes = [
238 models.Index(fields=["status"]),
239 models.Index(fields=["created_at"]),
240 ]
242 def __str__(self):
243 return f"Cached: {self.external_url}"
246class ServingAdjustment(models.Model):
247 """Cached AI-generated serving adjustments per profile."""
249 UNIT_SYSTEM_CHOICES = [
250 ("metric", "Metric"),
251 ("imperial", "Imperial"),
252 ]
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)
278 class Meta:
279 unique_together = ["recipe", "profile", "target_servings", "unit_system"]
280 indexes = [
281 models.Index(fields=["recipe", "profile"]),
282 ]
284 def __str__(self):
285 return f"{self.recipe.title} - {self.target_servings} servings ({self.profile.name})"