sandwitches 2.2.0__py3-none-any.whl → 2.3.1__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 (32) hide show
  1. sandwitches/admin.py +23 -2
  2. sandwitches/api.py +22 -1
  3. sandwitches/forms.py +49 -0
  4. sandwitches/management/__init__.py +0 -0
  5. sandwitches/management/commands/__init__.py +0 -0
  6. sandwitches/management/commands/reset_daily_orders.py +14 -0
  7. sandwitches/migrations/0007_historicalrecipe_price_recipe_price_order.py +86 -0
  8. sandwitches/migrations/0008_historicalrecipe_daily_orders_count_and_more.py +36 -0
  9. sandwitches/migrations/0009_historicalrecipe_is_approved_recipe_is_approved.py +22 -0
  10. sandwitches/models.py +63 -1
  11. sandwitches/settings.py +1 -0
  12. sandwitches/tasks.py +74 -0
  13. sandwitches/templates/admin/admin_base.html +4 -0
  14. sandwitches/templates/admin/dashboard.html +125 -61
  15. sandwitches/templates/admin/order_list.html +30 -0
  16. sandwitches/templates/admin/partials/dashboard_charts.html +90 -0
  17. sandwitches/templates/admin/partials/order_rows.html +28 -0
  18. sandwitches/templates/admin/rating_list.html +2 -0
  19. sandwitches/templates/admin/recipe_form.html +26 -0
  20. sandwitches/templates/admin/recipe_list.html +65 -15
  21. sandwitches/templates/base.html +12 -0
  22. sandwitches/templates/components/navbar.html +10 -5
  23. sandwitches/templates/components/recipe_header.html +17 -0
  24. sandwitches/templates/components/side_menu.html +4 -0
  25. sandwitches/templates/partials/recipe_list.html +7 -0
  26. sandwitches/templates/profile.html +95 -0
  27. sandwitches/templates/recipe_form.html +15 -1
  28. sandwitches/urls.py +9 -0
  29. sandwitches/views.py +178 -21
  30. {sandwitches-2.2.0.dist-info → sandwitches-2.3.1.dist-info}/METADATA +1 -1
  31. {sandwitches-2.2.0.dist-info → sandwitches-2.3.1.dist-info}/RECORD +32 -22
  32. {sandwitches-2.2.0.dist-info → sandwitches-2.3.1.dist-info}/WHEEL +0 -0
sandwitches/admin.py CHANGED
@@ -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
4
+ from .models import Recipe, Tag, Rating, Setting, Order
5
5
  from django.utils.html import format_html
6
6
  from import_export import resources
7
7
  from import_export.admin import ImportExportModelAdmin
@@ -24,6 +24,11 @@ class RatingResource(resources.ModelResource):
24
24
  model = Rating
25
25
 
26
26
 
27
+ class OrderResource(resources.ModelResource):
28
+ class Meta:
29
+ model = Order
30
+
31
+
27
32
  User = get_user_model()
28
33
 
29
34
 
@@ -42,7 +47,14 @@ class CustomUserAdmin(UserAdmin):
42
47
  @admin.register(Recipe)
43
48
  class RecipeAdmin(ImportExportModelAdmin):
44
49
  resource_classes = [RecipeResource]
45
- list_display = ("title", "uploaded_by", "created_at", "is_highlighted", "show_url")
50
+ list_display = (
51
+ "title",
52
+ "uploaded_by",
53
+ "price",
54
+ "created_at",
55
+ "is_highlighted",
56
+ "show_url",
57
+ )
46
58
  list_editable = ("is_highlighted",)
47
59
  readonly_fields = ("created_at", "updated_at")
48
60
 
@@ -67,3 +79,12 @@ class TagAdmin(ImportExportModelAdmin):
67
79
  @admin.register(Rating)
68
80
  class RatingAdmin(ImportExportModelAdmin):
69
81
  resource_classes = [RatingResource]
82
+
83
+
84
+ @admin.register(Order)
85
+ class OrderAdmin(ImportExportModelAdmin):
86
+ resource_classes = [OrderResource]
87
+ list_display = ("id", "user", "recipe", "status", "total_price", "created_at")
88
+ list_filter = ("status", "created_at")
89
+ search_fields = ("user__username", "recipe__title")
90
+ readonly_fields = ("total_price", "created_at", "updated_at")
sandwitches/api.py CHANGED
@@ -1,6 +1,6 @@
1
1
  import logging
2
2
  from ninja import NinjaAPI
3
- from .models import Recipe, Tag, Setting, Rating
3
+ from .models import Recipe, Tag, Setting, Rating, Order
4
4
  from django.contrib.auth import get_user_model
