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.
@@ -0,0 +1,28 @@
1
+ {% load i18n %}
2
+ {% for order in orders %}
3
+ <tr>
4
+ <td>{{ order.id }}</td>
5
+ <td>
6
+ <div class="row align-center">
7
+ {% if order.user.avatar %}
8
+ <img src="{{ order.user.avatar.url }}" class="circle tiny">
9
+ {% else %}
10
+ <i class="circle tiny">person</i>
11
+ {% endif %}
12
+ <span class="max">{{ order.user.username }}</span>
13
+ </div>
14
+ </td>
15
+ <td>{{ order.recipe.title }}</td>
16
+ <td>{{ order.total_price }} €</td>
17
+ <td>
18
+ <span class="chip {% if order.status == 'PENDING' %}surface-variant{% elif order.status == 'COMPLETED' %}primary{% else %}error{% endif %}">
19
+ {{ order.get_status_display }}
20
+ </span>
21
+ </td>
22
+ <td>{{ order.created_at|date:"d/m/Y H:i" }}</td>
23
+ </tr>
24
+ {% empty %}
25
+ <tr>
26
+ <td colspan="6" class="center-align">{% trans "No orders found." %}</td>
27
+ </tr>
28
+ {% endfor %}
@@ -12,6 +12,7 @@
12
12
  <th>{% trans "Recipe" %}</th>
13
13
  <th>{% trans "User" %}</th>
14
14
  <th class="min center-align">{% trans "Score" %}</th>
15
+ <th>{% trans "Comment" %}</th>
15
16
  <th>{% trans "Updated" %}</th>
16
17
  <th class="right-align">{% trans "Actions" %}</th>
17
18
  </tr>
@@ -27,6 +28,7 @@
27
28
  <span>{{ r.score }}</span>
28
29
  </div>
29
30
  </td>
31
+ <td>{{ r.comment|default:"-"|truncatechars:50 }}</td>
30
32
  <td>{{ r.updated_at|date:"SHORT_DATETIME_FORMAT" }}</td>
31
33
  <td class="right-align">
32
34
  <a href="{% url 'admin_rating_delete' r.pk %}" class="button circle transparent" title="{% trans 'Delete' %}"><i>delete</i></a>
@@ -60,6 +60,32 @@
60
60
  <label>{{ form.uploaded_by.label }}</label>
61
61
  {% if form.uploaded_by.errors %}<span class="error">{{ form.uploaded_by.errors|striptags }}</span>{% endif %}
62
62
  </div>
63
+
64
+ <div class="field label border round mt-1">
65
+ {{ form.price }}
66
+ <label>{{ form.price.label }}</label>
67
+ {% if form.price.errors %}<span class="error">{{ form.price.errors|striptags }}</span>{% endif %}
68
+ </div>
69
+
70
+ <div class="field label border round mt-1">
71
+ {{ form.max_daily_orders }}
72
+ <label>{{ form.max_daily_orders.label }}</label>
73
+ {% if form.max_daily_orders.errors %}<span class="error">{{ form.max_daily_orders.errors|striptags }}</span>{% endif %}
74
+ </div>
75
+
76
+ <div class="field middle-align mt-1">
77
+ <label class="checkbox">
78
+ {{ form.is_highlighted }}
79
+ <span>{{ form.is_highlighted.label }}</span>
80
+ </label>
81
+ </div>
82
+
83
+ <div class="field middle-align mt-1">
84
+ <label class="checkbox">
85
+ {{ form.is_approved }}
86
+ <span>{{ form.is_approved.label }}</span>
87
+ </label>
88
+ </div>
63
89
  </article>
64
90
 
65
91
  <!-- Description -->
@@ -12,21 +12,48 @@
12
12
  </a>
13
13
  </div>
14
14
 
