sandwitches 2.3.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.
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
@@ -137,6 +137,9 @@ class RecipeForm(forms.ModelForm):
137
137
  "ingredients",
138
138
  "instructions",
139
139
  "price",
140
+ "is_highlighted",
141
+ "is_approved",
142
+ "max_daily_orders",
140
143
  ]
141
144
  widgets = {
142
145
  "image": forms.FileInput(),
@@ -175,6 +178,39 @@ class RecipeForm(forms.ModelForm):
175
178
  return recipe
176
179
 
177
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
+
178
214
  class RatingForm(forms.Form):
179
215
  """Form for rating recipes (0-10) with an optional comment."""
180
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,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
@@ -131,6 +132,11 @@ class Recipe(models.Model):
131
132
  )
132
133
  tags = models.ManyToManyField(Tag, blank=True, related_name="recipes")
133
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)
134
140
  created_at = models.DateTimeField(auto_now_add=True)
135
141
  updated_at = models.DateTimeField(auto_now=True)
136
142
  history = HistoricalRecords()
@@ -247,7 +253,28 @@ class Order(models.Model):
247
253
  raise ValueError("Cannot order a recipe without a price.")
248
254
  if not self.total_price:
249
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
+
250
274
  super().save(*args, **kwargs)
251
275
 
276
+ if is_new:
277
+ notify_order_submitted.enqueue(order_id=self.pk)
278
+
252
279
  def __str__(self):
253
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>
@@ -127,37 +127,147 @@
127
127
 
128
128
 
129
129
 
130
- <!-- Charts Section -->
130
+ <!-- Charts Section -->
131
131
 
132
- <div class="s12 m6">
133
132
 
134
- <article class="round border padding">
135
133
 
136
- <h6 class="bold mb-1">{% trans "Recipes Over Time (Last 30 Days)" %}</h6>
134
+ <div class="s12">
137
135
 
138
- <canvas id="recipeChart" style="width:100%; max-height:300px;"></canvas>
139
136
 
140
- </article>
141
137
 
142
- </div>
138
+ {% include "admin/partials/dashboard_charts.html" %}
139
+
140
+
141
+
142
+ </div>
143
+
144
+
145
+
146
+
147
+
148
+
149
+
150
+ {% if pending_recipes %}
151
+
152
+
153
+
154
+ <div class="s12">
155
+
156
+
157
+
158
+ <h5 class="bold error-text">{% trans "Pending Approvals" %}</h5>
159
+
160
+
161
+
162
+ <table class="border striped no-space">
163
+
164
+
165
+
166
+ <thead>
167
+
168
+
169
+
170
+ <tr>
171
+
172
+
173
+
174
+ <th>{% trans "Title" %}</th>
175
+
176
+
177
+
178
+ <th>{% trans "Uploader" %}</th>
179
+
180
+
181
+
182
+ <th>{% trans "Created At" %}</th>
183
+
184
+
185
+
186
+ <th class="right-align">{% trans "Actions" %}</th>
187
+
188
+
189
+
190
+ </tr>
191
+
192
+
193
+
194
+ </thead>
195
+
196
+
197
+
198
+ <tbody>
199
+
200
+
201
+
202
+ {% for recipe in pending_recipes %}
203
+
204
+
205
+
206
+ <tr class="pointer" onclick="location.href='{% url 'admin_recipe_edit' recipe.pk %}'">
207
+
208
+
209
+
210
+ <td>{{ recipe.title }}</td>
211
+
212
+
213
+
214
+ <td>{{ recipe.uploaded_by.username|default:"-" }}</td>
215
+
216
+
217
+
218
+ <td>{{ recipe.created_at|date:"SHORT_DATETIME_FORMAT" }}</td>
219
+
220
+
221
+
222
+ <td class="right-align">
223
+
224
+
225
+
226
+ <a href="{% url 'admin_recipe_approve' recipe.pk %}" class="button circle transparent" onclick="event.stopPropagation();" title="{% trans 'Approve' %}"><i>check</i></a>
227
+
228
+
229
+
230
+ <a href="{% url 'admin_recipe_edit' recipe.pk %}" class="button circle transparent" onclick="event.stopPropagation();"><i>edit</i></a>
231
+
232
+
233
+
234
+ </td>
235
+
236
+
237
+
238
+ </tr>
239
+
240
+
241
+
242
+ {% endfor %}
243
+
244
+
245
+
246
+ </tbody>
247
+
248
+
249
+
250
+ </table>
251
+
252
+
253
+
254
+ </div>
255
+
256
+
257
+
258
+ {% endif %}
143
259
 
