sandwitches 2.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. sandwitches/__init__.py +6 -0
  2. sandwitches/admin.py +69 -0
  3. sandwitches/api.py +207 -0
  4. sandwitches/asgi.py +16 -0
  5. sandwitches/feeds.py +23 -0
  6. sandwitches/forms.py +196 -0
  7. sandwitches/locale/nl/LC_MESSAGES/django.mo +0 -0
  8. sandwitches/locale/nl/LC_MESSAGES/django.po +1010 -0
  9. sandwitches/migrations/0001_initial.py +328 -0
  10. sandwitches/migrations/0002_historicalrecipe_servings_recipe_servings.py +27 -0
  11. sandwitches/migrations/0003_setting.py +35 -0
  12. sandwitches/migrations/0004_alter_setting_ai_api_key_and_more.py +37 -0
  13. sandwitches/migrations/0005_rating_comment.py +17 -0
  14. sandwitches/migrations/0006_historicalrecipe_is_highlighted_and_more.py +22 -0
  15. sandwitches/migrations/__init__.py +0 -0
  16. sandwitches/models.py +218 -0
  17. sandwitches/settings.py +220 -0
  18. sandwitches/storage.py +114 -0
  19. sandwitches/tasks.py +115 -0
  20. sandwitches/templates/admin/admin_base.html +118 -0
  21. sandwitches/templates/admin/confirm_delete.html +23 -0
  22. sandwitches/templates/admin/dashboard.html +262 -0
  23. sandwitches/templates/admin/rating_list.html +38 -0
  24. sandwitches/templates/admin/recipe_form.html +184 -0
  25. sandwitches/templates/admin/recipe_list.html +64 -0
  26. sandwitches/templates/admin/tag_form.html +30 -0
  27. sandwitches/templates/admin/tag_list.html +37 -0
  28. sandwitches/templates/admin/task_detail.html +91 -0
  29. sandwitches/templates/admin/task_list.html +41 -0
  30. sandwitches/templates/admin/user_form.html +37 -0
  31. sandwitches/templates/admin/user_list.html +60 -0
  32. sandwitches/templates/base.html +94 -0
  33. sandwitches/templates/base_beer.html +57 -0
  34. sandwitches/templates/components/carousel_scripts.html +59 -0
  35. sandwitches/templates/components/favorites_search_form.html +85 -0
  36. sandwitches/templates/components/footer.html +14 -0
  37. sandwitches/templates/components/ingredients_scripts.html +50 -0
  38. sandwitches/templates/components/ingredients_section.html +11 -0
  39. sandwitches/templates/components/instructions_section.html +9 -0
  40. sandwitches/templates/components/language_dialog.html +26 -0
  41. sandwitches/templates/components/navbar.html +27 -0
  42. sandwitches/templates/components/rating_section.html +66 -0
  43. sandwitches/templates/components/recipe_header.html +32 -0
  44. sandwitches/templates/components/search_form.html +106 -0
  45. sandwitches/templates/components/search_scripts.html +98 -0
  46. sandwitches/templates/components/side_menu.html +35 -0
  47. sandwitches/templates/components/user_menu.html +10 -0
  48. sandwitches/templates/detail.html +178 -0
  49. sandwitches/templates/favorites.html +42 -0
  50. sandwitches/templates/index.html +76 -0
  51. sandwitches/templates/login.html +57 -0
  52. sandwitches/templates/partials/recipe_list.html +87 -0
  53. sandwitches/templates/recipe_form.html +119 -0
  54. sandwitches/templates/setup.html +105 -0
  55. sandwitches/templates/signup.html +133 -0
  56. sandwitches/templatetags/__init__.py +0 -0
  57. sandwitches/templatetags/custom_filters.py +15 -0
  58. sandwitches/templatetags/markdown_extras.py +17 -0
  59. sandwitches/urls.py +109 -0
  60. sandwitches/utils.py +222 -0
  61. sandwitches/views.py +647 -0
  62. sandwitches/wsgi.py +16 -0
  63. sandwitches-2.2.0.dist-info/METADATA +104 -0
  64. sandwitches-2.2.0.dist-info/RECORD +65 -0
  65. sandwitches-2.2.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,328 @@
