sandwitches 2.5.3__tar.gz → 2.5.5__tar.gz

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 (88) hide show
  1. {sandwitches-2.5.3 → sandwitches-2.5.5}/PKG-INFO +1 -1
  2. {sandwitches-2.5.3 → sandwitches-2.5.5}/pyproject.toml +1 -1
  3. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/admin.py +8 -3
  4. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/api.py +17 -1
  5. sandwitches-2.5.5/src/sandwitches/migrations/0018_remove_order_recipe_alter_order_total_price_and_more.py +58 -0
  6. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/models.py +39 -26
  7. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/tasks.py +27 -6
  8. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/templates/admin/order_list.html +1 -1
  9. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/templates/admin/partials/order_rows.html +5 -1
  10. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/templates/order_detail.html +8 -6
  11. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/templates/profile.html +10 -1
  12. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/urls.py +0 -3
  13. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/views.py +91 -28
  14. {sandwitches-2.5.3 → sandwitches-2.5.5}/README.md +0 -0
  15. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/__init__.py +0 -0
  16. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/asgi.py +0 -0
  17. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/feeds.py +0 -0
  18. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/forms.py +0 -0
  19. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/locale/nl/LC_MESSAGES/django.mo +0 -0
  20. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/locale/nl/LC_MESSAGES/django.po +0 -0
  21. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/management/__init__.py +0 -0
  22. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/management/commands/__init__.py +0 -0
  23. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/management/commands/reset_daily_orders.py +0 -0
  24. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/migrations/0001_initial.py +0 -0
  25. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/migrations/0002_historicalrecipe_servings_recipe_servings.py +0 -0
  26. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/migrations/0003_setting.py +0 -0
  27. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/migrations/0004_alter_setting_ai_api_key_and_more.py +0 -0
  28. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/migrations/0005_rating_comment.py +0 -0
  29. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/migrations/0006_historicalrecipe_is_highlighted_and_more.py +0 -0
  30. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/migrations/0007_historicalrecipe_price_recipe_price_order.py +0 -0
  31. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/migrations/0008_historicalrecipe_daily_orders_count_and_more.py +0 -0
  32. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/migrations/0009_historicalrecipe_is_approved_recipe_is_approved.py +0 -0
  33. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/migrations/0010_rename_is_approved_historicalrecipe_is_community_made_and_more.py +0 -0
  34. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/migrations/0011_alter_historicalrecipe_is_community_made_and_more.py +0 -0
  35. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/migrations/0012_rename_is_community_made_historicalrecipe_is_approved_and_more.py +0 -0
  36. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/migrations/0013_cartitem.py +0 -0
  37. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/migrations/0014_ensure_groups_exist.py +0 -0
  38. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/migrations/0015_order_completed_alter_order_status_and_more.py +0 -0
  39. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/migrations/0016_user_theme.py +0 -0
  40. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/migrations/0017_setting_gotify_token_setting_gotify_url.py +0 -0
  41. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/migrations/__init__.py +0 -0
  42. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/settings.py +0 -0
  43. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/storage.py +0 -0
  44. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/templates/admin/admin_base.html +0 -0
  45. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/templates/admin/confirm_delete.html +0 -0
  46. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/templates/admin/dashboard.html +0 -0
  47. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/templates/admin/partials/dashboard_charts.html +0 -0
  48. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/templates/admin/rating_list.html +0 -0
  49. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/templates/admin/recipe_approval_list.html +0 -0
  50. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/templates/admin/recipe_form.html +0 -0
  51. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/templates/admin/recipe_list.html +0 -0
  52. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/templates/admin/settings.html +0 -0
  53. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/templates/admin/tag_form.html +0 -0
  54. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/templates/admin/tag_list.html +0 -0
  55. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/templates/admin/task_detail.html +0 -0
  56. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/templates/admin/task_list.html +0 -0
  57. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/templates/admin/user_form.html +0 -0
  58. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/templates/admin/user_list.html +0 -0
  59. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/templates/base.html +0 -0
  60. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/templates/base_beer.html +0 -0
  61. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/templates/cart.html +0 -0
  62. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/templates/community.html +0 -0
  63. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/templates/components/carousel_scripts.html +0 -0
  64. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/templates/components/favorites_search_form.html +0 -0
  65. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/templates/components/footer.html +0 -0
  66. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/templates/components/ingredients_scripts.html +0 -0
  67. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/templates/components/ingredients_section.html +0 -0
  68. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/templates/components/instructions_section.html +0 -0
  69. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/templates/components/navbar.html +0 -0
  70. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/templates/components/rating_section.html +0 -0
  71. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/templates/components/recipe_header.html +0 -0
  72. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/templates/components/search_form.html +0 -0
  73. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/templates/components/search_scripts.html +0 -0
  74. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/templates/components/side_menu.html +0 -0
  75. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/templates/components/user_menu.html +0 -0
  76. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/templates/detail.html +0 -0
  77. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/templates/favorites.html +0 -0
  78. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/templates/index.html +0 -0
  79. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/templates/login.html +0 -0
  80. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/templates/partials/recipe_list.html +0 -0
  81. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/templates/settings.html +0 -0
  82. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/templates/setup.html +0 -0
  83. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/templates/signup.html +0 -0
  84. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/templatetags/__init__.py +0 -0
  85. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/templatetags/custom_filters.py +0 -0
  86. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/templatetags/markdown_extras.py +0 -0
  87. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/utils.py +0 -0
  88. {sandwitches-2.5.3 → sandwitches-2.5.5}/src/sandwitches/wsgi.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: sandwitches
