sandwitches 1.4.2__py3-none-any.whl → 2.0.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.
- sandwitches/__init__.py +6 -0
- sandwitches/admin.py +21 -2
- sandwitches/api.py +112 -6
- sandwitches/feeds.py +23 -0
- sandwitches/forms.py +110 -7
- sandwitches/locale/nl/LC_MESSAGES/django.mo +0 -0
- sandwitches/locale/nl/LC_MESSAGES/django.po +784 -134
- sandwitches/migrations/0001_initial.py +255 -2
- sandwitches/migrations/0002_historicalrecipe_servings_recipe_servings.py +27 -0
- sandwitches/migrations/0003_setting.py +35 -0
- sandwitches/migrations/0004_alter_setting_ai_api_key_and_more.py +37 -0
- sandwitches/migrations/0005_rating_comment.py +17 -0
- sandwitches/models.py +48 -4
- sandwitches/settings.py +14 -5
- sandwitches/storage.py +44 -12
- sandwitches/templates/admin/admin_base.html +118 -0
- sandwitches/templates/admin/confirm_delete.html +23 -0
- sandwitches/templates/admin/dashboard.html +262 -0
- sandwitches/templates/admin/rating_list.html +38 -0
- sandwitches/templates/admin/recipe_form.html +184 -0
- sandwitches/templates/admin/recipe_list.html +64 -0
- sandwitches/templates/admin/tag_form.html +30 -0
- sandwitches/templates/admin/tag_list.html +37 -0
- sandwitches/templates/admin/task_detail.html +91 -0
- sandwitches/templates/admin/task_list.html +41 -0
- sandwitches/templates/admin/user_form.html +37 -0
- sandwitches/templates/admin/user_list.html +60 -0
- sandwitches/templates/base.html +80 -1
- sandwitches/templates/base_beer.html +57 -0
- sandwitches/templates/components/favorites_search_form.html +85 -0
- sandwitches/templates/components/footer.html +14 -0
- sandwitches/templates/components/ingredients_scripts.html +50 -0
- sandwitches/templates/components/ingredients_section.html +11 -0
- sandwitches/templates/components/instructions_section.html +9 -0
- sandwitches/templates/components/language_dialog.html +26 -0
- sandwitches/templates/components/navbar.html +27 -0
- sandwitches/templates/components/rating_section.html +66 -0
- sandwitches/templates/components/recipe_header.html +32 -0
- sandwitches/templates/components/search_form.html +106 -0
- sandwitches/templates/components/search_scripts.html +98 -0
- sandwitches/templates/components/side_menu.html +35 -0
- sandwitches/templates/components/user_menu.html +10 -0
- sandwitches/templates/detail.html +167 -110
- sandwitches/templates/favorites.html +42 -0
- sandwitches/templates/index.html +28 -61
- sandwitches/templates/partials/recipe_list.html +87 -0
- sandwitches/templates/recipe_form.html +119 -0
- sandwitches/templates/setup.html +1 -1
- sandwitches/templates/signup.html +114 -31
- sandwitches/templatetags/custom_filters.py +15 -0
- sandwitches/urls.py +56 -0
- sandwitches/utils.py +222 -0
- sandwitches/views.py +503 -14
- sandwitches-2.0.0.dist-info/METADATA +104 -0
- sandwitches-2.0.0.dist-info/RECORD +62 -0
- sandwitches/migrations/0002_historicalrecipe.py +0 -61
- sandwitches/migrations/0003_rating.py +0 -57
- sandwitches/migrations/0004_add_uploaded_by.py +0 -25
- sandwitches/migrations/0005_historicalrecipe_uploaded_by.py +0 -27
- sandwitches/migrations/0006_profile.py +0 -48
- sandwitches/migrations/0007_alter_rating_score.py +0 -23
- sandwitches/migrations/0008_delete_profile.py +0 -15
- sandwitches/templates/base_pico.html +0 -260
- sandwitches/templates/form.html +0 -16
- sandwitches-1.4.2.dist-info/METADATA +0 -25
- sandwitches-1.4.2.dist-info/RECORD +0 -35
- {sandwitches-1.4.2.dist-info → sandwitches-2.0.0.dist-info}/WHEEL +0 -0
sandwitches/views.py
CHANGED
|
@@ -4,28 +4,358 @@ from django.contrib import messages
|
|
|
4
4
|
from django.contrib.auth import login
|
|
5
5
|
from django.contrib.auth import get_user_model
|
|
6
6
|
from django.contrib.auth.decorators import login_required
|
|
7
|
+
from django.contrib.admin.views.decorators import staff_member_required
|
|
7
8
|
from django.utils.translation import gettext as _
|
|
8
|
-
from .models import Recipe, Rating
|
|
9
|
-
from .forms import
|
|
9
|
+
from .models import Recipe, Rating, Tag
|
|
10
|
+
from .forms import (
|
|
11
|
+
RecipeForm,
|
|
12
|
+
AdminSetupForm,
|
|
13
|
+
UserSignupForm,
|
|
14
|
+
RatingForm,
|
|
15
|
+
UserEditForm,
|
|
16
|
+
TagForm,
|
|
17
|
+
)
|
|
10
18
|
from django.http import HttpResponseBadRequest
|
|
11
19
|
from django.conf import settings
|
|
12
20
|
from django.http import FileResponse, Http404
|
|
13
21
|
from pathlib import Path
|
|
14
22
|
import mimetypes
|
|
23
|
+
from PIL import Image
|
|
24
|
+
from django.db.models import Q, Avg
|
|
25
|
+
from django_tasks.backends.database.models import DBTaskResult
|
|
26
|
+
|
|
27
|
+
from sandwitches import __version__ as sandwitches_version
|
|
15
28
|
|
|
16
29
|
User = get_user_model()
|
|
17
30
|
|
|
18
31
|
|
|
19
|
-
|
|
32
|
+
@staff_member_required
|
|
33
|
+
def admin_dashboard(request):
|
|
34
|
+
recipe_count = Recipe.objects.count() # ty:ignore[unresolved-attribute]
|
|
35
|
+
user_count = User.objects.count()
|
|
36
|
+
tag_count = Tag.objects.count() # ty:ignore[unresolved-attribute]
|
|
37
|
+
recent_recipes = Recipe.objects.order_by("-created_at")[:5] # ty:ignore[unresolved-attribute]
|
|
38
|
+
|
|
39
|
+
# Data for charts
|
|
40
|
+
from datetime import timedelta
|
|
41
|
+
from django.utils import timezone
|
|
42
|
+
from django.db.models.functions import TruncDate
|
|
43
|
+
from django.db.models import Count
|
|
44
|
+
|
|
45
|
+
# Get date range from request or default to last 30 days
|
|
46
|
+
end_date_str = request.GET.get("end_date")
|
|
47
|
+
start_date_str = request.GET.get("start_date")
|
|
48
|
+
|
|
49
|
+
today = timezone.now().date()
|
|
50
|
+
try:
|
|
51
|
+
end_date = (
|
|
52
|
+
timezone.datetime.strptime(end_date_str, "%Y-%m-%d").date()
|
|
53
|
+
if end_date_str
|
|
54
|
+
else today
|
|
55
|
+
)
|
|
56
|
+
start_date = (
|
|
57
|
+
timezone.datetime.strptime(start_date_str, "%Y-%m-%d").date()
|
|
58
|
+
if start_date_str
|
|
59
|
+
else end_date - timedelta(days=30)
|
|
60
|
+
)
|
|
61
|
+
except (ValueError, TypeError):
|
|
62
|
+
end_date = today
|
|
63
|
+
start_date = today - timedelta(days=30)
|
|
64
|
+
|
|
65
|
+
# Recipes over time
|
|
66
|
+
recipe_data = (
|
|
67
|
+
Recipe.objects.filter(created_at__date__range=(start_date, end_date)) # ty:ignore[unresolved-attribute]
|
|
68
|
+
.annotate(date=TruncDate("created_at"))
|
|
69
|
+
.values("date")
|
|
70
|
+
.annotate(count=Count("id"))
|
|
71
|
+
.order_by("date")
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Ratings over time
|
|
75
|
+
rating_data = (
|
|
76
|
+
Rating.objects.filter(created_at__date__range=(start_date, end_date)) # ty:ignore[unresolved-attribute]
|
|
77
|
+
.annotate(date=TruncDate("created_at"))
|
|
78
|
+
.values("date")
|
|
79
|
+
.annotate(avg=Avg("score"))
|
|
80
|
+
.order_by("date")
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Prepare labels and data for JS
|
|
84
|
+
recipe_labels = [d["date"].strftime("%d/%m/%Y") for d in recipe_data]
|
|
85
|
+
recipe_counts = [d["count"] for d in recipe_data]
|
|
86
|
+
|
|
87
|
+
rating_labels = [d["date"].strftime("%d/%m/%Y") for d in rating_data]
|
|
88
|
+
rating_avgs = [float(d["avg"]) for d in rating_data]
|
|
89
|
+
|
|
90
|
+
return render(
|
|
91
|
+
request,
|
|
92
|
+
"admin/dashboard.html",
|
|
93
|
+
{
|
|
94
|
+
"recipe_count": recipe_count,
|
|
95
|
+
"user_count": user_count,
|
|
96
|
+
"tag_count": tag_count,
|
|
97
|
+
"recent_recipes": recent_recipes,
|
|
98
|
+
"recipe_labels": recipe_labels,
|
|
99
|
+
"recipe_counts": recipe_counts,
|
|
100
|
+
"rating_labels": rating_labels,
|
|
101
|
+
"rating_avgs": rating_avgs,
|
|
102
|
+
"start_date": start_date.strftime("%Y-%m-%d"),
|
|
103
|
+
"end_date": end_date.strftime("%Y-%m-%d"),
|
|
104
|
+
"version": sandwitches_version,
|
|
105
|
+
},
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@staff_member_required
|
|
110
|
+
def admin_recipe_list(request):
|
|
111
|
+
recipes = (
|
|
112
|
+
Recipe.objects.annotate(avg_rating=Avg("ratings__score")) # ty:ignore[unresolved-attribute]
|
|
113
|
+
.prefetch_related("tags")
|
|
114
|
+
.all()
|
|
115
|
+
)
|
|
116
|
+
return render(
|
|
117
|
+
request,
|
|
118
|
+
"admin/recipe_list.html",
|
|
119
|
+
{"recipes": recipes, "version": sandwitches_version},
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@staff_member_required
|
|
124
|
+
def admin_recipe_add(request):
|
|
125
|
+
if request.method == "POST":
|
|
126
|
+
form = RecipeForm(request.POST, request.FILES)
|
|
127
|
+
if form.is_valid():
|
|
128
|
+
recipe = form.save(commit=False)
|
|
129
|
+
recipe.uploaded_by = request.user
|
|
130
|
+
recipe.save()
|
|
131
|
+
form.save_m2m()
|
|
132
|
+
messages.success(request, _("Recipe added successfully."))
|
|
133
|
+
return redirect("admin_recipe_list")
|
|
134
|
+
else:
|
|
135
|
+
form = RecipeForm()
|
|
136
|
+
return render(
|
|
137
|
+
request,
|
|
138
|
+
"admin/recipe_form.html",
|
|
139
|
+
{"form": form, "title": _("Add Recipe"), "version": sandwitches_version},
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@staff_member_required
|
|
144
|
+
def admin_recipe_edit(request, pk):
|
|
20
145
|
recipe = get_object_or_404(Recipe, pk=pk)
|
|
21
146
|
if request.method == "POST":
|
|
22
147
|
form = RecipeForm(request.POST, request.FILES, instance=recipe)
|
|
23
148
|
if form.is_valid():
|
|
24
149
|
form.save()
|
|
25
|
-
|
|
150
|
+
messages.success(request, _("Recipe updated successfully."))
|
|
151
|
+
return redirect("admin_recipe_list")
|
|
26
152
|
else:
|
|
27
153
|
form = RecipeForm(instance=recipe)
|
|
28
|
-
return render(
|
|
154
|
+
return render(
|
|
155
|
+
request,
|
|
156
|
+
"admin/recipe_form.html",
|
|
157
|
+
{
|
|
158
|
+
"form": form,
|
|
159
|
+
"recipe": recipe,
|
|
160
|
+
"title": _("Edit Recipe"),
|
|
161
|
+
"version": sandwitches_version,
|
|
162
|
+
},
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@staff_member_required
|
|
167
|
+
def admin_recipe_delete(request, pk):
|
|
168
|
+
recipe = get_object_or_404(Recipe, pk=pk)
|
|
169
|
+
if request.method == "POST":
|
|
170
|
+
recipe.delete()
|
|
171
|
+
messages.success(request, _("Recipe deleted."))
|
|
172
|
+
return redirect("admin_recipe_list")
|
|
173
|
+
return render(
|
|
174
|
+
request,
|
|
175
|
+
"admin/confirm_delete.html",
|
|
176
|
+
{"object": recipe, "type": _("recipe"), "version": sandwitches_version},
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@staff_member_required
|
|
181
|
+
def admin_recipe_rotate(request, pk):
|
|
182
|
+
recipe = get_object_or_404(Recipe, pk=pk)
|
|
183
|
+
direction = request.GET.get("direction", "cw")
|
|
184
|
+
if not recipe.image:
|
|
185
|
+
messages.error(request, _("No image to rotate."))
|
|
186
|
+
return redirect("admin_recipe_edit", pk=pk)
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
img = Image.open(recipe.image.path)
|
|
190
|
+
if direction == "ccw":
|
|
191
|
+
# Rotate 90 degrees counter-clockwise
|
|
192
|
+
img = img.rotate(90, expand=True)
|
|
193
|
+
else:
|
|
194
|
+
# Rotate 90 degrees clockwise (default)
|
|
195
|
+
img = img.rotate(-90, expand=True)
|
|
196
|
+
img.save(recipe.image.path)
|
|
197
|
+
messages.success(request, _("Image rotated successfully."))
|
|
198
|
+
except Exception as e:
|
|
199
|
+
messages.error(request, _("Error rotating image: ") + str(e))
|
|
200
|
+
|
|
201
|
+
return redirect("admin_recipe_edit", pk=pk)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@staff_member_required
|
|
205
|
+
def admin_user_list(request):
|
|
206
|
+
users = User.objects.all()
|
|
207
|
+
return render(
|
|
208
|
+
request,
|
|
209
|
+
"admin/user_list.html",
|
|
210
|
+
{"users": users, "version": sandwitches_version},
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@staff_member_required
|
|
215
|
+
def admin_user_edit(request, pk):
|
|
216
|
+
user_obj = get_object_or_404(User, pk=pk)
|
|
217
|
+
if request.method == "POST":
|
|
218
|
+
form = UserEditForm(request.POST, request.FILES, instance=user_obj)
|
|
219
|
+
if form.is_valid():
|
|
220
|
+
form.save()
|
|
221
|
+
messages.success(request, _("User updated successfully."))
|
|
222
|
+
return redirect("admin_user_list")
|
|
223
|
+
else:
|
|
224
|
+
form = UserEditForm(instance=user_obj)
|
|
225
|
+
return render(
|
|
226
|
+
request,
|
|
227
|
+
"admin/user_form.html",
|
|
228
|
+
{"form": form, "user_obj": user_obj, "version": sandwitches_version},
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@staff_member_required
|
|
233
|
+
def admin_user_delete(request, pk):
|
|
234
|
+
user_obj = get_object_or_404(User, pk=pk)
|
|
235
|
+
if user_obj == request.user:
|
|
236
|
+
messages.error(request, _("You cannot delete yourself."))
|
|
237
|
+
return redirect("admin_user_list")
|
|
238
|
+
if request.method == "POST":
|
|
239
|
+
user_obj.delete()
|
|
240
|
+
messages.success(request, _("User deleted."))
|
|
241
|
+
return redirect("admin_user_list")
|
|
242
|
+
return render(
|
|
243
|
+
request,
|
|
244
|
+
"admin/confirm_delete.html",
|
|
245
|
+
{"object": user_obj, "type": _("user"), "version": sandwitches_version},
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@staff_member_required
|
|
250
|
+
def admin_tag_list(request):
|
|
251
|
+
tags = Tag.objects.all() # ty:ignore[unresolved-attribute]
|
|
252
|
+
return render(
|
|
253
|
+
request,
|
|
254
|
+
"admin/tag_list.html",
|
|
255
|
+
{"tags": tags, "version": sandwitches_version},
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@staff_member_required
|
|
260
|
+
def admin_tag_add(request):
|
|
261
|
+
if request.method == "POST":
|
|
262
|
+
form = TagForm(request.POST)
|
|
263
|
+
if form.is_valid():
|
|
264
|
+
form.save()
|
|
265
|
+
messages.success(request, _("Tag added successfully."))
|
|
266
|
+
return redirect("admin_tag_list")
|
|
267
|
+
else:
|
|
268
|
+
form = TagForm()
|
|
269
|
+
return render(
|
|
270
|
+
request,
|
|
271
|
+
"admin/tag_form.html",
|
|
272
|
+
{"form": form, "title": _("Add Tag"), "version": sandwitches_version},
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@staff_member_required
|
|
277
|
+
def admin_tag_edit(request, pk):
|
|
278
|
+
tag = get_object_or_404(Tag, pk=pk)
|
|
279
|
+
if request.method == "POST":
|
|
280
|
+
form = TagForm(request.POST, instance=tag)
|
|
281
|
+
if form.is_valid():
|
|
282
|
+
form.save()
|
|
283
|
+
messages.success(request, _("Tag updated successfully."))
|
|
284
|
+
return redirect("admin_tag_list")
|
|
285
|
+
else:
|
|
286
|
+
form = TagForm(instance=tag)
|
|
287
|
+
return render(
|
|
288
|
+
request,
|
|
289
|
+
"admin/tag_form.html",
|
|
290
|
+
{
|
|
291
|
+
"form": form,
|
|
292
|
+
"tag": tag,
|
|
293
|
+
"title": _("Edit Tag"),
|
|
294
|
+
"version": sandwitches_version,
|
|
295
|
+
},
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
@staff_member_required
|
|
300
|
+
def admin_tag_delete(request, pk):
|
|
301
|
+
tag = get_object_or_404(Tag, pk=pk)
|
|
302
|
+
if request.method == "POST":
|
|
303
|
+
tag.delete()
|
|
304
|
+
messages.success(request, _("Tag deleted."))
|
|
305
|
+
return redirect("admin_tag_list")
|
|
306
|
+
return render(
|
|
307
|
+
request,
|
|
308
|
+
"admin/confirm_delete.html",
|
|
309
|
+
{"object": tag, "type": _("tag"), "version": sandwitches_version},
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
@staff_member_required
|
|
314
|
+
def admin_task_list(request):
|
|
315
|
+
tasks = DBTaskResult.objects.all().order_by("-enqueued_at")[:50]
|
|
316
|
+
return render(
|
|
317
|
+
request,
|
|
318
|
+
"admin/task_list.html",
|
|
319
|
+
{"tasks": tasks, "version": sandwitches_version},
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
@staff_member_required
|
|
324
|
+
def admin_task_detail(request, pk):
|
|
325
|
+
task = get_object_or_404(DBTaskResult, pk=pk)
|
|
326
|
+
return render(
|
|
327
|
+
request,
|
|
328
|
+
"admin/task_detail.html",
|
|
329
|
+
{"task": task, "version": sandwitches_version},
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
@staff_member_required
|
|
334
|
+
def admin_rating_list(request):
|
|
335
|
+
ratings = (
|
|
336
|
+
Rating.objects.select_related("recipe", "user") # ty:ignore[unresolved-attribute]
|
|
337
|
+
.all()
|
|
338
|
+
.order_by("-updated_at")
|
|
339
|
+
)
|
|
340
|
+
return render(
|
|
341
|
+
request,
|
|
342
|
+
"admin/rating_list.html",
|
|
343
|
+
{"ratings": ratings, "version": sandwitches_version},
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
@staff_member_required
|
|
348
|
+
def admin_rating_delete(request, pk):
|
|
349
|
+
rating = get_object_or_404(Rating, pk=pk)
|
|
350
|
+
if request.method == "POST":
|
|
351
|
+
rating.delete()
|
|
352
|
+
messages.success(request, _("Rating deleted."))
|
|
353
|
+
return redirect("admin_rating_list")
|
|
354
|
+
return render(
|
|
355
|
+
request,
|
|
356
|
+
"admin/confirm_delete.html",
|
|
357
|
+
{"object": rating, "type": _("rating"), "version": sandwitches_version},
|
|
358
|
+
)
|
|
29
359
|
|
|
30
360
|
|
|
31
361
|
def recipe_detail(request, slug):
|
|
@@ -40,7 +370,11 @@ def recipe_detail(request, slug):
|
|
|
40
370
|
except Rating.DoesNotExist: # ty:ignore[unresolved-attribute]
|
|
41
371
|
user_rating = None
|
|
42
372
|
# show form prefilled when possible
|
|
43
|
-
initial =
|
|
373
|
+
initial = (
|
|
374
|
+
{"score": str(user_rating.score), "comment": user_rating.comment}
|
|
375
|
+
if user_rating
|
|
376
|
+
else None
|
|
377
|
+
)
|
|
44
378
|
rating_form = RatingForm(initial=initial)
|
|
45
379
|
return render(
|
|
46
380
|
request,
|
|
@@ -51,6 +385,10 @@ def recipe_detail(request, slug):
|
|
|
51
385
|
"rating_count": count,
|
|
52
386
|
"user_rating": user_rating,
|
|
53
387
|
"rating_form": rating_form,
|
|
388
|
+
"version": sandwitches_version,
|
|
389
|
+
"all_ratings": recipe.ratings.select_related("user").order_by(
|
|
390
|
+
"-created_at"
|
|
391
|
+
), # Add all ratings for display
|
|
54
392
|
},
|
|
55
393
|
)
|
|
56
394
|
|
|
@@ -66,9 +404,12 @@ def recipe_rate(request, pk):
|
|
|
66
404
|
|
|
67
405
|
form = RatingForm(request.POST)
|
|
68
406
|
if form.is_valid():
|
|
69
|
-
score =
|
|
407
|
+
score = form.cleaned_data["score"]
|
|
408
|
+
comment = form.cleaned_data["comment"]
|
|
70
409
|
Rating.objects.update_or_create( # ty:ignore[unresolved-attribute]
|
|
71
|
-
recipe=recipe,
|
|
410
|
+
recipe=recipe,
|
|
411
|
+
user=request.user,
|
|
412
|
+
defaults={"score": score, "comment": comment},
|
|
72
413
|
)
|
|
73
414
|
messages.success(request, _("Your rating has been saved."))
|
|
74
415
|
else:
|
|
@@ -76,11 +417,157 @@ def recipe_rate(request, pk):
|
|
|
76
417
|
return redirect("recipe_detail", slug=recipe.slug)
|
|
77
418
|
|
|
78
419
|
|
|
420
|
+
@login_required
|
|
421
|
+
def toggle_favorite(request, pk):
|
|
422
|
+
recipe = get_object_or_404(Recipe, pk=pk)
|
|
423
|
+
if recipe in request.user.favorites.all():
|
|
424
|
+
request.user.favorites.remove(recipe)
|
|
425
|
+
messages.success(request, _("Recipe removed from favorites."))
|
|
426
|
+
else:
|
|
427
|
+
request.user.favorites.add(recipe)
|
|
428
|
+
messages.success(request, _("Recipe added to favorites."))
|
|
429
|
+
|
|
430
|
+
# Redirect to the page where the request came from, or default to recipe detail
|
|
431
|
+
referer = request.META.get("HTTP_REFERER")
|
|
432
|
+
if referer:
|
|
433
|
+
return redirect(referer)
|
|
434
|
+
return redirect("recipe_detail", slug=recipe.slug)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
@login_required
|
|
438
|
+
def favorites(request):
|
|
439
|
+
recipes = request.user.favorites.all().prefetch_related("favorited_by")
|
|
440
|
+
|
|
441
|
+
# Filtering
|
|
442
|
+
q = request.GET.get("q")
|
|
443
|
+
if q:
|
|
444
|
+
recipes = recipes.filter(
|
|
445
|
+
Q(title__icontains=q) | Q(tags__name__icontains=q)
|
|
446
|
+
).distinct()
|
|
447
|
+
|
|
448
|
+
date_start = request.GET.get("date_start")
|
|
449
|
+
if date_start:
|
|
450
|
+
recipes = recipes.filter(created_at__gte=date_start)
|
|
451
|
+
|
|
452
|
+
date_end = request.GET.get("date_end")
|
|
453
|
+
if date_end:
|
|
454
|
+
recipes = recipes.filter(created_at__lte=date_end)
|
|
455
|
+
|
|
456
|
+
uploader = request.GET.get("uploader")
|
|
457
|
+
if uploader:
|
|
458
|
+
recipes = recipes.filter(uploaded_by__username=uploader)
|
|
459
|
+
|
|
460
|
+
tag = request.GET.get("tag")
|
|
461
|
+
if tag:
|
|
462
|
+
recipes = recipes.filter(tags__name=tag)
|
|
463
|
+
|
|
464
|
+
# Sorting
|
|
465
|
+
sort = request.GET.get("sort", "date_desc")
|
|
466
|
+
if sort == "date_asc":
|
|
467
|
+
recipes = recipes.order_by("created_at")
|
|
468
|
+
elif sort == "rating":
|
|
469
|
+
recipes = recipes.annotate(avg_rating=Avg("ratings__score")).order_by(
|
|
470
|
+
"-avg_rating", "-created_at"
|
|
471
|
+
)
|
|
472
|
+
elif sort == "user":
|
|
473
|
+
recipes = recipes.order_by("uploaded_by__username", "-created_at")
|
|
474
|
+
else: # date_desc or default
|
|
475
|
+
recipes = recipes.order_by("-created_at")
|
|
476
|
+
|
|
477
|
+
if request.headers.get("HX-Request"):
|
|
478
|
+
return render(
|
|
479
|
+
request,
|
|
480
|
+
"partials/recipe_list.html",
|
|
481
|
+
{"recipes": recipes, "user": request.user},
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
# Context for filters - only show options relevant to favorited recipes
|
|
485
|
+
uploaders = User.objects.filter(recipes__in=request.user.favorites.all()).distinct()
|
|
486
|
+
tags = Tag.objects.filter(recipes__in=request.user.favorites.all()).distinct() # ty:ignore[unresolved-attribute]
|
|
487
|
+
|
|
488
|
+
return render(
|
|
489
|
+
request,
|
|
490
|
+
"favorites.html",
|
|
491
|
+
{
|
|
492
|
+
"recipes": recipes,
|
|
493
|
+
"version": sandwitches_version,
|
|
494
|
+
"uploaders": uploaders,
|
|
495
|
+
"tags": tags,
|
|
496
|
+
"selected_tags": request.GET.getlist("tag"), # Add selected_tags
|
|
497
|
+
"user": request.user,
|
|
498
|
+
},
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
|
|
79
502
|
def index(request):
|
|
80
503
|
if not User.objects.filter(is_superuser=True).exists():
|
|
81
504
|
return redirect("setup")
|
|
82
|
-
recipes =
|
|
83
|
-
|
|
505
|
+
recipes = (
|
|
506
|
+
Recipe.objects.all().prefetch_related("favorited_by") # ty:ignore[unresolved-attribute]
|
|
507
|
+
) # Start with all, order later
|
|
508
|
+
|
|
509
|
+
# Filtering
|
|
510
|
+
q = request.GET.get("q")
|
|
511
|
+
if q:
|
|
512
|
+
recipes = recipes.filter(
|
|
513
|
+
Q(title__icontains=q) | Q(tags__name__icontains=q)
|
|
514
|
+
).distinct()
|
|
515
|
+
|
|
516
|
+
date_start = request.GET.get("date_start")
|
|
517
|
+
if date_start:
|
|
518
|
+
recipes = recipes.filter(created_at__gte=date_start)
|
|
519
|
+
|
|
520
|
+
date_end = request.GET.get("date_end")
|
|
521
|
+
if date_end:
|
|
522
|
+
recipes = recipes.filter(created_at__lte=date_end)
|
|
523
|
+
|
|
524
|
+
uploader = request.GET.get("uploader")
|
|
525
|
+
if uploader:
|
|
526
|
+
recipes = recipes.filter(uploaded_by__username=uploader)
|
|
527
|
+
|
|
528
|
+
tags = request.GET.getlist("tag")
|
|
529
|
+
if tags:
|
|
530
|
+
recipes = recipes.filter(tags__name__in=tags)
|
|
531
|
+
|
|
532
|
+
if request.user.is_authenticated and request.GET.get("favorites") == "on":
|
|
533
|
+
recipes = recipes.filter(pk__in=request.user.favorites.values("pk"))
|
|
534
|
+
|
|
535
|
+
# Sorting
|
|
536
|
+
sort = request.GET.get("sort", "date_desc")
|
|
537
|
+
if sort == "date_asc":
|
|
538
|
+
recipes = recipes.order_by("created_at")
|
|
539
|
+
elif sort == "rating":
|
|
540
|
+
recipes = recipes.annotate(avg_rating=Avg("ratings__score")).order_by(
|
|
541
|
+
"-avg_rating", "-created_at"
|
|
542
|
+
)
|
|
543
|
+
elif sort == "user":
|
|
544
|
+
recipes = recipes.order_by("uploaded_by__username", "-created_at")
|
|
545
|
+
else: # date_desc or default
|
|
546
|
+
recipes = recipes.order_by("-created_at")
|
|
547
|
+
|
|
548
|
+
if request.headers.get("HX-Request"):
|
|
549
|
+
return render(
|
|
550
|
+
request,
|
|
551
|
+
"partials/recipe_list.html",
|
|
552
|
+
{"recipes": recipes, "user": request.user},
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
# Context for filters
|
|
556
|
+
uploaders = User.objects.filter(recipes__isnull=False).distinct()
|
|
557
|
+
tags = Tag.objects.all() # ty:ignore[unresolved-attribute]
|
|
558
|
+
|
|
559
|
+
return render(
|
|
560
|
+
request,
|
|
561
|
+
"index.html",
|
|
562
|
+
{
|
|
563
|
+
"recipes": recipes,
|
|
564
|
+
"version": sandwitches_version,
|
|
565
|
+
"uploaders": uploaders,
|
|
566
|
+
"tags": tags,
|
|
567
|
+
"selected_tags": request.GET.getlist("tag"),
|
|
568
|
+
"user": request.user, # Pass user to template
|
|
569
|
+
},
|
|
570
|
+
)
|
|
84
571
|
|
|
85
572
|
|
|
86
573
|
def setup(request):
|
|
@@ -103,7 +590,7 @@ def setup(request):
|
|
|
103
590
|
else:
|
|
104
591
|
form = AdminSetupForm()
|
|
105
592
|
|
|
106
|
-
return render(request, "setup.html", {"form": form})
|
|
593
|
+
return render(request, "setup.html", {"form": form, "version": sandwitches_version})
|
|
107
594
|
|
|
108
595
|
|
|
109
596
|
def signup(request):
|
|
@@ -111,7 +598,7 @@ def signup(request):
|
|
|
111
598
|
User signup page: create new regular user accounts.
|
|
112
599
|
"""
|
|
113
600
|
if request.method == "POST":
|
|
114
|
-
form = UserSignupForm(request.POST)
|
|
601
|
+
form = UserSignupForm(request.POST, request.FILES)
|
|
115
602
|
if form.is_valid():
|
|
116
603
|
user = form.save()
|
|
117
604
|
# log in the newly created user
|
|
@@ -122,7 +609,9 @@ def signup(request):
|
|
|
122
609
|
else:
|
|
123
610
|
form = UserSignupForm()
|
|
124
611
|
|
|
125
|
-
return render(
|
|
612
|
+
return render(
|
|
613
|
+
request, "signup.html", {"form": form, "version": sandwitches_version}
|
|
614
|
+
)
|
|
126
615
|
|
|
127
616
|
|
|
128
617
|
def media(request, file_path=None):
|
|
@@ -140,7 +629,7 @@ def media(request, file_path=None):
|
|
|
140
629
|
if not full_path.exists() or not full_path.is_file():
|
|
141
630
|
raise Http404("File not found")
|
|
142
631
|
|
|
143
|
-
content_type,
|
|
632
|
+
content_type, encoding = mimetypes.guess_type(full_path)
|
|
144
633
|
if not content_type or not content_type.startswith("image/"):
|
|
145
634
|
return HttpResponseBadRequest("Access Denied: Only image files are allowed.")
|
|
146
635
|
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: sandwitches
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Author: Martyn van Dijke
|
|
6
|
+
Author-email: Martyn van Dijke <martijnvdijke600@gmail.com>
|
|
7
|
+
Requires-Dist: django-debug-toolbar>=6.1.0
|
|
8
|
+
Requires-Dist: django-filter>=25.2
|
|
9
|
+
Requires-Dist: django-imagekit>=6.0.0
|
|
10
|
+
Requires-Dist: django-import-export>=4.3.14
|
|
11
|
+
Requires-Dist: django-ninja>=1.5.1
|
|
12
|
+
Requires-Dist: django-simple-history>=3.10.1
|
|
13
|
+
Requires-Dist: django-tasks>=0.10.0
|
|
14
|
+
Requires-Dist: django-solo>=2.3.0
|
|
15
|
+
Requires-Dist: django>=6.0.0
|
|
16
|
+
Requires-Dist: gunicorn>=23.0.0
|
|
17
|
+
Requires-Dist: markdown>=3.10
|
|
18
|
+
Requires-Dist: pillow>=12.0.0
|
|
19
|
+
Requires-Dist: uvicorn>=0.40.0
|
|
20
|
+
Requires-Dist: whitenoise[brotli]>=6.11.0
|
|
21
|
+
Requires-Python: >=3.12
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
<p align="center">
|
|
25
|
+
<img src="src/static/icons/banner.svg" alt="Sandwitches Banner" width="600px">
|
|
26
|
+
</p>
|
|
27
|
+
|
|
28
|
+
<h1 align="center">🥪 Sandwitches</h1>
|
|
29
|
+
|
|
30
|
+
<p align="center">
|
|
31
|
+
<strong>Sandwiches so good, they haunt you!</strong>
|
|
32
|
+
</p>
|
|
33
|
+
|
|
34
|
+
<p align="center">
|
|
35
|
+
<a href="https://github.com/martynvdijke/sandwitches/actions/workflows/ci.yaml">
|
|
36
|
+
<img src="https://github.com/martynvdijke/sandwitches/actions/workflows/ci.yaml/badge.svg" alt="CI Status">
|
|
37
|
+
</a>
|
|
38
|
+
<a href="https://github.com/martynvdijke/sandwitches/blob/main/LICENSE">
|
|
39
|
+
<img src="https://img.shields.io/github/license/martynvdijke/sandwitches" alt="License">
|
|
40
|
+
</a>
|
|
41
|
+
<img src="https://img.shields.io/badge/python-3.12+-blue.svg" alt="Python Version">
|
|
42
|
+
<img src="https://img.shields.io/badge/django-6.0-green.svg" alt="Django Version">
|
|
43
|
+
<a href="https://github.com/astral-sh/ruff">
|
|
44
|
+
<img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json" alt="Ruff">
|
|
45
|
+
</a>
|
|
46
|
+
</p>
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## ✨ Overview
|
|
51
|
+
|
|
52
|
+
Sandwitches is a modern, recipe management platform built with **Django 6.0**.
|
|
53
|
+
It is made as a hobby project for my girlfriend, who likes to make what I call "fancy" sandwiches (sandwiches that go beyond the Dutch normals), lucky to be me :).
|
|
54
|
+
See wanted to have a way to advertise and share those sandwtiches with the family and so I started coding making it happen, in the hopes of getting more fancy sandwiches.
|
|
55
|
+
|
|
56
|
+
## 📥 Getting Started
|
|
57
|
+
|
|
58
|
+
### Prerequisites
|
|
59
|
+
|
|
60
|
+
* Python 3.12+
|
|
61
|
+
* [uv](https://github.com/astral-sh/uv) (recommended) or pip
|
|
62
|
+
|
|
63
|
+
### Installation
|
|
64
|
+
|
|
65
|
+
1. **Clone the repository**:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
git clone https://github.com/martynvdijke/sandwitches.git
|
|
69
|
+
cd sandwitches
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
2. **Sync dependencies**:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
uv sync
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
3. **Run migrations and collect static files**:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
uv run invoke setup-ci # Sets up environment variables
|
|
82
|
+
uv run src/manage.py migrate
|
|
83
|
+
uv run src/manage.py collectstatic --noinput
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
4. **Start the development server**:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
uv run src/manage.py runserver
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## 🧪 Testing & Quality
|
|
93
|
+
|
|
94
|
+
The project maintains high standards with over **80+ automated tests**.
|
|
95
|
+
|
|
96
|
+
* **Run tests**: `uv run invoke tests`
|
|
97
|
+
* **Linting**: `uv run invoke linting`
|
|
98
|
+
* **Type checking**: `uv run invoke typecheck`
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
<p align="center">
|
|
103
|
+
Made with ❤️ for sandwich enthusiasts.
|
|
104
|
+
</p>
|