1
+ # Generated by Django 6.0 on 2025-12-31 16:09
2
+
3
+ import django.contrib.auth.models
4
+ import django.contrib.auth.validators
5
+ import django.core.validators
6
+ import django.db.models.deletion
7
+ import django.utils.timezone
8
+ import sandwitches.storage
9
+ import simple_history.models
10
+ from django.conf import settings
11
+ from django.db import migrations, models
12
+
13
+
14
+ class Migration(migrations.Migration):
15
+ initial = True
16
+
17
+ dependencies = [
18
+ ("auth", "0012_alter_user_first_name_max_length"),
19
+ ]
20
+
21
+ operations = [
22
+ migrations.CreateModel(
23
+ name="Tag",
24
+ fields=[
25
+ (
26
+ "id",
27
+ models.BigAutoField(
28
+ auto_created=True,
29
+ primary_key=True,
30
+ serialize=False,
31
+ verbose_name="ID",
32
+ ),
33
+ ),
34
+ ("name", models.CharField(max_length=50, unique=True)),
35
+ ("slug", models.SlugField(blank=True, max_length=60, unique=True)),
36
+ ],
37
+ options={
38
+ "verbose_name": "Tag",
39
+ "verbose_name_plural": "Tags",
40
+ "ordering": ("name",),
41
+ },
42
+ ),
43
+ migrations.CreateModel(
44
+ name="User",
45
+ fields=[
46
+ (
47
+ "id",
48
+ models.BigAutoField(
49
+ auto_created=True,
50
+ primary_key=True,
51
+ serialize=False,
52
+ verbose_name="ID",
53
+ ),
54
+ ),
55
+ ("password", models.CharField(max_length=128, verbose_name="password")),
56
+ (
57
+ "last_login",
58
+ models.DateTimeField(
59
+ blank=True, null=True, verbose_name="last login"
60
+ ),
61
+ ),
62
+ (
63
+ "is_superuser",
64
+ models.BooleanField(
65
+ default=False,
66
+ help_text="Designates that this user has all permissions without explicitly assigning them.",
67
+ verbose_name="superuser status",
68
+ ),
69
+ ),
70
+ (
71
+ "username",
72
+ models.CharField(
73
+ error_messages={
74
+ "unique": "A user with that username already exists."
75
+ },
76
+ help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
77
+ max_length=150,
78
+ unique=True,
79
+ validators=[
80
+ django.contrib.auth.validators.UnicodeUsernameValidator()
81
+ ],
82
+ verbose_name="username",
83
+ ),
84
+ ),
85
+ (
86
+ "first_name",
87
+ models.CharField(
88
+ blank=True, max_length=150, verbose_name="first name"
89
+ ),
90
+ ),
91
+ (
92
+ "last_name",
93
+ models.CharField(
94
+ blank=True, max_length=150, verbose_name="last name"
95
+ ),
96
+ ),
97
+ (
98
+ "email",
99
+ models.EmailField(
100
+ blank=True, max_length=254, verbose_name="email address"
101
+ ),
102
+ ),
103
+ (
104
+ "is_staff",
105
+ models.BooleanField(
106
+ default=False,
107
+ help_text="Designates whether the user can log into this admin site.",
108
+ verbose_name="staff status",
109
+ ),
110
+ ),
111
+ (
112
+ "is_active",
113
+ models.BooleanField(
114
+ default=True,
115
+ help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
116
+ verbose_name="active",
117
+ ),
118
+ ),
119
+ (
120
+ "date_joined",
121
+ models.DateTimeField(
122
+ default=django.utils.timezone.now, verbose_name="date joined"
123
+ ),
124
+ ),
125
+ (
126
+ "avatar",
127
+ models.ImageField(blank=True, null=True, upload_to="avatars"),
128
+ ),
129
+ ("bio", models.TextField(blank=True)),
130
+ (
131
+ "language",
132
+ models.CharField(
133
+ choices=[("en", "English"), ("nl", "Nederlands")],
134
+ default="en",
135
+ max_length=10,
136
+ ),
137
+ ),
138
+ (
139
+ "groups",
140
+ models.ManyToManyField(
141
+ blank=True,
142
+ help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
143
+ related_name="user_set",
144
+ related_query_name="user",
145
+ to="auth.group",
146
+ verbose_name="groups",
147
+ ),
148
+ ),
149
+ (
150
+ "user_permissions",
151
+ models.ManyToManyField(
152
+ blank=True,
153
+ help_text="Specific permissions for this user.",
154
+ related_name="user_set",
155
+ related_query_name="user",
156
+ to="auth.permission",
157
+ verbose_name="user permissions",
158
+ ),
159
+ ),
160
+ ],
161
+ options={
162
+ "verbose_name": "User",
163
+ "verbose_name_plural": "Users",
164
+ },
165
+ managers=[
166
+ ("objects", django.contrib.auth.models.UserManager()),
167
+ ],
168
+ ),
169
+ migrations.CreateModel(
170
+ name="HistoricalRecipe",
171
+ fields=[
172
+ (
173
+ "id",
174
+ models.BigIntegerField(
175
+ auto_created=True, blank=True, db_index=True, verbose_name="ID"
176
+ ),
177
+ ),
178
+ ("title", models.CharField(db_index=True, max_length=255)),
179
+ ("slug", models.SlugField(blank=True, max_length=255)),
180
+ ("description", models.TextField(blank=True)),
181
+ ("ingredients", models.TextField(blank=True)),
182
+ ("instructions", models.TextField(blank=True)),
183
+ ("image", models.TextField(blank=True, max_length=100, null=True)),
184
+ ("created_at", models.DateTimeField(blank=True, editable=False)),
185
+ ("updated_at", models.DateTimeField(blank=True, editable=False)),
186
+ ("history_id", models.AutoField(primary_key=True, serialize=False)),
187
+ ("history_date", models.DateTimeField(db_index=True)),
188
+ ("history_change_reason", models.CharField(max_length=100, null=True)),
189
+ (
190
+ "history_type",
191
+ models.CharField(
192
+ choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
193
+ max_length=1,
194
+ ),
195
+ ),
196
+ (
197
+ "history_user",
198
+ models.ForeignKey(
199
+ null=True,
200
+ on_delete=django.db.models.deletion.SET_NULL,
201
+ related_name="+",
202
+ to=settings.AUTH_USER_MODEL,
203
+ ),
204
+ ),
205
+ (
206
+ "uploaded_by",
207
+ models.ForeignKey(
208
+ blank=True,
209
+ db_constraint=False,
210
+ null=True,
211
+ on_delete=django.db.models.deletion.DO_NOTHING,
212
+ related_name="+",
213
+ to=settings.AUTH_USER_MODEL,
214
+ ),
215
+ ),
216
+ ],
217
+ options={
218
+ "verbose_name": "historical Recipe",
219
+ "verbose_name_plural": "historical Recipes",
220
+ "ordering": ("-history_date", "-history_id"),
221
+ "get_latest_by": ("history_date", "history_id"),
222
+ },
223
+ bases=(simple_history.models.HistoricalChanges, models.Model),
224
+ ),
225
+ migrations.CreateModel(
226
+ name="Recipe",
227
+ fields=[
228
+ (
229
+ "id",
230
+ models.BigAutoField(
231
+ auto_created=True,
232
+ primary_key=True,
233
+ serialize=False,
234
+ verbose_name="ID",
235
+ ),
236
+ ),
237
+ ("title", models.CharField(max_length=255, unique=True)),
238
+ ("slug", models.SlugField(blank=True, max_length=255, unique=True)),
239
+ ("description", models.TextField(blank=True)),
240
+ ("ingredients", models.TextField(blank=True)),
241
+ ("instructions", models.TextField(blank=True)),
242
+ (
243
+ "image",
244
+ models.ImageField(
245
+ blank=True,
246
+ null=True,
247
+ storage=sandwitches.storage.HashedFilenameStorage(),
248
+ upload_to="recipes/",
249
+ ),
250
+ ),
251
+ ("created_at", models.DateTimeField(auto_now_add=True)),
252
+ ("updated_at", models.DateTimeField(auto_now=True)),
253
+ (
254
+ "uploaded_by",
255
+ models.ForeignKey(
256
+ blank=True,
257
+ null=True,
258
+ on_delete=django.db.models.deletion.SET_NULL,
259
+ related_name="recipes",
260
+ to=settings.AUTH_USER_MODEL,
261
+ ),
262
+ ),
263
+ (
264
+ "tags",
265
+ models.ManyToManyField(
266
+ blank=True, related_name="recipes", to="sandwitches.tag"
267
+ ),
268
+ ),
269
+ ],
270
+ options={
271
+ "verbose_name": "Recipe",
272
+ "verbose_name_plural": "Recipes",
273
+ "ordering": ("-created_at",),
274
+ },
275
+ ),
276
+ migrations.AddField(
277
+ model_name="user",
278
+ name="favorites",
279
+ field=models.ManyToManyField(
280
+ blank=True, related_name="favorited_by", to="sandwitches.recipe"
281
+ ),
282
+ ),
283
+ migrations.CreateModel(
284
+ name="Rating",
285
+ fields=[
286
+ (
287
+ "id",
288
+ models.BigAutoField(
289
+ auto_created=True,
290
+ primary_key=True,
291
+ serialize=False,
292
+ verbose_name="ID",
293
+ ),
294
+ ),
295
+ (
296
+ "score",
297
+ models.FloatField(
298
+ validators=[
299
+ django.core.validators.MinValueValidator(0.0),
300
+ django.core.validators.MaxValueValidator(10.0),
301
+ ]
302
+ ),
303
+ ),
304
+ ("created_at", models.DateTimeField(auto_now_add=True)),
305
+ ("updated_at", models.DateTimeField(auto_now=True)),
306
+ (
307
+ "user",
308
+ models.ForeignKey(
309
+ on_delete=django.db.models.deletion.CASCADE,
310
+ related_name="ratings",
311
+ to=settings.AUTH_USER_MODEL,
312
+ ),
313
+ ),
314
+ (
315
+ "recipe",
316
+ models.ForeignKey(
317
+ on_delete=django.db.models.deletion.CASCADE,
318
+ related_name="ratings",
319
+ to="sandwitches.recipe",
320
+ ),
321
+ ),
322
+ ],
323
+ options={
324
+ "ordering": ("-updated_at",),
325
+ "unique_together": {("recipe", "user")},
326
+ },
327
+ ),
328
+ ]
@@ -0,0 +1,27 @@
1
+ # Generated by Django 6.0 on 2026-01-13 11:59
2
+
3
+ import django.core.validators
4
+ from django.db import migrations, models
5
+
6
+
7
+ class Migration(migrations.Migration):
8
+ dependencies = [
9
+ ("sandwitches", "0001_initial"),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AddField(
14
+ model_name="historicalrecipe",
15
+ name="servings",
16
+ field=models.IntegerField(
17
+ default=1, validators=[django.core.validators.MinValueValidator(1)]
18
+ ),
19
+ ),
20
+ migrations.AddField(
21
+ model_name="recipe",
22
+ name="servings",
23
+ field=models.IntegerField(
24
+ default=1, validators=[django.core.validators.MinValueValidator(1)]
25
+ ),
26
+ ),
27
+ ]
@@ -0,0 +1,35 @@
1
+ # Generated by Django 6.0 on 2026-01-14 09:13
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+ dependencies = [
8
+ ("sandwitches", "0002_historicalrecipe_servings_recipe_servings"),
9
+ ]
10
+
11
+ operations = [
12
+ migrations.CreateModel(
13
+ name="Setting",
14
+ fields=[
15
+ (
16
+ "id",
17
+ models.BigAutoField(
18
+ auto_created=True,
19
+ primary_key=True,
20
+ serialize=False,
21
+ verbose_name="ID",
22
+ ),
23
+ ),
24
+ ("site_name", models.CharField(default="Sandwitches", max_length=255)),
25
+ ("site_description", models.TextField(blank=True)),
26
+ ("email", models.EmailField(blank=True, max_length=254)),
27
+ ("ai_connection_point", models.URLField(blank=True)),
28
+ ("ai_model", models.CharField(blank=True, max_length=255)),
29
+ ("ai_api_key", models.CharField(blank=True, max_length=255)),
30
+ ],
31
+ options={
32
+ "verbose_name": "Site Settings",
33
+ },
34
+ ),
35
+ ]
@@ -0,0 +1,37 @@
1
+ # Generated by Django 6.0 on 2026-01-14 09:32
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+ dependencies = [
8
+ ("sandwitches", "0003_setting"),
9
+ ]
10
+
11
+ operations = [
12
+ migrations.AlterField(
13
+ model_name="setting",
14
+ name="ai_api_key",
15
+ field=models.CharField(blank=True, max_length=255, null=True),
16
+ ),
17
+ migrations.AlterField(
18
+ model_name="setting",
19
+ name="ai_connection_point",
20
+ field=models.URLField(blank=True, null=True),
21
+ ),
22
+ migrations.AlterField(
23
+ model_name="setting",
24
+ name="ai_model",
25
+ field=models.CharField(blank=True, max_length=255, null=True),
26
+ ),
27
+ migrations.AlterField(
28
+ model_name="setting",
29
+ name="email",
30
+ field=models.EmailField(blank=True, max_length=254, null=True),
31
+ ),
32
+ migrations.AlterField(
33
+ model_name="setting",
34
+ name="site_description",
35
+ field=models.TextField(blank=True, null=True),
36
+ ),
37
+ ]
@@ -0,0 +1,17 @@
1
+ # Generated by Django 6.0 on 2026-01-14 10:03
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+ dependencies = [
8
+ ("sandwitches", "0004_alter_setting_ai_api_key_and_more"),
9
+ ]
10
+
11
+ operations = [
12
+ migrations.AddField(
13
+ model_name="rating",
14
+ name="comment",
15
+ field=models.TextField(blank=True),
16
+ ),
17
+ ]
@@ -0,0 +1,22 @@
1
+ # Generated by Django 6.0.1 on 2026-01-17 16:48
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+ dependencies = [
8
+ ("sandwitches", "0005_rating_comment"),
9
+ ]
10
+
11
+ operations = [
12
+ migrations.AddField(
13
+ model_name="historicalrecipe",
14
+ name="is_highlighted",
15
+ field=models.BooleanField(default=False),
16
+ ),
17
+ migrations.AddField(
18
+ model_name="recipe",
19
+ name="is_highlighted",
20
+ field=models.BooleanField(default=False),
21
+ ),
22
+ ]
File without changes
sandwitches/models.py ADDED
@@ -0,0 +1,218 @@
1
+ from django.db import models
2
+ from django.utils.text import slugify
3
+ from .storage import HashedFilenameStorage
4
+ from simple_history.models import HistoricalRecords
5
+ from django.contrib.auth.models import AbstractUser
6
+ from django.db.models import Avg
7
+ from .tasks import email_users
8
+ from django.conf import settings
9
+ from django.core.validators import MinValueValidator, MaxValueValidator
10
+ import logging
11
+ from django.urls import reverse
12
+ from solo.models import SingletonModel
13
+
14
+ from imagekit.models import ImageSpecField
15
+ from imagekit.processors import ResizeToFill
16
+
17
+ hashed_storage = HashedFilenameStorage()
18
+
19
+
20
+ class Setting(SingletonModel):
21
+ site_name = models.CharField(max_length=255, default="Sandwitches")
22
+ site_description = models.TextField(blank=True, null=True)
23
+ email = models.EmailField(blank=True, null=True)
24
+ ai_connection_point = models.URLField(blank=True, null=True)
25
+ ai_model = models.CharField(max_length=255, blank=True, null=True)
26
+ ai_api_key = models.CharField(max_length=255, blank=True, null=True)
27
+
28
+ def __str__(self):
29
+ return "Site Settings"
30
+
31
+ class Meta:
32
+ verbose_name = "Site Settings"
33
+
34
+
35
+ class User(AbstractUser):
36
+ avatar = models.ImageField(upload_to="avatars", blank=True, null=True)
37
+ avatar_thumbnail = ImageSpecField(
38
+ source="avatar",
39
+ processors=[ResizeToFill(100, 50)],
40
+ format="JPEG",
41
+ options={"quality": 60},
42
+ )
43
+ bio = models.TextField(blank=True)
44
+ language = models.CharField(
45
+ max_length=10,
46
+ choices=settings.LANGUAGES,
47
+ default=settings.LANGUAGE_CODE,
48
+ )
49
+ favorites = models.ManyToManyField(
50
+ "Recipe", related_name="favorited_by", blank=True
51
+ )
52
+
53
+ class Meta:
54
+ verbose_name = "User"
55
+ verbose_name_plural = "Users"
56
+
57
+ def __str__(self):
58
+ return self.username
59
+
60
+
61
+ class Tag(models.Model):
62
+ name = models.CharField(max_length=50, unique=True)
63
+ slug = models.SlugField(max_length=60, unique=True, blank=True)
64
+
65
+ class Meta:
66
+ ordering = ("name",)
67
+ verbose_name = "Tag"
68
+ verbose_name_plural = "Tags"
69
+
70
+ def save(self, *args, **kwargs):
71
+ if not self.slug:
72
+ base = slugify(self.name)[:55]
73
+ slug = base
74
+ n = 1
75
+ while Tag.objects.filter(slug=slug).exclude(pk=self.pk).exists(): # ty:ignore[unresolved-attribute]
76
+ slug = f"{base}-{n}"
77
+ n += 1
78
+ self.slug = slug
79
+ super().save(*args, **kwargs)
80
+
81
+ def __str__(self):
82
+ return self.name
83
+
84
+
85
+ class Recipe(models.Model):
86
+ title = models.CharField(max_length=255, unique=True)
87
+ slug = models.SlugField(max_length=255, unique=True, blank=True)
88
+ description = models.TextField(blank=True)
89
+ ingredients = models.TextField(blank=True)
90
+ instructions = models.TextField(blank=True)
91
+ servings = models.IntegerField(default=1, validators=[MinValueValidator(1)])
92
+ uploaded_by = models.ForeignKey(
93
+ settings.AUTH_USER_MODEL,
94
+ related_name="recipes",
95
+ on_delete=models.SET_NULL,
96
+ null=True,
97
+ blank=True,
98
+ )
99
+ image = models.ImageField(
100
+ upload_to="recipes/",
101
+ storage=hashed_storage,
102
+ blank=True,
103
+ null=True,
104
+ )
105
+ image_thumbnail = ImageSpecField(
106
+ source="image",
107
+ processors=[ResizeToFill(150, 150)],
108
+ format="JPEG",
109
+ options={"quality": 70},
110
+ )
111
+ image_small = ImageSpecField(
112
+ source="image",
113
+ processors=[ResizeToFill(400, 300)],
114
+ format="JPEG",
115
+ options={"quality": 75},
116
+ )
117
+ image_medium = ImageSpecField(
118
+ source="image",
119
+ processors=[ResizeToFill(700, 500)],
120
+ format="JPEG",
121
+ options={"quality": 85},
122
+ )
123
+ image_large = ImageSpecField(
124
+ source="image",
125
+ processors=[ResizeToFill(1200, 800)],
126
+ format="JPEG",
127
+ options={"quality": 95},
128
+ )
129
+ tags = models.ManyToManyField(Tag, blank=True, related_name="recipes")
130
+ is_highlighted = models.BooleanField(default=False)
131
+ created_at = models.DateTimeField(auto_now_add=True)
132
+ updated_at = models.DateTimeField(auto_now=True)
133
+ history = HistoricalRecords()
134
+
135
+ class Meta:
136
+ ordering = ("-created_at",)
137
+ verbose_name = "Recipe"
138
+ verbose_name_plural = "Recipes"
139
+
140
+ def save(self, *args, **kwargs):
141
+ is_new = self._state.adding
142
+
143
+ if not self.slug:
144
+ base = slugify(self.title)[:240]
145
+ slug = base
146
+ n = 1
147
+ while Recipe.objects.filter(slug=slug).exclude(pk=self.pk).exists(): # ty:ignore[unresolved-attribute]
148
+ slug = f"{base}-{n}"
149
+ n += 1
150
+ self.slug = slug
151
+
152
+ super().save(*args, **kwargs)
153
+
154
+ send_email = getattr(settings, "SEND_EMAIL")
155
+ logging.debug(f"SEND_EMAIL is set to {send_email}")
156
+
157
+ if is_new or settings.DEBUG:
158
+ if send_email:
159
+ email_users.enqueue(recipe_id=self.pk)
160
+ else:
161
+ logging.warning(
162
+ "Email sending is disabled; not sending email notification, make sure SEND_EMAIL is set to True in settings."
163
+ )
164
+ else:
165
+ logging.debug(
166
+ "Existing recipe saved (update); skipping email notification."
167
+ )
168
+
169
+ def get_absolute_url(self):
170
+ return reverse("recipe_detail", kwargs={"slug": self.slug})
171
+
172
+ def tag_list(self):
173
+ return list(self.tags.values_list("name", flat=True)) # ty:ignore[possibly-missing-attribute]
174
+
175
+ def set_tags_from_string(self, tag_string):
176
+ """
177
+ Accepts a comma separated string like "tag1, tag2" and attaches existing tags
178
+ or creates new ones as needed. Returns the Tag queryset assigned.
179
+ """
180
+ names = [t.strip() for t in (tag_string or "").split(",") if t.strip()]
181
+ tags = []
182
+ for name in names:
183
+ tag = Tag.objects.filter(name__iexact=name).first() # ty:ignore[unresolved-attribute]
184
+ if not tag:
185
+ tag = Tag.objects.create(name=name) # ty:ignore[unresolved-attribute]
186
+ tags.append(tag)
187
+ self.tags.set(tags) # ty:ignore[possibly-missing-attribute]
188
+ return self.tags.all() # ty:ignore[possibly-missing-attribute]
189
+
190
+ def average_rating(self):
191
+ agg = self.ratings.aggregate(avg=Avg("score")) # ty:ignore[unresolved-attribute]
192
+ return agg["avg"] or 0
193
+
194
+ def rating_count(self):
195
+ return self.ratings.count() # ty:ignore[unresolved-attribute]
196
+
197
+ def __str__(self):
198
+ return self.title
199
+
200
+
201
+ class Rating(models.Model):
202
+ recipe = models.ForeignKey(Recipe, related_name="ratings", on_delete=models.CASCADE)
203
+ user = models.ForeignKey(
204
+ settings.AUTH_USER_MODEL, related_name="ratings", on_delete=models.CASCADE
205
+ )
206
+ score = models.FloatField(
207
+ validators=[MinValueValidator(0.0), MaxValueValidator(10.0)]
208
+ )
209
+ comment = models.TextField(blank=True)
210
+ created_at = models.DateTimeField(auto_now_add=True)
211
+ updated_at = models.DateTimeField(auto_now=True)
212
+
213
+ class Meta:
214
+ unique_together = ("recipe", "user")
215
+ ordering = ("-updated_at",)
216
+
217
+ def __str__(self):
218
+ return f"{self.recipe} — {self.score} by {self.user}"