3
- Version: 2.5.3
3
+ Version: 2.5.5
4
4
  Summary: Add your description here
5
5
  Author: Martyn van Dijke
6
6
  Author-email: Martyn van Dijke <martijnvdijke600@gmail.com>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sandwitches"
3
- version = "2.5.3"
3
+ version = "2.5.5"
4
4
  description = "Add your description here"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -1,7 +1,7 @@
1
1
  from django.contrib import admin
2
2
  from django.contrib.auth import get_user_model
3
3
  from django.contrib.auth.admin import UserAdmin
4
- from .models import Recipe, Tag, Rating, Setting, Order
4
+ from .models import Recipe, Tag, Rating, Setting, Order, OrderItem
5
5
  from django.utils.html import format_html
6
6
  from import_export import resources
7
7
  from import_export.admin import ImportExportModelAdmin
@@ -81,18 +81,23 @@ class RatingAdmin(ImportExportModelAdmin):
81
81
  resource_classes = [RatingResource]
82
82
 
83
83
 
84
+ class OrderItemInline(admin.TabularInline):
85
+ model = OrderItem
86
+ extra = 0
87
+
88
+
84
89
  @admin.register(Order)
85
90
  class OrderAdmin(ImportExportModelAdmin):
86
91
  resource_classes = [OrderResource]
92
+ inlines = [OrderItemInline]
87
93
  list_display = (
88
94
  "id",
89
95
  "user",
90
- "recipe",
91
96
  "status",
92
97
  "completed",
93
98
  "total_price",
94
99
  "created_at",
95
100
  )
96
101
  list_filter = ("status", "completed", "created_at")
97
- search_fields = ("user__username", "recipe__title")
102
+ search_fields = ("user__username",)
98
103
  readonly_fields = ("total_price", "created_at", "updated_at")
@@ -220,9 +220,25 @@ def get_tag(request, tag_id: int):
220
220
 
221
221
  @api.post("v1/orders", auth=django_auth, response={201: OrderSchema, 400: Error})
222
222
  def create_order(request, payload: CreateOrderSchema):
223
+ from .models import OrderItem
224
+ from django.db import transaction
225
+ from .tasks import notify_order_submitted, send_gotify_notification
226
+
223
227
  recipe = get_object_or_404(Recipe, id=payload.recipe_id)
224
228
  try:
225
- order = Order.objects.create(user=request.user, recipe=recipe) # ty:ignore[unresolved-attribute]
229
+ with transaction.atomic():
230
+ order = Order.objects.create(user=request.user) # ty:ignore[unresolved-attribute]
231
+ OrderItem.objects.create(order=order, recipe=recipe, quantity=1) # ty:ignore[unresolved-attribute]
232
+ order.total_price = recipe.price
233
+ order.save()
234
+
235
+ notify_order_submitted.enqueue(order_id=order.pk)
236
+ send_gotify_notification.enqueue(
237
+ title="New Order Received",
238
+ message=f"Order #{order.pk} by {request.user.username}. Total: {order.total_price}€",
239
+ priority=6,
240
+ )
241
+
226
242
  return 201, order