15
- <table class="border striped no-space">
16
- <thead>
17
- <tr>
18
- <th class="min">{% trans "ID" %}</th>
19
- <th class="min">{% trans "Image" %}</th>
20
- <th class="max">{% trans "Title" %}</th>
21
- <th class="min">{% trans "Rating" %}</th>
22
- <th class="m l">{% trans "Tags" %}</th>
23
- <th>{% trans "Uploader" %}</th>
24
- <th>{% trans "Created" %}</th>
25
- <th class="right-align">{% trans "Actions" %}</th>
26
- </tr>
27
- </thead>
28
- <tbody>
29
- {% for recipe in recipes %}
15
+ <table class="border striped no-space">
16
+ <thead>
17
+ <tr>
18
+ <th class="min">{% trans "ID" %}</th>
19
+ <th class="min">{% trans "Image" %}</th>
20
+ <th class="max">
21
+ <a href="?sort={% if current_sort == 'title' %}-title{% else %}title{% endif %}" class="row align-center">
22
+ {% trans "Title" %}
23
+ {% if current_sort == 'title' %}<i>arrow_upward</i>{% elif current_sort == '-title' %}<i>arrow_downward</i>{% endif %}
24
+ </a>
25
+ </th>
26
+ <th class="min">
27
+ <a href="?sort={% if current_sort == 'price' %}-price{% else %}price{% endif %}" class="row align-center">
28
+ {% trans "Price" %}
29
+ {% if current_sort == 'price' %}<i>arrow_upward</i>{% elif current_sort == '-price' %}<i>arrow_downward</i>{% endif %}
30
+ </a>
31
+ </th>
32
+ <th class="min">
33
+ <a href="?sort={% if current_sort == 'orders' %}-orders{% else %}orders{% endif %}" class="row align-center">
34
+ {% trans "Orders" %}
35
+ {% if current_sort == 'orders' %}<i>arrow_upward</i>{% elif current_sort == '-orders' %}<i>arrow_downward</i>{% endif %}
36
+ </a>
37
+ </th>
38
+ <th class="min">{% trans "Rating" %}</th>
39
+ <th class="min">{% trans "Approved" %}</th>
40
+ <th class="m l">{% trans "Tags" %}</th>
41
+ <th>
42
+ <a href="?sort={% if current_sort == 'uploader' %}-uploader{% else %}uploader{% endif %}" class="row align-center">
43
+ {% trans "Uploader" %}
44
+ {% if current_sort == 'uploader' %}<i>arrow_upward</i>{% elif current_sort == '-uploader' %}<i>arrow_downward</i>{% endif %}
45
+ </a>
46
+ </th>
47
+ <th>
48
+ <a href="?sort={% if current_sort == 'created_at' %}-created_at{% else %}created_at{% endif %}" class="row align-center">
49
+ {% trans "Created" %}
50
+ {% if current_sort == 'created_at' %}<i>arrow_upward</i>{% elif current_sort == '-created_at' %}<i>arrow_downward</i>{% endif %}
51
+ </a>
52
+ </th>
53
+ <th class="right-align">{% trans "Actions" %}</th>
54
+ </tr>
55
+ </thead>
56
+ <tbody> {% for recipe in recipes %}
30
57
  <tr class="pointer" onclick="location.href='{% url 'admin_recipe_edit' recipe.pk %}'">
31
58
  <td class="min">{{ recipe.id }}</td>
32
59
  <td class="min">
@@ -39,14 +66,37 @@
39
66
  {% endif %}
40
67
  </td>
41
68
  <td class="max">
69
+ {% if recipe.is_highlighted %}
70
+ <i class="tiny amber-text">star</i>
71
+ {% endif %}
42
72
  <b>{{ recipe.title }}</b>
43
73
  </td>
74
+ <td class="min no-wrap">
75
+ {% if recipe.price %}
76
+ {{ recipe.price }} €
77
+ {% else %}
78
+ -
79
+ {% endif %}
80
+ </td>
81
+ <td class="min no-wrap">
82
+ {{ recipe.daily_orders_count }} / {{ recipe.max_daily_orders|default:"∞" }}
83
+ </td>
44
84
  <td class="min center-align">
45
85
  <div class="row align-center">
46
86
  <i class="primary-text">star</i>
47
87
  <span>{{ recipe.avg_rating|default:0|floatformat:1 }}</span>
48
88
  </div>
49
89
  </td>