144
- <div class="s12 m6">
145
260
 
146
- <article class="round border padding">
147
261
 
148
- <h6 class="bold mb-1">{% trans "Average Rating Over Time (Last 30 Days)" %}</h6>
149
262
 
150
- <canvas id="ratingChart" style="width:100%; max-height:300px;"></canvas>
151
263
 
152
- </article>
153
264
 
154
- </div>
155
265
 
266
+ <div class="s12">
156
267
 
157
268
 
158
- <div class="s12">
159
269
 
160
- <h5 class="bold">{% trans "Recent Recipes" %}</h5>
270
+ <h5 class="bold">{% trans "Recent Recipes" %}</h5>
161
271
 
162
272
  <table class="border striped no-space">
163
273
 
@@ -213,50 +323,4 @@
213
323
 
214
324
  {% block admin_scripts %}
215
325
  <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
216
- <script>
217
- document.addEventListener('DOMContentLoaded', function() {
218
- const primaryColor = getComputedStyle(document.documentElement).getPropertyValue('--primary').trim() || '#5d4037';
219
- const secondaryColor = getComputedStyle(document.documentElement).getPropertyValue('--secondary').trim() || '#ff7a18';
220
-
221
- // Recipe Chart
222
- new Chart(document.getElementById('recipeChart'), {
223
- type: 'line',
224
- data: {
225
- labels: {{ recipe_labels|safe }},
226
- datasets: [{
227
- label: '{% trans "Recipes Created" %}',
228
- data: {{ recipe_counts|safe }},
229
- borderColor: primaryColor,
230
- backgroundColor: primaryColor + '33',
231
- fill: true,
232
- tension: 0.4
233
- }]
234
- },
235
- options: {
236
- responsive: true,
237
- plugins: { legend: { display: false } },
238
- scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } } }
239
- }
240
- });
241
-
242
- // Rating Chart
243
- new Chart(document.getElementById('ratingChart'), {
244
- type: 'bar',
245
- data: {
246
- labels: {{ rating_labels|safe }},
247
- datasets: [{
248
- label: '{% trans "Avg Rating" %}',
249
- data: {{ rating_avgs|safe }},
250
- backgroundColor: secondaryColor,
251
- borderRadius: 4
252
- }]
253
- },
254
- options: {
255
- responsive: true,
256
- plugins: { legend: { display: false } },
257
- scales: { y: { min: 0, max: 10 } }
258
- }
259
- });
260
- });
261
- </script>
262
326
  {% endblock %}