227
243
  except (ValidationError, ValueError) as e:
228
244
  return 400, {"message": str(e)}
@@ -0,0 +1,58 @@
1
+ # Generated by Django 6.0.1 on 2026-02-02 19:20
2
+
3
+ import django.db.models.deletion
4
+ from django.db import migrations, models
5
+
6
+
7
+ class Migration(migrations.Migration):
8
+ dependencies = [
9
+ ("sandwitches", "0017_setting_gotify_token_setting_gotify_url"),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.RemoveField(
14
+ model_name="order",
15
+ name="recipe",
16
+ ),
17
+ migrations.AlterField(
18
+ model_name="order",
19
+ name="total_price",
20
+ field=models.DecimalField(decimal_places=2, default=0, max_digits=6),
21
+ ),
22
+ migrations.CreateModel(
23
+ name="OrderItem",
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
+ ("quantity", models.PositiveIntegerField(default=1)),
35
+ ("price", models.DecimalField(decimal_places=2, max_digits=6)),
36
+ (
37
+ "order",
38
+ models.ForeignKey(
39
+ on_delete=django.db.models.deletion.CASCADE,
40
+ related_name="items",
41
+ to="sandwitches.order",
42
+ ),
43
+ ),
44
+ (
45
+ "recipe",
46
+ models.ForeignKey(
47
+ on_delete=django.db.models.deletion.CASCADE,
48
+ related_name="order_items",
49
+ to="sandwitches.recipe",
50
+ ),
51
+ ),
52
+ ],
53
+ options={
54
+ "verbose_name": "Order Item",
55
+ "verbose_name_plural": "Order Items",
56
+ },
57
+ ),
58
+ ]
@@ -276,10 +276,9 @@ class Order(models.Model):
276
276
  user = models.ForeignKey(
277
277
  settings.AUTH_USER_MODEL, related_name="orders", on_delete=models.CASCADE
278
278
  )
279
- recipe = models.ForeignKey(Recipe, related_name="orders", on_delete=models.CASCADE)
280
279
  status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="PENDING")
281
280
  completed = models.BooleanField(default=False)
282
- total_price = models.DecimalField(max_digits=6, decimal_places=2)
281
+ total_price = models.DecimalField(max_digits=6, decimal_places=2, default=0)
283
282
  created_at = models.DateTimeField(auto_now_add=True)
284
283
  updated_at = models.DateTimeField(auto_now=True)
285
284
 
@@ -289,40 +288,54 @@ class Order(models.Model):
289
288
  verbose_name_plural = "Orders"
290
289
 
291
290
  def save(self, *args, **kwargs):
292
- if not self.recipe.price: # ty:ignore[possibly-missing-attribute]
293
- raise ValueError("Cannot order a recipe without a price.")
294
- if not self.total_price:
295
- self.total_price = self.recipe.price # ty:ignore[possibly-missing-attribute]
296
-
297
291
  is_new = self.pk is None
298
- if is_new:
299
- # We use select_for_update to lock the row and prevent race conditions
300
- # However, since 'self.recipe' is already fetched, we need to re-fetch it with lock if we want to be strict.
301
- # For simplicity in this context, we will reload it or trust the current instance but ideally:
302
-
303
- # We need to wrap this in a transaction if not already
304
- # But simple increment logic:
305
- if (
306
- self.recipe.max_daily_orders is not None # ty:ignore[possibly-missing-attribute]
307
- and self.recipe.daily_orders_count >= self.recipe.max_daily_orders # ty:ignore[possibly-missing-attribute]
308
- ):
309
- raise ValidationError("Daily order limit reached for this recipe.")
310
-
311
- self.recipe.daily_orders_count += 1 # ty:ignore[possibly-missing-attribute]
312
- self.recipe.save(update_fields=["daily_orders_count"]) # ty:ignore[possibly-missing-attribute]
313
-
314
292
  super().save(*args, **kwargs)
315
-
316
293
  if is_new:
317
294
  notify_order_submitted.enqueue(order_id=self.pk)