90
+ <td class="min center-align">
91
+ {% if recipe.is_approved %}
92
+ <i class="primary-text">check_circle</i>
93
+ {% else %}
94
+ <a href="{% url 'admin_recipe_approve' recipe.pk %}" class="button tiny primary round" onclick="event.stopPropagation();">
95
+ <i>check</i>
96
+ <span>{% trans "Approve" %}</span>
97
+ </a>
98
+ {% endif %}
99
+ </td>
50
100
  <td class="m l">
51
101
  {% for tag in recipe.tags.all %}
52
102
  <span class="chip tiny">{{ tag.name }}</span>
@@ -56,6 +56,18 @@
56
56
  </svg>
57
57
  </div>
58
58
  {% block navbar %}{% endblock %}
59
+
60
+ {% if messages %}
61
+ <div class="padding no-margin">
62
+ {% for message in messages %}
63
+ <div class="snackbar active {% if message.tags == 'error' %}error{% else %}primary{% endif %}">
64
+ <i>{% if message.tags == 'error' %}error{% else %}info{% endif %}</i>
65
+ <span>{{ message }}</span>
66
+ </div>
67
+ {% endfor %}
68
+ </div>
69
+ {% endif %}
70
+
59
71
  <main class="container" role="main">{% block content %}{% endblock %}</main>
60
72
  {% block footer %}{% endblock %}
61
73
  {% block extra_scripts %}{% endblock %}
@@ -36,5 +36,14 @@
36
36
  <div class="row align-center">
37
37
  <i class="primary-text">euro_symbol</i>
38
38
  <h5 class="bold ml-1">{{ recipe.price }}</h5>
39
+ {% if user.is_authenticated %}
40
+ <form action="{% url 'order_recipe' recipe.pk %}" method="post" class="ml-2">
41
+ {% csrf_token %}
42
+ <button type="submit" class="button primary round">
43
+ <i>shopping_cart</i>
44
+ <span>{% trans "Order Now" %}</span>
45
+ </button>
46
+ </form>
47
+ {% endif %}
39
48
  </div>
40
49
  {% endif %}
@@ -15,6 +15,10 @@
15
15
  <i class="extra padding">favorite</i>
16
16
  <span class="large-text">{% trans "Favorites" %}</span>
17
17
  </a>
18
+ <a href="{% url 'submit_recipe' %}" class="padding {% if request.resolver_match.url_name == 'submit_recipe' %}active{% endif %}">
19
+ <i class="extra padding">add_circle</i>
20
+ <span class="large-text">{% trans "Submit Recipe" %}</span>
21
+ </a>
18
22
  {% endif %}
19
23
  <a href="/api/docs" class="padding">
20
24
  <i class="extra padding">api</i>
@@ -1,4 +1,4 @@
1
- {% extends "base.html" %}
1
+ {% extends "base_beer.html" %}
2
2
  {% load static i18n %}
3
3
  {% block title %}{% trans "Your Profile" %}{% endblock %}
4
4
 
@@ -68,9 +68,23 @@
68
68
  </div>
69
69
  </div>
70
70
 
71
+ <div class="s12 m6">
72
+ <div class="field label border round">
73
+ {{ form.price }}
74
+ <label>{% trans "Price" %}</label>
75
+ </div>
76
+ </div>
77
+
78
+ <div class="s12 m6">
79
+ <div class="field label border round">
80
+ {{ form.servings }}
81
+ <label>{% trans "Servings" %}</label>
82
+ </div>
83
+ </div>
84
+
71
85
  <div class="s12">
72
86
  <div class="field label border round">
73
- {{ form.tags }}
87
+ {{ form.tags_string }}
74
88
  <label>{% trans "Tags (comma separated)" %}</label>
75
89
  </div>
76
90
  </div>
