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
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 00:40 +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 )
82 # Timestamps
83 scraped_at = models.DateTimeField(auto_now_add=True)
84 updated_at = models.DateTimeField(auto_now=True)
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 ]
94 def __str__(self):
95 return self.title
98class SearchSource(models.Model):
99 """Curated recipe search source."""
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)
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)
113 class Meta:
114 ordering = ['name']
116 def __str__(self):
117 return self.name
120class RecipeFavorite(models.Model):
121 """User's favorite recipes, scoped to profile."""
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)
135 class Meta:
136 unique_together = ['profile', 'recipe']
137 ordering = ['-created_at']
139 def __str__(self):
140 return f"{self.profile.name} - {self.recipe.title}"
143class RecipeCollection(models.Model):
144 """User-created collection of recipes, scoped to profile."""
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)
156 class Meta:
157 unique_together = ['profile', 'name']
158 ordering = ['-updated_at']
160 def __str__(self):
161 return f"{self.profile.name} - {self.name}"
164class RecipeCollectionItem(models.Model):
165 """A recipe within a collection."""
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)
179 class Meta:
180 unique_together = ['collection', 'recipe']
181 ordering = ['order', '-added_at']
183 def __str__(self):
184 return f"{self.collection.name} - {self.recipe.title}"
187class RecipeViewHistory(models.Model):
188 """Tracks recently viewed recipes, scoped to profile."""
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)
201 class Meta:
202 unique_together = ['profile', 'recipe']
203 ordering = ['-viewed_at']
205 def __str__(self):
206 return f"{self.profile.name} viewed {self.recipe.title}"
209class CachedSearchImage(models.Model):
210 """Cached search result image for offline/iOS 9 compatibility."""
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)
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)
227 class Meta:
228 indexes = [
229 models.Index(fields=['status']),
230 models.Index(fields=['created_at']),
231 ]
233 def __str__(self):
234 return f"Cached: {self.external_url}"
237class ServingAdjustment(models.Model):
238 """Cached AI-generated serving adjustments per profile."""
240 UNIT_SYSTEM_CHOICES = [
241 ('metric', 'Metric'),
242 ('imperial', 'Imperial'),
243 ]
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)
269 class Meta:
270 unique_together = ['recipe', 'profile', 'target_servings', 'unit_system']
271 indexes = [
272 models.Index(fields=['recipe', 'profile']),
273 ]
275 def __str__(self):
276 return f"{self.recipe.title} - {self.target_servings} servings ({self.profile.name})"