318
295
  send_gotify_notification.enqueue(
319
296
  title="New Order Received",
320
- message=f"Order #{self.pk} for '{self.recipe.title}' by {self.user.username}. Total: {self.total_price}€",
297
+ message=f"Order #{self.pk} by {self.user.username}. Total: {self.total_price}€",
321
298
  priority=6,
322
299
  )
323
300
 
324
301
  def __str__(self):
325
- return f"Order #{self.pk} - {self.user} - {self.recipe}"
302
+ return f"Order #{self.pk} - {self.user}"
303
+
304
+
305
+ class OrderItem(models.Model):
306
+ order = models.ForeignKey(Order, related_name="items", on_delete=models.CASCADE)
307
+ recipe = models.ForeignKey(
308
+ Recipe, related_name="order_items", on_delete=models.CASCADE
309
+ )
310
+ quantity = models.PositiveIntegerField(default=1)
311
+ price = models.DecimalField(max_digits=6, decimal_places=2)
312
+
313
+ class Meta:
314
+ verbose_name = "Order Item"
315
+ verbose_name_plural = "Order Items"
316
+
317
+ def save(self, *args, **kwargs):
318
+ if not self.price:
319
+ self.price = self.recipe.price
320
+
321
+ is_new = self.pk is None
322
+ if is_new:
323
+ if (
324
+ self.recipe.max_daily_orders is not None
325
+ and self.recipe.daily_orders_count + self.quantity
326
+ > self.recipe.max_daily_orders
327
+ ):
328
+ raise ValidationError(
329
+ f"Daily order limit reached for {self.recipe.title}."
330
+ )
331
+
332
+ self.recipe.daily_orders_count += self.quantity
333
+ self.recipe.save(update_fields=["daily_orders_count"])
334
+
335
+ super().save(*args, **kwargs)
336
+
337
+ def __str__(self):
338
+ return f"{self.quantity}x {self.recipe.title} in Order #{self.order.pk}"
326
339
 
327
340
 
328
341
  class CartItem(models.Model):
@@ -49,7 +49,11 @@ def notify_order_submitted(order_id):
49
49
  from .models import Order
50
50
 
51
51
  try:
52
- order = Order.objects.select_related("user", "recipe").get(pk=order_id) # ty:ignore[unresolved-attribute]
52
+ order = (
53
+ Order.objects.select_related("user") # ty:ignore[unresolved-attribute]
54
+ .prefetch_related("items__recipe")
55
+ .get(pk=order_id)
56
+ ) # ty:ignore[unresolved-attribute]
53
57
  except Order.DoesNotExist: # ty:ignore[unresolved-attribute]
54
58
  logging.warning(f"Order {order_id} not found. Skipping notification.")
55
59
  return
@@ -59,22 +63,38 @@ def notify_order_submitted(order_id):
59
63
  logging.warning(f"User {user.username} has no email. Skipping notification.")
60
64
  return
61
65
 
62
- recipe = order.recipe
63
- subject = _("Order Confirmation: %(recipe_title)s") % {"recipe_title": recipe.title}
66
+ items = order.items.all()
67
+ if not items:
68
+ logging.warning(f"Order {order_id} has no items. Skipping notification.")
69
+ return
70
+
71
+ # Construct item list string
72
+ item_lines = []
73
+ for item in items:
74
+ item_lines.append(f"- {item.quantity}x {item.recipe.title}")
75
+
76
+ items_summary = "\n".join(item_lines)
77
+ items_html_list = "".join(
78
+ [f"<li>{item.quantity}x {item.recipe.title}</li>" for item in items]
79
+ )
80
+
81
+ subject = _("Order Confirmation: Order #%(order_id)s") % {"order_id": order.id}
64
82
  from_email = getattr(settings, "EMAIL_FROM_ADDRESS")
65
83
 
66
84
  context_data = {
67
85
  "user_name": user.get_full_name() or user.username,
68
- "recipe_title": recipe.title,
86
+ "items_summary": items_summary,
69
87
  "order_id": order.id,
70
88
  "total_price": order.total_price,
89
+ "items_html_list": items_html_list,
71
90
  }
72
91
 