5
5
  from .utils import (
6
6
  parse_ingredient_line,
@@ -14,6 +14,7 @@ from django.shortcuts import get_object_or_404
14
14
  from datetime import date
15
15
  import random
16
16
  from typing import List, Optional # Import typing hints
17
+ from django.core.exceptions import ValidationError
17
18
 
18
19
  from ninja.security import django_auth
19
20
 
@@ -82,6 +83,16 @@ class ScaledIngredient(Schema): # New Schema for scaled ingredients
82
83
  name: Optional[str]
83
84
 
84
85
 
86
+ class OrderSchema(ModelSchema):
87
+ class Meta:
88
+ model = Order
89
+ fields = ["id", "status", "total_price", "created_at"]
90
+
91
+
92
+ class CreateOrderSchema(Schema):
93
+ recipe_id: int
94
+
95
+
85
96
  @api.get("ping")
86
97
  def ping(request):
87
98
  return {"status": "ok", "message": "pong"}
@@ -205,3 +216,13 @@ def get_tags(request):
205
216
  def get_tag(request, tag_id: int):
206
217
  tag = get_object_or_404(Tag, id=tag_id)
207
218
  return tag
219
+
220
+
221
+ @api.post("v1/orders", auth=django_auth, response={201: OrderSchema, 400: Error})
222
+ def create_order(request, payload: CreateOrderSchema):
223
+ recipe = get_object_or_404(Recipe, id=payload.recipe_id)
224
+ try:
225
+ order = Order.objects.create(user=request.user, recipe=recipe) # ty:ignore[unresolved-attribute]
226
+ return 201, order
227
+ except (ValidationError, ValueError) as e:
228
+ return 400, {"message": str(e)}
sandwitches/forms.py CHANGED
@@ -85,6 +85,18 @@ class UserSignupForm(UserCreationForm, BaseUserFormMixin):
85
85
  return user
86
86
 
87
87
 
88
+ class UserProfileForm(forms.ModelForm):
89
+ class Meta:
90
+ model = User
91
+ fields = (
92
+ "first_name",
93
+ "last_name",
94
+ "email",
95
+ "avatar",
96
+ "bio",
97
+ )
98
+
99
+
88
100
  class UserEditForm(forms.ModelForm):
89
101
  class Meta:
90
102
  model = User
@@ -124,6 +136,10 @@ class RecipeForm(forms.ModelForm):
124
136
  "description",
125
137
  "ingredients",
126
138
  "instructions",
139
+ "price",
140
+ "is_highlighted",
141
+ "is_approved",
142
+ "max_daily_orders",
127
143
  ]