sandwitches/urls.py CHANGED
@@ -34,6 +34,7 @@ urlpatterns = [
34
34
  path("login/", views.CustomLoginView.as_view(), name="login"),
35
35
  path("logout/", LogoutView.as_view(next_page="index"), name="logout"),
36
36
  path("profile/", views.user_profile, name="user_profile"),
37
+ path("submit-recipe/", views.submit_recipe, name="submit_recipe"),
37
38
  path("admin/", admin.site.urls),
38
39
  path("api/", api.urls),
39
40
  path("media/<path:file_path>", views.media, name="media"),
@@ -49,6 +50,7 @@ urlpatterns += i18n_patterns(
49
50
  path("recipes/<slug:slug>/", views.recipe_detail, name="recipe_detail"),
50
51
  path("setup/", views.setup, name="setup"),
51
52
  path("recipes/<int:pk>/rate/", views.recipe_rate, name="recipe_rate"),
53
+ path("recipes/<int:pk>/order/", views.order_recipe, name="order_recipe"),
52
54
  path("recipes/<int:pk>/favorite/", views.toggle_favorite, name="toggle_favorite"),
53
55
  path("dashboard/", views.admin_dashboard, name="admin_dashboard"),
54
56
  path("dashboard/recipes/", views.admin_recipe_list, name="admin_recipe_list"),
@@ -63,6 +65,11 @@ urlpatterns += i18n_patterns(
63
65
  views.admin_recipe_delete,
64
66
  name="admin_recipe_delete",
65
67
  ),
68
+ path(
69
+ "dashboard/recipes/<int:pk>/approve/",
70
+ views.admin_recipe_approve,
71
+ name="admin_recipe_approve",
72
+ ),
66
73
  path(
67
74
  "dashboard/recipes/<int:pk>/rotate/",
68
75
  views.admin_recipe_rotate,
@@ -99,6 +106,7 @@ urlpatterns += i18n_patterns(
99
106
  views.admin_rating_delete,
100
107
  name="admin_rating_delete",
101
108
  ),
109
+ path("dashboard/orders/", views.admin_order_list, name="admin_order_list"),
102
110
  prefix_default_language=True,
103
111
  )
104
112
 
sandwitches/views.py CHANGED
@@ -1,3 +1,5 @@
1
+ import logging
2
+ from django.core.exceptions import ValidationError
1
3
  from django.shortcuts import render, get_object_or_404, redirect
2
4
  from django.urls import reverse
3
5
  from django.contrib import messages
@@ -6,7 +8,7 @@ from django.contrib.auth import get_user_model
6
8
  from django.contrib.auth.decorators import login_required
7
9
  from django.contrib.admin.views.decorators import staff_member_required
8
10
  from django.utils.translation import gettext as _
9
- from .models import Recipe, Rating, Tag
11
+ from .models import Recipe, Rating, Tag, Order
10
12
  from .forms import (
11
13
  RecipeForm,
12
14
  AdminSetupForm,
@@ -15,10 +17,11 @@ from .forms import (
15
17
  UserEditForm,
16
18
  TagForm,
17
19
  UserProfileForm,
20
+ UserRecipeSubmissionForm,
18
21
  )
19
- from django.http import HttpResponseBadRequest
22
+ from django.http import HttpResponseBadRequest, Http404
20
23
  from django.conf import settings
21
- from django.http import FileResponse, Http404
24
+ from django.http import FileResponse
22
25
  from pathlib import Path
23
26
  import mimetypes
24
27
  from PIL import Image
@@ -32,6 +35,30 @@ from sandwitches import __version__ as sandwitches_version
32
35
  User = get_user_model()
33
36
 
34
37
 
38
+ @login_required
39
+ def submit_recipe(request):
40
+ if request.method == "POST":
41
+ form = UserRecipeSubmissionForm(request.POST, request.FILES)
42
+ if form.is_valid():
43
+ recipe = form.save(commit=False)
44
+ recipe.uploaded_by = request.user
45
+ recipe.is_approved = False # Explicitly set to False just in case
46
+ recipe.save()
47
+ form.save_m2m()
48
+ messages.success(
49
+ request,
50
+ _("Your recipe has been submitted and is awaiting admin approval."),
51
+ )
52
+ return redirect("user_profile")
53
+ else:
54
+ form = UserRecipeSubmissionForm()
55
+ return render(
56
+ request,
57
+ "recipe_form.html",
58
+ {"form": form, "title": _("Submit Recipe"), "version": sandwitches_version},
59
+ )
60
+
61
+
35
62
  class CustomLoginView(LoginView):
36
63
  template_name = "login.html"
37
64
  redirect_authenticated_user = True
@@ -88,6 +115,15 @@ def admin_dashboard(request):
88
115
  .order_by("date")
89
116
  )
90
117
 
118
+ # Orders over time
119
+ order_data = (
120
+ Order.objects.filter(created_at__date__range=(start_date, end_date)) # ty:ignore[unresolved-attribute]
121
+ .annotate(date=TruncDate("created_at"))
122
+ .values("date")
123
+ .annotate(count=Count("id"))
124
+ .order_by("date")
125
+ )
126
+
91
127
  # Prepare labels and data for JS
92
128
  recipe_labels = [d["date"].strftime("%d/%m/%Y") for d in recipe_data]
93
129
  recipe_counts = [d["count"] for d in recipe_data]
@@ -95,36 +131,71 @@ def admin_dashboard(request):
95
131
  rating_labels = [d["date"].strftime("%d/%m/%Y") for d in rating_data]
96
132
  rating_avgs = [float(d["avg"]) for d in rating_data]
97
133
 
134
+ order_labels = [d["date"].strftime("%d/%m/%Y") for d in order_data]
135
+ order_counts = [d["count"] for d in order_data]
136
+
137
+ pending_recipes = Recipe.objects.filter(is_approved=False).order_by("-created_at") # ty:ignore[unresolved-attribute]
138
+
139
+ context = {
140
+ "recipe_count": recipe_count,
141
+ "user_count": user_count,
142
+ "tag_count": tag_count,
143
+ "recent_recipes": recent_recipes,
144
+ "pending_recipes": pending_recipes,
145
+ "recipe_labels": recipe_labels,
146
+ "recipe_counts": recipe_counts,
147
+ "rating_labels": rating_labels,
148
+ "rating_avgs": rating_avgs,
149
+ "order_labels": order_labels,
150
+ "order_counts": order_counts,
151
+ "start_date": start_date.strftime("%Y-%m-%d"),
152
+ "end_date": end_date.strftime("%Y-%m-%d"),
153
+ "version": sandwitches_version,
154
+ }
155
+
156
+ if request.headers.get("HX-Request"):
157
+ return render(request, "admin/partials/dashboard_charts.html", context)
158
+
98
159
  return render(
99
160
  request,
100
161
  "admin/dashboard.html",
101
- {
102
- "recipe_count": recipe_count,
103
- "user_count": user_count,
104
- "tag_count": tag_count,
105
- "recent_recipes": recent_recipes,
106
- "recipe_labels": recipe_labels,
107
- "recipe_counts": recipe_counts,
108
- "rating_labels": rating_labels,
109
- "rating_avgs": rating_avgs,
110
- "start_date": start_date.strftime("%Y-%m-%d"),
111
- "end_date": end_date.strftime("%Y-%m-%d"),
112
- "version": sandwitches_version,
113
- },
162
+ context,
114
163
  )
115
164
 
116
165
 
117
166
  @staff_member_required
118
167
  def admin_recipe_list(request):
168
+ sort_param = request.GET.get("sort", "-created_at")
169
+ allowed_sorts = {
170
+ "title": "title",
171
+ "-title": "-title",
172
+ "created_at": "created_at",
173
+ "-created_at": "-created_at",
174
+ "uploader": "uploaded_by__username",
175
+ "-uploader": "-uploaded_by__username",
176
+ "price": "price",
177
+ "-price": "-price",
178
+ "orders": "daily_orders_count",
179
+ "-orders": "-daily_orders_count",
180
+ "rating": "avg_rating",
181
+ "-rating": "-avg_rating",
182
+ }
183
+
184
+ order_by = allowed_sorts.get(sort_param, "-created_at")
185
+
119
186
  recipes = (
120
187
  Recipe.objects.annotate(avg_rating=Avg("ratings__score")) # ty:ignore[unresolved-attribute]
121
188
  .prefetch_related("tags")
122
- .all()
189
+ .order_by(order_by)
123
190
  )
124
191
  return render(
125
192
  request,
126
193
  "admin/recipe_list.html",
127
- {"recipes": recipes, "version": sandwitches_version},
194
+ {
195
+ "recipes": recipes,
196
+ "version": sandwitches_version,
197
+ "current_sort": sort_param,
198
+ },
128
199
  )
129
200
 
130
201
 
@@ -171,6 +242,17 @@ def admin_recipe_edit(request, pk):
171
242
  )
172
243
 
173
244
 
245
+ @staff_member_required
246
+ def admin_recipe_approve(request, pk):
247
+ recipe = get_object_or_404(Recipe, pk=pk)
248
+ recipe.is_approved = True
249
+ recipe.save()
250
+ messages.success(
251
+ request, _("Recipe '%(title)s' approved.") % {"title": recipe.title}
252
+ )
253
+ return redirect("admin_recipe_list")
254
+
255
+
174
256
  @staff_member_required
175
257
  def admin_recipe_delete(request, pk):
176
258
  recipe = get_object_or_404(Recipe, pk=pk)
@@ -366,8 +448,38 @@ def admin_rating_delete(request, pk):
366
448
  )
