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.
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 +35 -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-2.0.0.dist-info/METADATA +104 -0
  55. sandwitches-2.0.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-2.0.0.dist-info}/WHEEL +0 -0
sandwitches/__init__.py CHANGED
@@ -0,0 +1,6 @@
1
+ """Top-level package for sandwitches."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("sandwitches") # Matches the 'name' in pyproject.toml
6
+ __author__ = """Martyn van Dijke"""
sandwitches/admin.py CHANGED
@@ -1,8 +1,12 @@
1
1
  from django.contrib import admin
2
- from .models import Recipe, Tag, Rating
2
+ from django.contrib.auth import get_user_model
3
+ from django.contrib.auth.admin import UserAdmin
4
+ from .models import Recipe, Tag, Rating, Setting
3
5
  from django.utils.html import format_html
4
6
  from import_export import resources
5
7
  from import_export.admin import ImportExportModelAdmin
8
+ from solo.admin import SingletonModelAdmin
9
+ from .forms import SettingForm
6
10
 
7
11
 
8
12
  class RecipeResource(resources.ModelResource):
@@ -20,6 +24,21 @@ class RatingResource(resources.ModelResource):
20
24
  model = Rating
21
25
 
22
26
 
27
+ User = get_user_model()
28
+
29
+
30
+ @admin.register(Setting)
31
+ class SettingAdmin(SingletonModelAdmin):
32
+ form = SettingForm
33
+
34
+
35
+ @admin.register(User)
36
+ class CustomUserAdmin(UserAdmin):
37
+ fieldsets = UserAdmin.fieldsets + (
38
+ (None, {"fields": ("avatar", "bio", "language", "favorites")}),
39
+ )
40
+
41
+
23
42
  @admin.register(Recipe)
24
43
  class RecipeAdmin(ImportExportModelAdmin):
25
44
  resource_classes = [RecipeResource]
@@ -36,7 +55,7 @@ class RecipeAdmin(ImportExportModelAdmin):
36
55
  url = obj.get_absolute_url()
37
56
  return format_html("<a href='{url}'>{url}</a>", url=url)
38
57
 
39
- show_url.short_description = "Recipe Link" # ty:ignore[unresolved-attribute, inconsistent-mro]
58
+ show_url.short_description = "Recipe Link" # ty:ignore[unresolved-attribute]
40
59
 
41
60
 
42
61
  @admin.register(Tag)
sandwitches/api.py CHANGED
@@ -1,21 +1,39 @@
1
+ import logging
1
2
  from ninja import NinjaAPI
2
- from .models import Recipe, Tag
3
+ from .models import Recipe, Tag, Setting, Rating
4
+ from django.contrib.auth import get_user_model
5
+ from .utils import (
6
+ parse_ingredient_line,
7
+ scale_ingredient,
8
+ format_scaled_ingredient,
9
+ ) # Import utility functions
3
10
 
4
11
  from ninja import ModelSchema
5
12
  from ninja import Schema
6
- from django.contrib.auth.models import User
7
13
  from django.shortcuts import get_object_or_404
8
14
  from datetime import date
9
15
  import random
16
+ from typing import List, Optional # Import typing hints
10
17
 
11
18
  from ninja.security import django_auth
12
19
 
13
- from __init__ import __version__
20
+ from . import __version__
21
+
22
+ # Get the custom User model
23
+ User = get_user_model()
14
24
 
15
25
  api = NinjaAPI(version=__version__)
16
26
 
17
27
 
28
+ class UserPublicSchema(ModelSchema):
29
+ class Meta:
30
+ model = User
31
+ fields = ["username", "first_name", "last_name", "avatar"]
32
+
33
+
18
34
  class RecipeSchema(ModelSchema):
35
+ favorited_by: List[UserPublicSchema] = []
36
+
19
37
  class Meta:
20
38
  model = Recipe
21
39
  fields = "__all__"
@@ -33,6 +51,20 @@ class UserSchema(ModelSchema):
33
51
  exclude = ["password", "last_login", "user_permissions"]
34
52
 
35
53
 
54
+ class SettingSchema(ModelSchema):
55
+ class Meta:
56
+ model = Setting
57
+ fields = "__all__"
58
+
59
+
60
+ class RatingSchema(ModelSchema):
61
+ user: UserPublicSchema
62
+
63
+ class Meta:
64
+ model = Rating
65
+ fields = "__all__"
66
+
67
+
36
68
  class Error(Schema):
37
69
  message: str
38
70
 
@@ -42,11 +74,35 @@ class RatingResponseSchema(Schema):
42
74
  count: int
43
75
 
44
76
 
77
+ class ScaledIngredient(Schema): # New Schema for scaled ingredients
78
+ original_line: str
79
+ scaled_line: str
80
+ quantity: Optional[float]
81
+ unit: Optional[str]
82
+ name: Optional[str]
83
+
84
+
45
85
  @api.get("ping")
46
86
  def ping(request):
47
87
  return {"status": "ok", "message": "pong"}
48
88
 
49
89
 
90
+ @api.get("v1/settings", response=SettingSchema)
91
+ def get_settings(request):
92
+ return Setting.objects.get() # ty:ignore[unresolved-attribute]
93
+
94
+
95
+ @api.post("v1/settings", auth=django_auth, response={200: SettingSchema, 403: Error})
96
+ def update_settings(request, payload: SettingSchema):
97
+ if not request.user.is_staff:
98
+ return 403, {"message": "You are not authorized to perform this action"}
99
+ settings = Setting.objects.get() # ty:ignore[unresolved-attribute]
100
+ for attr, value in payload.dict().items():
101
+ setattr(settings, attr, value)
102
+ settings.save()
103
+ return settings
104
+
105
+
50
106
  @api.get("v1/me", response={200: UserSchema, 403: Error})
51
107
  def me(request):
52
108
  if not request.user.is_authenticated:
@@ -61,18 +117,68 @@ def users(request):
61
117
 
62
118
  @api.get("v1/recipes", response=list[RecipeSchema])
63
119
  def get_recipes(request):
64
- return Recipe.objects.all() # ty:ignore[unresolved-attribute]
120
+ return Recipe.objects.all().prefetch_related("favorited_by") # ty:ignore[unresolved-attribute]
65
121
 
66
122
 
67
123
  @api.get("v1/recipes/{recipe_id}", response=RecipeSchema)
68
124
  def get_recipe(request, recipe_id: int):
69
- recipe = get_object_or_404(Recipe, id=recipe_id)
125
+ recipe = get_object_or_404(
126
+ Recipe.objects.prefetch_related("favorited_by"), # ty:ignore[unresolved-attribute]
127
+ id=recipe_id,
128
+ )
70
129
  return recipe
71
130
 
72
131
 
132
+ @api.get("v1/recipes/{recipe_id}/scale-ingredients", response=List[ScaledIngredient])
133
+ def scale_recipe_ingredients(request, recipe_id: int, target_servings: int):
134
+ recipe = get_object_or_404(Recipe, id=recipe_id)
135
+
136
+ # Ensure target_servings is at least 1
137
+ target_servings = max(1, target_servings)
138
+
139
+ current_servings = recipe.servings
140
+ if not current_servings or current_servings <= 0:
141
+ current_servings = 1
142
+
143
+ ingredient_lines = [
144
+ line.strip() for line in (recipe.ingredients or "").split("\n") if line.strip()
145
+ ]
146
+
147
+ scaled_ingredients_output = []
148
+ for line in ingredient_lines:
149
+ try:
150
+ parsed = parse_ingredient_line(line)
151
+ scaled = scale_ingredient(parsed, current_servings, target_servings)
152
+ formatted_line = format_scaled_ingredient(scaled)
153
+
154
+ scaled_ingredients_output.append(
155
+ ScaledIngredient(
156
+ original_line=line,
157
+ scaled_line=formatted_line,
158
+ quantity=scaled.get("quantity"),
159
+ unit=scaled.get("unit"),
160
+ name=scaled.get("name"),
161
+ )
162
+ )
163
+ except Exception as e:
164
+ # Fallback for lines that fail to parse/scale
165
+ scaled_ingredients_output.append(
166
+ ScaledIngredient(
167
+ original_line=line,
168
+ scaled_line=line,
169
+ quantity=None,
170
+ unit=None,
171
+ name=line,
172
+ )
173
+ )
174
+ logging.warning(f"Failed to scale ingredient line '{line}': {e}")
175
+
176
+ return scaled_ingredients_output
177
+
178
+
73
179
  @api.get("v1/recipe-of-the-day", response=RecipeSchema)
74
180
  def get_recipe_of_the_day(request):
75
- recipes = list(Recipe.objects.all()) # ty:ignore[unresolved-attribute]
181
+ recipes = list(Recipe.objects.all().prefetch_related("favorited_by")) # ty:ignore[unresolved-attribute]
76
182
  if not recipes:
77
183
  return None
78
184
  today = date.today()
sandwitches/feeds.py ADDED
@@ -0,0 +1,23 @@
1
+ from django.contrib.syndication.views import Feed
2
+ from django.urls import reverse_lazy
3
+ from .models import Recipe
4
+
5
+
6
+ class LatestRecipesFeed(Feed):
7
+ title = "Sandwitches - Latest Recipes"
8
+ link = reverse_lazy(
9
+ "index"
10
+ ) # This should point to the homepage or a list of recipes
11
+ description = "Updates on the newest recipes added to Sandwitches."
12
+
13
+ def items(self):
14
+ return Recipe.objects.order_by("-created_at")[:5] # ty:ignore[unresolved-attribute]
15
+
16
+ def item_title(self, item):
17
+ return item.title
18
+
19
+ def item_description(self, item):
20
+ return item.description
21
+
22
+ def item_link(self, item):
23
+ return item.get_absolute_url()
sandwitches/forms.py CHANGED
@@ -1,8 +1,9 @@
1
1
  from django import forms
2
+ from django.conf import settings
2
3
  from django.contrib.auth import get_user_model
3
4
  from django.contrib.auth.forms import UserCreationForm
4
5
  from django.utils.translation import gettext_lazy as _
5
- from .models import Recipe
6
+ from .models import Recipe, Tag, Setting
6
7
 
7
8
  User = get_user_model()
8
9
 
@@ -46,11 +47,27 @@ class AdminSetupForm(forms.ModelForm, BaseUserFormMixin):
46
47
 
47
48
 
48
49
  class UserSignupForm(UserCreationForm, BaseUserFormMixin):
49
- """Refactored Regular User Form inheriting from Django's UserCreationForm"""
50
+ language = forms.ChoiceField(
51
+ choices=settings.LANGUAGES,
52
+ label=_("Preferred language"),
53
+ initial=settings.LANGUAGE_CODE,
54
+ )
55
+ avatar = forms.ImageField(label=_("Profile Image"), required=False)
56
+ bio = forms.CharField(
57
+ widget=forms.Textarea(attrs={"rows": 3}), label=_("Bio"), required=False
58
+ )
50
59
 