128
144
  widgets = {
129
145
  "image": forms.FileInput(),
@@ -162,6 +178,39 @@ class RecipeForm(forms.ModelForm):
162
178
  return recipe
163
179
 
164
180
 
181
+ class UserRecipeSubmissionForm(forms.ModelForm):
182
+ tags_string = forms.CharField(
183
+ required=False,
184
+ label=_("Tags (comma separated)"),
185
+ widget=forms.TextInput(attrs={"placeholder": _("e.g. spicy, vegan, quick")}),
186
+ )
187
+
188
+ class Meta:
189
+ model = Recipe
190
+ fields = [
191
+ "title",
192
+ "image",
193
+ "description",
194
+ "ingredients",
195
+ "instructions",
196
+ "price",
197
+ "servings",
198
+ ]
199
+ widgets = {
200
+ "image": forms.FileInput(),
201
+ }
202
+
203
+ def save(self, commit=True):
204
+ recipe = super().save(commit=commit)
205
+ if commit:
206
+ recipe.set_tags_from_string(self.cleaned_data.get("tags_string", ""))
207
+ else:
208
+ self.save_m2m = lambda: recipe.set_tags_from_string(
209
+ self.cleaned_data.get("tags_string", "")
210
+ )
211
+ return recipe
212
+
213
+
165
214
  class RatingForm(forms.Form):
166
215
  """Form for rating recipes (0-10) with an optional comment."""
167
216
 
File without changes
File without changes
@@ -0,0 +1,14 @@
1
+ from django.core.management.base import BaseCommand
2
+ from sandwitches.tasks import reset_daily_orders
3
+
4
+
5
+ class Command(BaseCommand):
6
+ help = "Resets the daily order count for all recipes. Should be run at midnight."
7
+
8
+ def handle(self, *args, **options):
9
+ task_result = reset_daily_orders.enqueue()
10
+ self.stdout.write(
11
+ self.style.SUCCESS(
12
+ f"Enqueued daily order count reset task (Result ID: {task_result.id})."
13
+ )
14
+ )
@@ -0,0 +1,86 @@
1
+ # Generated by Django 6.0.1 on 2026-01-20 10:49
2
+
3
+ import django.db.models.deletion
4
+ from django.conf import settings
5
+ from django.db import migrations, models
6
+
7
+
8
+ class Migration(migrations.Migration):
9
+ dependencies = [
10
+ ("sandwitches", "0006_historicalrecipe_is_highlighted_and_more"),
11
+ ]
12
+
13
+ operations = [
14
+ migrations.AddField(
15
+ model_name="historicalrecipe",
16
+ name="price",
17
+ field=models.DecimalField(
18
+ blank=True,
19
+ decimal_places=2,
20
+ max_digits=6,
21
+ null=True,
22
+ verbose_name="Price (€)",
23
+ ),
24
+ ),
25
+ migrations.AddField(
26
+ model_name="recipe",
27
+ name="price",
28
+ field=models.DecimalField(
29
+ blank=True,
30
+ decimal_places=2,
31
+ max_digits=6,
32
+ null=True,
33
+ verbose_name="Price (€)",
34
+ ),
35
+ ),
36
+ migrations.CreateModel(
37
+ name="Order",
38
+ fields=[
39
+ (
40
+ "id",
41
+ models.BigAutoField(
42
+ auto_created=True,
43
+ primary_key=True,
44
+ serialize=False,
45
+ verbose_name="ID",
46
+ ),
47
+ ),
48
+ (
49
+ "status",
50
+ models.CharField(
51
+ choices=[
52
+ ("PENDING", "Pending"),
53
+ ("COMPLETED", "Completed"),
54
+ ("CANCELLED", "Cancelled"),
55
+ ],
56
+ default="PENDING",
57
+ max_length=20,
58
+ ),
59
+ ),
60
+ ("total_price", models.DecimalField(decimal_places=2, max_digits=6)),
61
+ ("created_at", models.DateTimeField(auto_now_add=True)),
62
+ ("updated_at", models.DateTimeField(auto_now=True)),
63
+ (
64
+ "recipe",
65
+ models.ForeignKey(
66
+ on_delete=django.db.models.deletion.CASCADE,
67
+ related_name="orders",
68
+ to="sandwitches.recipe",
69
+ ),
70
+ ),
71
+ (
72
+ "user",
73
+ models.ForeignKey(
74
+ on_delete=django.db.models.deletion.CASCADE,
75
+ related_name="orders",
76
+ to=settings.AUTH_USER_MODEL,
77
+ ),
78
+ ),
79
+ ],
80
+ options={
81
+ "verbose_name": "Order",
82
+ "verbose_name_plural": "Orders",
83
+ "ordering": ("-created_at",),
84
+ },
85
+ ),
86
+ ]
@@ -0,0 +1,36 @@
1
+ # Generated by Django 6.0.1 on 2026-01-21 09:53
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+ dependencies = [
8
+ ("sandwitches", "0007_historicalrecipe_price_recipe_price_order"),
9
+ ]
10
+
11
+ operations = [
12
+ migrations.AddField(
13
+ model_name="historicalrecipe",
14
+ name="daily_orders_count",
15
+ field=models.PositiveIntegerField(default=0),
16
+ ),
17
+ migrations.AddField(
18
+ model_name="historicalrecipe",
19
+ name="max_daily_orders",
20
+ field=models.PositiveIntegerField(
21
+ blank=True, null=True, verbose_name="Max daily orders"
22
+ ),
23
+ ),
24
+ migrations.AddField(
25
+ model_name="recipe",
26
+ name="daily_orders_count",
27
+ field=models.PositiveIntegerField(default=0),
28
+ ),
29
+ migrations.AddField(
30
+ model_name="recipe",
31
+ name="max_daily_orders",
32
+ field=models.PositiveIntegerField(
33
+ blank=True, null=True, verbose_name="Max daily orders"
34
+ ),
35
+ ),
36
+ ]
@@ -0,0 +1,22 @@
1
+ # Generated by Django 6.0.1 on 2026-01-22 12:41
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+ dependencies = [
8
+ ("sandwitches", "0008_historicalrecipe_daily_orders_count_and_more"),
9
+ ]
10
+
11
+ operations = [
12
+ migrations.AddField(
13
+ model_name="historicalrecipe",
14
+ name="is_approved",
15
+ field=models.BooleanField(default=True),
16
+ ),
17
+ migrations.AddField(
18
+ model_name="recipe",
19
+ name="is_approved",
20
+ field=models.BooleanField(default=True),
21
+ ),
22
+ ]
sandwitches/models.py CHANGED
@@ -4,12 +4,13 @@ from .storage import HashedFilenameStorage
4
4
  from simple_history.models import HistoricalRecords
5
5
  from django.contrib.auth.models import AbstractUser
6
6
  from django.db.models import Avg
7
- from .tasks import email_users
7
+ from .tasks import email_users, notify_order_submitted
8
8
  from django.conf import settings
9
9
  from django.core.validators import MinValueValidator, MaxValueValidator
10
10
  import logging
11
11
  from django.urls import reverse
12
12
  from solo.models import SingletonModel
13
+ from django.core.exceptions import ValidationError
13
14
 
14
15
  from imagekit.models import ImageSpecField
15
16
  from imagekit.processors import ResizeToFill
@@ -89,6 +90,9 @@ class Recipe(models.Model):
89
90
  ingredients = models.TextField(blank=True)
90
91
  instructions = models.TextField(blank=True)
91
92
  servings = models.IntegerField(default=1, validators=[MinValueValidator(1)])
93
+ price = models.DecimalField(
94
+ max_digits=6, decimal_places=2, null=True, blank=True, verbose_name="Price (€)"
95
+ )
92
96
  uploaded_by = models.ForeignKey(
93
97
  settings.AUTH_USER_MODEL,
94
98
  related_name="recipes",
@@ -128,6 +132,11 @@ class Recipe(models.Model):
128
132
  )
129
133
  tags = models.ManyToManyField(Tag, blank=True, related_name="recipes")
130
134
  is_highlighted = models.BooleanField(default=False)
135
+ is_approved = models.BooleanField(default=True)
136
+ max_daily_orders = models.PositiveIntegerField(
137
+ null=True, blank=True, verbose_name="Max daily orders"
138
+ )
139
+ daily_orders_count = models.PositiveIntegerField(default=0)
131
140
  created_at = models.DateTimeField(auto_now_add=True)
132
141
  updated_at = models.DateTimeField(auto_now=True)
133
142
  history = HistoricalRecords()
@@ -216,3 +225,56 @@ class Rating(models.Model):
216
225
 
217
226
  def __str__(self):
218
227
  return f"{self.recipe} — {self.score} by {self.user}"
228
+
229
+
230
+ class Order(models.Model):
231
+ STATUS_CHOICES = (
232
+ ("PENDING", "Pending"),
233
+ ("COMPLETED", "Completed"),
234
+ ("CANCELLED", "Cancelled"),
235
+ )
236
+
237
+ user = models.ForeignKey(
238
+ settings.AUTH_USER_MODEL, related_name="orders", on_delete=models.CASCADE
239
+ )
240
+ recipe = models.ForeignKey(Recipe, related_name="orders", on_delete=models.CASCADE)
241
+ status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="PENDING")
242
+ total_price = models.DecimalField(max_digits=6, decimal_places=2)
243
+ created_at = models.DateTimeField(auto_now_add=True)
244
+ updated_at = models.DateTimeField(auto_now=True)
245
+
246
+ class Meta:
247
+ ordering = ("-created_at",)
248
+ verbose_name = "Order"
249
+ verbose_name_plural = "Orders"
250
+
251
+ def save(self, *args, **kwargs):
252
+ if not self.recipe.price: # ty:ignore[possibly-missing-attribute]
253
+ raise ValueError("Cannot order a recipe without a price.")
254
+ if not self.total_price:
255
+ self.total_price = self.recipe.price # ty:ignore[possibly-missing-attribute]
256
+
257
+ is_new = self.pk is None
258
+ if is_new:
259
+ # We use select_for_update to lock the row and prevent race conditions
260
+ # However, since 'self.recipe' is already fetched, we need to re-fetch it with lock if we want to be strict.
261
+ # For simplicity in this context, we will reload it or trust the current instance but ideally:
262
+
263
+ # We need to wrap this in a transaction if not already
264
+ # But simple increment logic:
265
+ if (
266
+ self.recipe.max_daily_orders is not None # ty:ignore[possibly-missing-attribute]
267
+ and self.recipe.daily_orders_count >= self.recipe.max_daily_orders # ty:ignore[possibly-missing-attribute]
268
+ ):
269
+ raise ValidationError("Daily order limit reached for this recipe.")
270
+
271
+ self.recipe.daily_orders_count += 1 # ty:ignore[possibly-missing-attribute]
272
+ self.recipe.save(update_fields=["daily_orders_count"]) # ty:ignore[possibly-missing-attribute]
273
+
274
+ super().save(*args, **kwargs)
275
+
276
+ if is_new:
277
+ notify_order_submitted.enqueue(order_id=self.pk)
278
+
279
+ def __str__(self):
280
+ return f"Order #{self.pk} - {self.user} - {self.recipe}"
sandwitches/settings.py CHANGED
@@ -132,6 +132,7 @@ LOGGING = {
132
132
 
133
133
  LOGIN_REDIRECT_URL = "index"
134
134
  LOGOUT_REDIRECT_URL = "index"
135
+ LOGIN_URL = "login"
135
136
 
136
137
 
137
138
  # Password validation
sandwitches/tasks.py CHANGED
@@ -35,6 +35,80 @@ def email_users(context, recipe_id):
35
35
  return True
36
36
 
37
37
 
38
+ @task(priority=5)
39
+ def reset_daily_orders():
40
+ from .models import Recipe
41
+
42
+ count = Recipe.objects.update(daily_orders_count=0) # ty:ignore[unresolved-attribute]
43
+ logging.info(f"Successfully reset daily order count for {count} recipes.")
44
+ return count
45
+
46
+
47
+ @task(priority=2, queue_name="emails")
48
+ def notify_order_submitted(order_id):
49
+ from .models import Order
50
+
51
+ try:
52
+ order = Order.objects.select_related("user", "recipe").get(pk=order_id) # ty:ignore[unresolved-attribute]
53
+ except Order.DoesNotExist: # ty:ignore[unresolved-attribute]
54
+ logging.warning(f"Order {order_id} not found. Skipping notification.")
55
+ return
56
+
57
+ user = order.user
58
+ if not user.email:
59
+ logging.warning(f"User {user.username} has no email. Skipping notification.")
60
+ return
61
+
62
+ recipe = order.recipe
63
+ subject = _("Order Confirmation: %(recipe_title)s") % {"recipe_title": recipe.title}
64
+ from_email = getattr(settings, "EMAIL_FROM_ADDRESS")
65
+
66
+ context_data = {
67
+ "user_name": user.get_full_name() or user.username,
68
+ "recipe_title": recipe.title,
69
+ "order_id": order.id,
70
+ "total_price": order.total_price,
71
+ }
72
+
73
+ text_content = (
74
+ _(
75
+ "Hello %(user_name)s,\n\n"
76
+ "Your order for %(recipe_title)s has been successfully submitted!\n"
77
+ "Order ID: %(order_id)s\n"
78
+ "Total Price: %(total_price)s\n\n"
79
+ "Thank you for ordering with Sandwitches.\n"
80
+ )
81
+ % context_data
82
+ )
83
+
84
+ html_content = (
85
+ _(
86
+ "<div style='font-family: sans-serif;'>"
87
+ "<h2>Order Confirmation</h2>"
88
+ "<p>Hello <strong>%(user_name)s</strong>,</p>"
89
+ "<p>Your order for <strong>%(recipe_title)s</strong> has been successfully submitted!</p>"
90
+ "<ul>"
91
+ "<li>Order ID: %(order_id)s</li>"
92
+ "<li>Total Price: %(total_price)s</li>"
93
+ "</ul>"
94
+ "<p>Thank you for ordering with Sandwitches.</p>"
95
+ "</div>"
96
+ )
97
+ % context_data
98
+ )
99
+
100
+ msg = EmailMultiAlternatives(
101
+ subject=subject,
102
+ body=text_content,
103
+ from_email=from_email,
104
+ to=[user.email],
105
+ )
106
+ msg.attach_alternative(html_content, "text/html")
107
+ msg.send()
108
+
109
+ logging.info(f"Order confirmation email sent to {user.email} for order {order.id}")
110
+
111
+
38
112
  def send_emails(recipe_id, emails):
39
113
  from .models import Recipe
40
114
 
@@ -68,6 +68,10 @@
68
68
  <i>star</i>
69
69
  <span>{% trans "Ratings" %}</span>
70
70
  </a>
71
+ <a href="{% url 'admin_order_list' %}" class="{% if request.resolver_match.url_name == 'admin_order_list' %}active{% endif %}">
72
+ <i>shopping_cart</i>
73
+ <span>{% trans "Orders" %}</span>
74
+ </a>
71
75
  <a href="{% url 'admin_task_list' %}" class="{% if request.resolver_match.url_name == 'admin_task_list' %}active{% endif %}">
72
76
  <i>assignment</i>
73
77
  <span>{% trans "Tasks" %}</span>