@@ -0,0 +1,30 @@
1
+ {% extends "admin/admin_base.html" %}
2
+ {% load i18n %}
3
+
4
+ {% block admin_title %}{% trans "Orders" %}{% endblock %}
5
+
6
+ {% block content %}
7
+ <div class="padding">
8
+ <div class="row align-center">
9
+ <h5 class="max">{% trans "Orders" %}</h5>
10
+ </div>
11
+
12
+ <div class="space"></div>
13
+
14
+ <table class="border">
15
+ <thead>
16
+ <tr>
17
+ <th>ID</th>
18
+ <th>{% trans "User" %}</th>
19
+ <th>{% trans "Recipe" %}</th>
20
+ <th>{% trans "Price" %}</th>
21
+ <th>{% trans "Status" %}</th>
22
+ <th>{% trans "Date" %}</th>
23
+ </tr>
24
+ </thead>
25
+ <tbody hx-get="{% url 'admin_order_list' %}" hx-trigger="every 5s">
26
+ {% include "admin/partials/order_rows.html" %}
27
+ </tbody>
28
+ </table>
29
+ </div>
30
+ {% endblock %}
@@ -0,0 +1,90 @@
1
+ {% load i18n %}
2
+ <div class="grid" hx-get="." hx-trigger="every 30s" hx-swap="outerHTML">
3
+ <div class="s12 m4">
4
+ <article class="round border padding">
5
+ <h6 class="bold mb-1">{% trans "Recipes Over Time" %}</h6>
6
+ <canvas id="recipeChart" style="width:100%; max-height:300px;"></canvas>
7
+ </article>
8
+ </div>
9
+ <div class="s12 m4">
10
+ <article class="round border padding">
11
+ <h6 class="bold mb-1">{% trans "Orders Over Time" %}</h6>
12
+ <canvas id="orderChart" style="width:100%; max-height:300px;"></canvas>
13
+ </article>
14
+ </div>
15
+ <div class="s12 m4">
16
+ <article class="round border padding">
17
+ <h6 class="bold mb-1">{% trans "Avg Rating" %}</h6>
18
+ <canvas id="ratingChart" style="width:100%; max-height:300px;"></canvas>
19
+ </article>
20
+ </div>
21
+
22
+ <script>
23
+ (function() {
24
+ const primaryColor = getComputedStyle(document.documentElement).getPropertyValue('--primary').trim() || '#5d4037';
25
+ const secondaryColor = getComputedStyle(document.documentElement).getPropertyValue('--secondary').trim() || '#ff7a18';
26
+ const tertiaryColor = getComputedStyle(document.documentElement).getPropertyValue('--tertiary').trim() || '#4caf50';
27
+
28
+ // Recipe Chart
29
+ new Chart(document.getElementById('recipeChart'), {
30
+ type: 'line',
31
+ data: {
32
+ labels: {{ recipe_labels|safe }},
33
+ datasets: [{
34
+ label: '{% trans "Recipes" %}',
35
+ data: {{ recipe_counts|safe }},
36
+ borderColor: primaryColor,
37
+ backgroundColor: primaryColor + '33',
38
+ fill: true,
39
+ tension: 0.4
40
+ }]
41
+ },
42
+ options: {
43
+ responsive: true,
44
+ plugins: { legend: { display: false } },
45
+ scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } } }
46
+ }
47
+ });
48
+
49
+ // Order Chart
50
+ new Chart(document.getElementById('orderChart'), {
51
+ type: 'line',
52
+ data: {
53
+ labels: {{ order_labels|safe }},
54
+ datasets: [{
55
+ label: '{% trans "Orders" %}',
56
+ data: {{ order_counts|safe }},
57
+ borderColor: tertiaryColor,
58
+ backgroundColor: tertiaryColor + '33',
59
+ fill: true,
60
+ tension: 0.4
61
+ }]
62
+ },
63
+ options: {
64
+ responsive: true,
65
+ plugins: { legend: { display: false } },
66
+ scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } } }
67
+ }
68
+ });
69
+
70
+ // Rating Chart
71
+ new Chart(document.getElementById('ratingChart'), {
72
+ type: 'bar',
73
+ data: {
74
+ labels: {{ rating_labels|safe }},
75
+ datasets: [{
76
+ label: '{% trans "Avg Rating" %}',
77
+ data: {{ rating_avgs|safe }},
78
+ backgroundColor: secondaryColor,
79
+ borderRadius: 4
80
+ }]
81
+ },
82
+ options: {
83
+ responsive: true,
84
+ plugins: { legend: { display: false } },
85
+ scales: { y: { min: 0, max: 10 } }
86
+ }
87
+ });
88
+ })();
89
+ </script>
90
+ </div>