51
60
  class Meta(UserCreationForm.Meta):
52
61
  model = User
53
- fields = ("username", "first_name", "last_name", "email")
62
+ fields = (
63
+ "username",
64
+ "first_name",
65
+ "last_name",
66
+ "email",
67
+ "language",
68
+ "avatar",
69
+ "bio",
70
+ )
54
71
 
55
72
  def clean(self):
56
73
  return super().clean()
@@ -59,29 +76,94 @@ class UserSignupForm(UserCreationForm, BaseUserFormMixin):
59
76
  user = super().save(commit=False)
60
77
  user.is_superuser = False
61
78
  user.is_staff = False
79
+ # Explicitly save the extra fields if they aren't automatically handled by ModelForm save (they should be if in Meta.fields)
80
+ user.language = self.cleaned_data["language"]
81
+ user.avatar = self.cleaned_data["avatar"]
82
+ user.bio = self.cleaned_data["bio"]
62
83
  if commit:
63
84
  user.save()
64
85
  return user
65
86
 
66
87
 
88
+ class UserEditForm(forms.ModelForm):
89
+ class Meta:
90
+ model = User
91
+ fields = (
92
+ "username",
93
+ "first_name",
94
+ "last_name",
95
+ "email",
96
+ "is_staff",
97
+ "is_active",
98
+ "language",
99
+ "avatar",
100
+ "bio",
101
+ )
102
+
103
+
104
+ class TagForm(forms.ModelForm):
105
+ class Meta:
106
+ model = Tag
107
+ fields = ("name",)
108
+
109
+
67
110
  class RecipeForm(forms.ModelForm):
