sandwitches 1.4.2__py3-none-any.whl → 1.5.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.
Files changed (67) hide show
  1. sandwitches/__init__.py +6 -0
  2. sandwitches/admin.py +21 -2
  3. sandwitches/api.py +112 -6
  4. sandwitches/feeds.py +23 -0
  5. sandwitches/forms.py +110 -7
  6. sandwitches/locale/nl/LC_MESSAGES/django.mo +0 -0
  7. sandwitches/locale/nl/LC_MESSAGES/django.po +784 -134
  8. sandwitches/migrations/0001_initial.py +255 -2
  9. sandwitches/migrations/0002_historicalrecipe_servings_recipe_servings.py +27 -0
  10. sandwitches/migrations/0003_setting.py +35 -0
  11. sandwitches/migrations/0004_alter_setting_ai_api_key_and_more.py +37 -0
  12. sandwitches/migrations/0005_rating_comment.py +17 -0
  13. sandwitches/models.py +48 -4
  14. sandwitches/settings.py +14 -5
  15. sandwitches/storage.py +44 -12
  16. sandwitches/templates/admin/admin_base.html +118 -0
  17. sandwitches/templates/admin/confirm_delete.html +23 -0
  18. sandwitches/templates/admin/dashboard.html +262 -0
  19. sandwitches/templates/admin/rating_list.html +38 -0
  20. sandwitches/templates/admin/recipe_form.html +184 -0
  21. sandwitches/templates/admin/recipe_list.html +64 -0
  22. sandwitches/templates/admin/tag_form.html +30 -0
  23. sandwitches/templates/admin/tag_list.html +37 -0
  24. sandwitches/templates/admin/task_detail.html +91 -0
  25. sandwitches/templates/admin/task_list.html +41 -0
  26. sandwitches/templates/admin/user_form.html +37 -0
  27. sandwitches/templates/admin/user_list.html +60 -0
  28. sandwitches/templates/base.html +80 -1
  29. sandwitches/templates/base_beer.html +57 -0
  30. sandwitches/templates/components/favorites_search_form.html +85 -0
  31. sandwitches/templates/components/footer.html +14 -0
  32. sandwitches/templates/components/ingredients_scripts.html +50 -0
  33. sandwitches/templates/components/ingredients_section.html +11 -0
  34. sandwitches/templates/components/instructions_section.html +9 -0
  35. sandwitches/templates/components/language_dialog.html +26 -0
  36. sandwitches/templates/components/navbar.html +27 -0
  37. sandwitches/templates/components/rating_section.html +66 -0
  38. sandwitches/templates/components/recipe_header.html +32 -0
  39. sandwitches/templates/components/search_form.html +106 -0
  40. sandwitches/templates/components/search_scripts.html +98 -0
  41. sandwitches/templates/components/side_menu.html +31 -0
  42. sandwitches/templates/components/user_menu.html +10 -0
  43. sandwitches/templates/detail.html +167 -110
  44. sandwitches/templates/favorites.html +42 -0
  45. sandwitches/templates/index.html +28 -61
  46. sandwitches/templates/partials/recipe_list.html +87 -0
  47. sandwitches/templates/recipe_form.html +119 -0
  48. sandwitches/templates/setup.html +1 -1
  49. sandwitches/templates/signup.html +114 -31
  50. sandwitches/templatetags/custom_filters.py +15 -0
  51. sandwitches/urls.py +56 -0
  52. sandwitches/utils.py +222 -0
  53. sandwitches/views.py +503 -14
  54. sandwitches-1.5.0.dist-info/METADATA +104 -0
  55. sandwitches-1.5.0.dist-info/RECORD +62 -0
  56. sandwitches/migrations/0002_historicalrecipe.py +0 -61
  57. sandwitches/migrations/0003_rating.py +0 -57
  58. sandwitches/migrations/0004_add_uploaded_by.py +0 -25
  59. sandwitches/migrations/0005_historicalrecipe_uploaded_by.py +0 -27
  60. sandwitches/migrations/0006_profile.py +0 -48
  61. sandwitches/migrations/0007_alter_rating_score.py +0 -23
  62. sandwitches/migrations/0008_delete_profile.py +0 -15
  63. sandwitches/templates/base_pico.html +0 -260
  64. sandwitches/templates/form.html +0 -16
  65. sandwitches-1.4.2.dist-info/METADATA +0 -25
  66. sandwitches-1.4.2.dist-info/RECORD +0 -35
  67. {sandwitches-1.4.2.dist-info → sandwitches-1.5.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 RecipeForm, AdminSetupForm, UserSignupForm, RatingForm
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
- def recipe_edit(request, pk):
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
- return redirect("recipes:admin_list")
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(request, "recipe_form.html", {"form": form, "recipe": recipe})
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 = {"score": str(user_rating.score)} if user_rating else None
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 = float(form.cleaned_data["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, user=request.user, defaults={"score": score}
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 = Recipe.objects.order_by("-created_at") # ty:ignore[unresolved-attribute]
83
- return render(request, "index.html", {"recipes": recipes})
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(request, "signup.html", {"form": form})
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, _ = mimetypes.guess_type(full_path)
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: 1.5.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>