367
449
 
368
450
 
451
+ @staff_member_required
452
+ def admin_order_list(request):
453
+ orders = (
454
+ Order.objects.select_related("user", "recipe") # ty:ignore[unresolved-attribute]
455
+ .all()
456
+ .order_by("-created_at")
457
+ )
458
+
459
+ if request.headers.get("HX-Request"):
460
+ return render(
461
+ request,
462
+ "admin/partials/order_rows.html",
463
+ {"orders": orders, "version": sandwitches_version},
464
+ )
465
+
466
+ return render(
467
+ request,
468
+ "admin/order_list.html",
469
+ {"orders": orders, "version": sandwitches_version},
470
+ )
471
+
472
+
369
473
  def recipe_detail(request, slug):
370
474
  recipe = get_object_or_404(Recipe, slug=slug)
475
+
476
+ if not recipe.is_approved:
477
+ if not (
478
+ request.user.is_authenticated
479
+ and (request.user.is_staff or recipe.uploaded_by == request.user)
480
+ ):
481
+ raise Http404("Recipe not found or pending approval.")
482
+
371
483
  avg = recipe.average_rating()
372
484
  count = recipe.rating_count()
373
485
  user_rating = None
@@ -401,6 +513,28 @@ def recipe_detail(request, slug):
401
513
  )