111
+ tags_string = forms.CharField(
112
+ required=False,
113
+ label=_("Tags (comma separated)"),
114
+ widget=forms.TextInput(attrs={"placeholder": _("e.g. spicy, vegan, quick")}),
115
+ )
116
+ rotation = forms.IntegerField(widget=forms.HiddenInput(), initial=0, required=False)
117
+
68
118
  class Meta:
69
119
  model = Recipe
70
120
  fields = [
71
121
  "title",
122
+ "image",
123
+ "uploaded_by",
72
124
  "description",
73
125
  "ingredients",
74
126
  "instructions",
75
- "image",
76
- "tags",
77
127
  ]
78
128
  widgets = {
79
- "tags": forms.TextInput(attrs={"placeholder": "tag1,tag2"}),
129
+ "image": forms.FileInput(),
80
130
  }
81
131
 
132
+ def __init__(self, *args, **kwargs):
133
+ super().__init__(*args, **kwargs)
134
+ if self.instance.pk:
135
+ self.fields["tags_string"].initial = ", ".join(
136
+ self.instance.tags.values_list("name", flat=True)
137
+ )
138
+
139
+ def save(self, commit=True):
140
+ recipe = super().save(commit=commit)
141
+
142
+ # Handle rotation if an image exists and rotation is requested
143
+ rotation = self.cleaned_data.get("rotation", 0)
144
+ if rotation != 0 and recipe.image:
145
+ try:
146
+ from PIL import Image as PILImage
147
+
148
+ img = PILImage.open(recipe.image.path)
149
+ # PIL rotates counter-clockwise by default, our 'rotation' is clockwise
150
+ img = img.rotate(-rotation, expand=True)
151
+ img.save(recipe.image.path)
152
+ except Exception:
153
+ pass
154
+
155
+ if commit:
156
+ recipe.set_tags_from_string(self.cleaned_data.get("tags_string", ""))
157
+ else:
158
+ # We'll need to handle this in the view if commit=False
159
+ self.save_m2m = lambda: recipe.set_tags_from_string(
160
+ self.cleaned_data.get("tags_string", "")
161
+ )
162
+ return recipe
163
+
82
164
 
83
165
  class RatingForm(forms.Form):
84
- """Form for rating recipes (0-10)."""
166
+ """Form for rating recipes (0-10) with an optional comment."""
85
167
 
86
168
  score = forms.FloatField(
87
169
  min_value=0.0,
@@ -91,3 +173,24 @@ class RatingForm(forms.Form):
91
173
  ),
92
174
  label=_("Your rating"),
93
175
  )
176
+ comment = forms.CharField(
177
+ widget=forms.Textarea(attrs={"rows": 2}),
178
+ label=_("Comment (optional)"),
179
+ required=False,
180
+ )
181
+
182
+
183
+ class SettingForm(forms.ModelForm):
184
+ class Meta:
185
+ model = Setting
186
+ fields = [
187
+ "site_name",
188
+ "site_description",
189
+ "email",
190
+ "ai_connection_point",
191
+ "ai_model",
192
+ "ai_api_key",
193
+ ]
194
+ widgets = {
195
+ "ai_api_key": forms.PasswordInput(render_value=True),
196
+ }
Binary file