73
92
  text_content = (
74
93
  _(
75
94
  "Hello %(user_name)s,\n\n"
76
- "Your order for %(recipe_title)s has been successfully submitted!\n"
95
+ "Your order has been successfully submitted!\n"
77
96
  "Order ID: %(order_id)s\n"
97
+ "Items:\n%(items_summary)s\n"
78
98
  "Total Price: %(total_price)s\n\n"
79
99
  "Thank you for ordering with Sandwitches.\n"
80
100
  )
@@ -86,9 +106,10 @@ def notify_order_submitted(order_id):
86
106
  "<div style='font-family: sans-serif;'>"
87
107
  "<h2>Order Confirmation</h2>"
88
108
  "<p>Hello <strong>%(user_name)s</strong>,</p>"
89
- "<p>Your order for <strong>%(recipe_title)s</strong> has been successfully submitted!</p>"
109
+ "<p>Your order has been successfully submitted!</p>"
90
110
  "<ul>"
91
111
  "<li>Order ID: %(order_id)s</li>"
112
+ "%(items_html_list)s"
92
113
  "<li>Total Price: %(total_price)s</li>"
93
114
  "</ul>"
94
115
  "<p>Thank you for ordering with Sandwitches.</p>"
@@ -16,7 +16,7 @@
16
16
  <tr>
17
17
  <th>ID</th>
18
18
  <th>{% trans "User" %}</th>
19
- <th>{% trans "Recipe" %}</th>
19
+ <th>{% trans "Items" %}</th>
20
20
  <th>{% trans "Price" %}</th>
21
21
  <th>{% trans "Status" %}</th>
22
22
  <th>{% trans "Date" %}</th>
@@ -12,7 +12,11 @@
12
12
  <span class="max">{{ order.user.username }}</span>
13
13
  </div>
14
14
  </td>
15
- <td>{{ order.recipe.title }}</td>
15
+ <td>
16
+ {% for item in order.items.all %}
17
+ <div>{{ item.quantity }}x {{ item.recipe.title }}</div>
18
+ {% endfor %}
19
+ </td>
16
20
  <td>€ {{ order.total_price }} </td>
17
21
  <td>
18
22
  <form method="post" action="{% url 'admin_order_update_status' order.pk %}" class="row align-center no-space">
@@ -35,22 +35,24 @@
35
35
  <div class="divider"></div>
36
36
 
37
37
  <h6>{% trans "Item Details" %}</h6>
38
+ {% for item in order.items.all %}
38
39
  <div class="row">
39
40
  <div class="max">
40
- <a href="{% url 'recipe_detail' order.recipe.slug %}" class="bold primary-text">{{ order.recipe.title }}</a>
41
- <p class="small-text">{{ order.recipe.description|truncatewords:20 }}</p>
41
+ <a href="{% url 'recipe_detail' item.recipe.slug %}" class="bold primary-text">{{ item.quantity }}x {{ item.recipe.title }}</a>
42
+ <p class="small-text">{{ item.recipe.description|truncatewords:20 }}</p>
42
43
  </div>
43
44
  <div class="min">
44
- <span class="bold">{{ order.total_price }} €</span>
45
+ <!-- Calculate item total: price * quantity -->
46
+ <span class="bold">{% widthratio item.price 1 item.quantity %} €</span>
45
47
  </div>
46
48
  </div>
47
49
 
48
- {% if order.recipe.image %}
50
+ {% if item.recipe.image %}
49
51
  <div class="space"></div>
50
- <img src="{{ order.recipe.image.url }}" class="responsive round border" alt="{{ order.recipe.title }}">
52
+ <img src="{{ item.recipe.image.url }}" class="responsive round border" alt="{{ item.recipe.title }}" style="max-height: 200px; object-fit: cover;">
51
53
  {% endif %}
52
-
53
54
  <div class="divider"></div>
55
+ {% endfor %}
54
56
 
55
57
  <div class="row">
56
58
  <div class="max text-right">
@@ -136,7 +136,16 @@
136
136
  <tr>
137
137
  <td>{{ order.id }}</td>
138
138
  <td>
139
- <a href="{% url 'recipe_detail' order.recipe.slug %}">{{ order.recipe.title }}</a>
139
+ {% with first_item=order.items.first %}
140
+ {% if first_item %}
141
+ <a href="{% url 'recipe_detail' first_item.recipe.slug %}">{{ first_item.recipe.title }}</a>
142
+ {% if order.items.count > 1 %}
143
+ <span class="small-text">(+{{ order.items.count|add:"-1" }})</span>
144
+ {% endif %}
145
+ {% else %}
146
+ <span>{% trans "No items" %}</span>
147
+ {% endif %}
148
+ {% endwith %}
140
149
  </td>
141
150
  <td>{{ order.created_at|date:"d/m/Y" }}</td>
142
151
  <td>
@@ -50,9 +50,6 @@ urlpatterns = [
50
50
  path("cart/checkout/", views.checkout_cart, name="checkout_cart"),
51
51
  path("", views.index, name="index"),
52
52
  path("feeds/latest/", LatestRecipesFeed(), name="latest_recipes_feed"),
53
- path(
54
- "feeds/latest/", LatestRecipesFeed(), name="latest_recipes_feed"
55
- ), # Add this line
56
53
  ]
57
54
 
58
55
  urlpatterns += i18n_patterns(
@@ -9,7 +9,11 @@ from django.contrib.auth.decorators import login_required
9
9
  from django.contrib.admin.views.decorators import staff_member_required
10
10
  from django.utils.translation import gettext as _
11
11
  from django.utils import translation
12
- from .models import Recipe, Rating, Tag, Order, CartItem, Setting
12
+ from .models import Recipe, Rating, Tag, Order, CartItem, Setting, OrderItem
13
+ from .tasks import (
14
+ notify_order_submitted,
15
+ send_gotify_notification,
16
+ )
13
17
  from .utils import ORDER_DB
14
18
  from .forms import (
15
19
  RecipeForm,
@@ -542,7 +546,8 @@ def admin_order_update_status(request, pk):
542
546
  @staff_member_required
543
547
  def admin_order_list(request):
544
548
  orders = (
545
- Order.objects.select_related("user", "recipe") # ty:ignore[unresolved-attribute]
549
+ Order.objects.select_related("user") # ty:ignore[unresolved-attribute]
550
+ .prefetch_related("items__recipe")
546
551
  .all()
547
552
  .order_by("-created_at")
548
553
  )
@@ -626,12 +631,27 @@ def order_recipe(request, pk):
626
631
  """
627
632
  Create an order for the given recipe by the logged-in user.
628
633
  """
634
+ from django.db import transaction
635
+ from .models import OrderItem
636
+
629
637
  recipe = get_object_or_404(Recipe, pk=pk)
630
638
  if request.method != "POST":
631
639
  return redirect("recipe_detail", slug=recipe.slug)
632
640
 
633
641
  try:
634
- order = Order.objects.create(user=request.user, recipe=recipe) # ty:ignore[unresolved-attribute]
642
+ with transaction.atomic():
643
+ order = Order.objects.create(user=request.user) # ty:ignore[unresolved-attribute]
644
+ OrderItem.objects.create(order=order, recipe=recipe, quantity=1) # ty:ignore[unresolved-attribute]
645
+ order.total_price = recipe.price
646
+ order.save()
647
+
648
+ notify_order_submitted.enqueue(order_id=order.pk)
649
+ send_gotify_notification.enqueue(
650
+ title="New Order Received",
651
+ message=f"Order #{order.pk} by {request.user.username}. Total: {order.total_price}€",
652
+ priority=6,
653
+ )
654
+
635
655
  logging.debug(f"Created {order}")
636
656
  messages.success(
637
657
  request,
@@ -918,7 +938,7 @@ def user_profile(request):
918
938
  else:
919
939
  form = UserProfileForm(instance=request.user)
920
940
 
921
- orders = request.user.orders.select_related("recipe").all()
941
+ orders = request.user.orders.prefetch_related("items__recipe").all()
922
942
 
923
943
  # Filtering
924
944
  status_filter = request.GET.get("status")
@@ -1052,39 +1072,82 @@ def update_cart_quantity(request, pk):
1052
1072
 
1053
1073
  @login_required
1054
1074
  def checkout_cart(request):
1055
- cart_items = CartItem.objects.filter(user=request.user) # ty:ignore[unresolved-attribute]
1075
+ if request.method != "POST":
1076
+ return redirect("view_cart")
1077
+
1078
+ cart_items = CartItem.objects.filter(user=request.user).select_related("recipe") # ty:ignore[unresolved-attribute]
1056
1079
  if not cart_items.exists():
1057
1080
  messages.error(request, _("Your cart is empty."))
1058
1081
  return redirect("view_cart")
1059
1082
 
1060
- created_orders = [] # noqa: F841
1061
- errors = []
1062
-
1063
- # We use a transaction to ensure either all orders are created or none if something goes wrong
1064
1083
  from django.db import transaction
1065
1084
 
1066
1085
  try:
1067
1086
  with transaction.atomic():
1087
+ # 1. Validate all items have prices and satisfy limits
1088
+ for item in cart_items:
1089
+ if not item.recipe.price:
1090
+ raise ValidationError(
1091
+ _(
1092
+ "Recipe '%(title)s' cannot be ordered because it has no price."
1093
+ )
1094
+ % {"title": item.recipe.title}
1095
+ )
1096
+
1097
+ if (
1098
+ item.recipe.max_daily_orders is not None
1099
+ and item.recipe.daily_orders_count + item.quantity
1100
+ > item.recipe.max_daily_orders
1101
+ ):
1102
+ raise ValidationError(
1103
+ _("Daily order limit reached for %(title)s.")
1104
+ % {"title": item.recipe.title}
1105
+ )
1106
+
1107
+ # 2. Create the order
1108
+ order = Order.objects.create(user=request.user) # ty:ignore[unresolved-attribute]
1109
+ total_price = 0
1110
+
1111
+ # 3. Create order items and update recipe counts
1068
1112
  for item in cart_items:
1069
- # Create Order for each recipe in cart (quantity times?)
1070
- # Current Order model doesn't have quantity, so we create multiple orders or update Order model.
1071
- # For now, let's create 'quantity' number of orders as per current schema
1072
- # OR we could update Order model to support quantity.
1073
- # Let's see if Order has quantity. (Checked: it does not).
1074
- for i in range(item.quantity):
1075
- try:
1076
- Order.objects.create(user=request.user, recipe=item.recipe) # ty:ignore[unresolved-attribute]
1077
- except (ValidationError, ValueError) as e:
1078
- errors.append(f"{item.recipe.title}: {str(e)}")
1079
- raise e # Trigger rollback
1113
+ order_item = OrderItem.objects.create( # ty:ignore[unresolved-attribute]
1114
+ order=order,
1115
+ recipe=item.recipe,
1116
+ quantity=item.quantity,
1117
+ price=item.recipe.price,
1118
+ )
1119
+ total_price += order_item.price * order_item.quantity
1080
1120
 
1121
+ # Note: OrderItem.save already updates daily_orders_count
1122
+ # But since we already checked it, it should be fine.
1123
+
1124
+ # 4. Finalize order
1125
+ order.total_price = total_price
1126
+ order.save()
1127
+
1128
+ # 5. Clear cart
1081
1129
  cart_items.delete()
1082
- messages.success(request, _("Orders submitted successfully!"))
1083
- return redirect("user_profile")
1084
- except Exception:
1085
- if errors:
1086
- for error in errors:
1087
- messages.error(request, error)
1088
- else:
1089
- messages.error(request, _("An error occurred during checkout."))
1130
+
1131
+ # 6. Notifications (outside atomic block for better reliability if using DB-backed tasks)
1132
+ notify_order_submitted.enqueue(order_id=order.pk)
1133
+ send_gotify_notification.enqueue(
1134
+ title="New Order Received",
1135
+ message=f"Order #{order.pk} by {request.user.username}. Total: {order.total_price}€",
1136
+ priority=6,
1137
+ )
1138
+
1139
+ messages.success(request, _("Orders submitted successfully!"))
1140
+ return redirect("user_profile")
1141
+
1142
+ except ValidationError as e:
1143
+ # Specific validation error (e.g. price missing or limit reached)
1144
+ messages.error(request, str(e))
1145
+ return redirect("view_cart")
1146
+ except Exception as e:
1147
+ # General error
1148
+ logging.exception("Error during checkout")
1149
+ messages.error(
1150
+ request,
1151
+ _("An error occurred during checkout: %(error)s") % {"error": str(e)},
1152
+ )
1090
1153
  return redirect("view_cart")
File without changes