402
514
 
403
515
 
516
+ @login_required
517
+ def order_recipe(request, pk):
518
+ """
519
+ Create an order for the given recipe by the logged-in user.
520
+ """
521
+ recipe = get_object_or_404(Recipe, pk=pk)
522
+ if request.method != "POST":
523
+ return redirect("recipe_detail", slug=recipe.slug)
524
+
525
+ try:
526
+ order = Order.objects.create(user=request.user, recipe=recipe) # ty:ignore[unresolved-attribute]
527
+ logging.debug(f"Created {order}")
528
+ messages.success(
529
+ request,
530
+ _("Your order for %(title)s has been submitted!") % {"title": recipe.title},
531
+ )
532
+ except (ValidationError, ValueError) as e:
533
+ messages.error(request, str(e))
534
+
535
+ return redirect("recipe_detail", slug=recipe.slug)
536
+
537
+
404
538
  @login_required
405
539
  def recipe_rate(request, pk):
406
540
  """
@@ -510,9 +644,16 @@ def favorites(request):
510
644
  def index(request):
511
645
  if not User.objects.filter(is_superuser=True).exists():
512
646
  return redirect("setup")
513
- recipes = (
514
- Recipe.objects.all().prefetch_related("favorited_by") # ty:ignore[unresolved-attribute]
515
- ) # Start with all, order later
647
+
648
+ recipes = Recipe.objects.all().prefetch_related("favorited_by") # ty:ignore[unresolved-attribute]
649
+
650
+ if not (request.user.is_authenticated and request.user.is_staff):
651
+ if request.user.is_authenticated:
652
+ # Show approved recipes OR recipes uploaded by the current user
653
+ recipes = recipes.filter(Q(is_approved=True) | Q(uploaded_by=request.user))
654
+ else:
655
+ # Show only approved recipes for anonymous users
656
+ recipes = recipes.filter(is_approved=True)
516
657
 
517
658
  # Filtering
518
659
  q = request.GET.get("q")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: sandwitches
3
- Version: 2.3.0
3
+ Version: 2.3.1
4
4
  Summary: Add your description here
5
5
  Author: Martyn van Dijke
6
6
  Author-email: Martyn van Dijke <martijnvdijke600@gmail.com>