sandwitches 1.4.2__tar.gz → 2.0.0__tar.gz

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 (85) hide show
  1. sandwitches-2.0.0/PKG-INFO +104 -0
  2. sandwitches-2.0.0/README.md +81 -0
  3. {sandwitches-1.4.2 → sandwitches-2.0.0}/pyproject.toml +2 -1
  4. sandwitches-2.0.0/src/sandwitches/__init__.py +6 -0
  5. {sandwitches-1.4.2 → sandwitches-2.0.0}/src/sandwitches/admin.py +21 -2
  6. sandwitches-2.0.0/src/sandwitches/api.py +207 -0
  7. sandwitches-2.0.0/src/sandwitches/feeds.py +23 -0
  8. sandwitches-2.0.0/src/sandwitches/forms.py +196 -0
  9. sandwitches-2.0.0/src/sandwitches/locale/nl/LC_MESSAGES/django.mo +0 -0
  10. sandwitches-2.0.0/src/sandwitches/locale/nl/LC_MESSAGES/django.po +1010 -0
  11. sandwitches-2.0.0/src/sandwitches/migrations/0001_initial.py +328 -0
  12. sandwitches-2.0.0/src/sandwitches/migrations/0002_historicalrecipe_servings_recipe_servings.py +27 -0
  13. sandwitches-2.0.0/src/sandwitches/migrations/0003_setting.py +35 -0
  14. sandwitches-2.0.0/src/sandwitches/migrations/0004_alter_setting_ai_api_key_and_more.py +37 -0
  15. sandwitches-2.0.0/src/sandwitches/migrations/0005_rating_comment.py +17 -0
  16. {sandwitches-1.4.2 → sandwitches-2.0.0}/src/sandwitches/models.py +48 -4
  17. {sandwitches-1.4.2 → sandwitches-2.0.0}/src/sandwitches/settings.py +14 -5
  18. {sandwitches-1.4.2 → sandwitches-2.0.0}/src/sandwitches/storage.py +44 -12
  19. sandwitches-2.0.0/src/sandwitches/templates/admin/admin_base.html +118 -0
  20. sandwitches-2.0.0/src/sandwitches/templates/admin/confirm_delete.html +23 -0
  21. sandwitches-2.0.0/src/sandwitches/templates/admin/dashboard.html +262 -0
  22. sandwitches-2.0.0/src/sandwitches/templates/admin/rating_list.html +38 -0
  23. sandwitches-2.0.0/src/sandwitches/templates/admin/recipe_form.html +184 -0
  24. sandwitches-2.0.0/src/sandwitches/templates/admin/recipe_list.html +64 -0
  25. sandwitches-2.0.0/src/sandwitches/templates/admin/tag_form.html +30 -0
  26. sandwitches-2.0.0/src/sandwitches/templates/admin/tag_list.html +37 -0
  27. sandwitches-2.0.0/src/sandwitches/templates/admin/task_detail.html +91 -0
  28. sandwitches-2.0.0/src/sandwitches/templates/admin/task_list.html +41 -0
  29. sandwitches-2.0.0/src/sandwitches/templates/admin/user_form.html +37 -0
  30. sandwitches-2.0.0/src/sandwitches/templates/admin/user_list.html +60 -0
  31. sandwitches-2.0.0/src/sandwitches/templates/base.html +94 -0
  32. sandwitches-2.0.0/src/sandwitches/templates/base_beer.html +57 -0
  33. sandwitches-2.0.0/src/sandwitches/templates/components/favorites_search_form.html +85 -0
  34. sandwitches-2.0.0/src/sandwitches/templates/components/footer.html +14 -0
  35. sandwitches-2.0.0/src/sandwitches/templates/components/ingredients_scripts.html +50 -0
  36. sandwitches-2.0.0/src/sandwitches/templates/components/ingredients_section.html +11 -0
  37. sandwitches-2.0.0/src/sandwitches/templates/components/instructions_section.html +9 -0
  38. sandwitches-2.0.0/src/sandwitches/templates/components/language_dialog.html +26 -0
  39. sandwitches-2.0.0/src/sandwitches/templates/components/navbar.html +27 -0
  40. sandwitches-2.0.0/src/sandwitches/templates/components/rating_section.html +66 -0
  41. sandwitches-2.0.0/src/sandwitches/templates/components/recipe_header.html +32 -0
  42. sandwitches-2.0.0/src/sandwitches/templates/components/search_form.html +106 -0
  43. sandwitches-2.0.0/src/sandwitches/templates/components/search_scripts.html +98 -0
  44. sandwitches-2.0.0/src/sandwitches/templates/components/side_menu.html +35 -0
  45. sandwitches-2.0.0/src/sandwitches/templates/components/user_menu.html +10 -0
  46. sandwitches-2.0.0/src/sandwitches/templates/detail.html +178 -0
  47. sandwitches-2.0.0/src/sandwitches/templates/favorites.html +42 -0
  48. sandwitches-2.0.0/src/sandwitches/templates/index.html +42 -0
  49. sandwitches-2.0.0/src/sandwitches/templates/partials/recipe_list.html +87 -0
  50. sandwitches-2.0.0/src/sandwitches/templates/recipe_form.html +119 -0
  51. {sandwitches-1.4.2 → sandwitches-2.0.0}/src/sandwitches/templates/setup.html +1 -1
  52. sandwitches-2.0.0/src/sandwitches/templates/signup.html +133 -0
  53. sandwitches-2.0.0/src/sandwitches/templatetags/custom_filters.py +15 -0
  54. sandwitches-2.0.0/src/sandwitches/urls.py +106 -0
  55. sandwitches-2.0.0/src/sandwitches/utils.py +222 -0
  56. sandwitches-2.0.0/src/sandwitches/views.py +637 -0
  57. sandwitches-1.4.2/PKG-INFO +0 -25
  58. sandwitches-1.4.2/README.md +0 -3
  59. sandwitches-1.4.2/src/sandwitches/api.py +0 -101
  60. sandwitches-1.4.2/src/sandwitches/forms.py +0 -93
  61. sandwitches-1.4.2/src/sandwitches/locale/nl/LC_MESSAGES/django.mo +0 -0
  62. sandwitches-1.4.2/src/sandwitches/locale/nl/LC_MESSAGES/django.po +0 -360
  63. sandwitches-1.4.2/src/sandwitches/migrations/0001_initial.py +0 -75
  64. sandwitches-1.4.2/src/sandwitches/migrations/0002_historicalrecipe.py +0 -61
  65. sandwitches-1.4.2/src/sandwitches/migrations/0003_rating.py +0 -57
  66. sandwitches-1.4.2/src/sandwitches/migrations/0004_add_uploaded_by.py +0 -25
  67. sandwitches-1.4.2/src/sandwitches/migrations/0005_historicalrecipe_uploaded_by.py +0 -27
  68. sandwitches-1.4.2/src/sandwitches/migrations/0006_profile.py +0 -48
  69. sandwitches-1.4.2/src/sandwitches/migrations/0007_alter_rating_score.py +0 -23
  70. sandwitches-1.4.2/src/sandwitches/migrations/0008_delete_profile.py +0 -15
  71. sandwitches-1.4.2/src/sandwitches/templates/base.html +0 -15
  72. sandwitches-1.4.2/src/sandwitches/templates/base_pico.html +0 -260
  73. sandwitches-1.4.2/src/sandwitches/templates/detail.html +0 -121
  74. sandwitches-1.4.2/src/sandwitches/templates/form.html +0 -16
  75. sandwitches-1.4.2/src/sandwitches/templates/index.html +0 -75
  76. sandwitches-1.4.2/src/sandwitches/templates/signup.html +0 -50
  77. sandwitches-1.4.2/src/sandwitches/templatetags/__init__.py +0 -0
  78. sandwitches-1.4.2/src/sandwitches/urls.py +0 -50
  79. sandwitches-1.4.2/src/sandwitches/views.py +0 -148
  80. {sandwitches-1.4.2 → sandwitches-2.0.0}/src/sandwitches/asgi.py +0 -0
  81. {sandwitches-1.4.2/src/sandwitches → sandwitches-2.0.0/src/sandwitches/migrations}/__init__.py +0 -0
  82. {sandwitches-1.4.2 → sandwitches-2.0.0}/src/sandwitches/tasks.py +0 -0
  83. {sandwitches-1.4.2/src/sandwitches/migrations → sandwitches-2.0.0/src/sandwitches/templatetags}/__init__.py +0 -0
  84. {sandwitches-1.4.2 → sandwitches-2.0.0}/src/sandwitches/templatetags/markdown_extras.py +0 -0
  85. {sandwitches-1.4.2 → sandwitches-2.0.0}/src/sandwitches/wsgi.py +0 -0
@@ -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>
@@ -0,0 +1,81 @@
1
+ <p align="center">
2
+ <img src="src/static/icons/banner.svg" alt="Sandwitches Banner" width="600px">
3
+ </p>
4
+
5
+ <h1 align="center">🥪 Sandwitches</h1>
6
+
7
+ <p align="center">
8
+ <strong>Sandwiches so good, they haunt you!</strong>
9
+ </p>
10
+
11
+ <p align="center">
12
+ <a href="https://github.com/martynvdijke/sandwitches/actions/workflows/ci.yaml">
13
+ <img src="https://github.com/martynvdijke/sandwitches/actions/workflows/ci.yaml/badge.svg" alt="CI Status">
14
+ </a>
15
+ <a href="https://github.com/martynvdijke/sandwitches/blob/main/LICENSE">
16
+ <img src="https://img.shields.io/github/license/martynvdijke/sandwitches" alt="License">
17
+ </a>
18
+ <img src="https://img.shields.io/badge/python-3.12+-blue.svg" alt="Python Version">
19
+ <img src="https://img.shields.io/badge/django-6.0-green.svg" alt="Django Version">
20
+ <a href="https://github.com/astral-sh/ruff">
21
+ <img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json" alt="Ruff">
22
+ </a>
23
+ </p>
24
+
25
+ ---
26
+
27
+ ## ✨ Overview
28
+
29
+ Sandwitches is a modern, recipe management platform built with **Django 6.0**.
30
+ 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 :).
31
+ 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.
32
+
33
+ ## 📥 Getting Started
34
+
35
+ ### Prerequisites
36
+
37
+ * Python 3.12+
38
+ * [uv](https://github.com/astral-sh/uv) (recommended) or pip
39
+
40
+ ### Installation
41
+
42
+ 1. **Clone the repository**:
43
+
44
+ ```bash
45
+ git clone https://github.com/martynvdijke/sandwitches.git
46
+ cd sandwitches
47
+ ```
48
+
49
+ 2. **Sync dependencies**:
50
+
51
+ ```bash
52
+ uv sync
53
+ ```
54
+
55
+ 3. **Run migrations and collect static files**:
56
+
57
+ ```bash
58
+ uv run invoke setup-ci # Sets up environment variables
59
+ uv run src/manage.py migrate
60
+ uv run src/manage.py collectstatic --noinput
61
+ ```
62
+
63
+ 4. **Start the development server**:
64
+
65
+ ```bash
66
+ uv run src/manage.py runserver
67
+ ```
68
+
69
+ ## 🧪 Testing & Quality
70
+
71
+ The project maintains high standards with over **80+ automated tests**.
72
+
73
+ * **Run tests**: `uv run invoke tests`
74
+ * **Linting**: `uv run invoke linting`
75
+ * **Type checking**: `uv run invoke typecheck`
76
+
77
+ ---
78
+
79
+ <p align="center">
80
+ Made with ❤️ for sandwich enthusiasts.
81
+ </p>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sandwitches"
3
- version = "1.4.2"
3
+ version = "2.0.0"
4
4
  description = "Add your description here"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -17,6 +17,7 @@ dependencies = [
17
17
  "django-ninja>=1.5.1",
18
18
  "django-simple-history>=3.10.1",
19
19
  "django-tasks>=0.10.0",
20
+ "django-solo>=2.3.0",
20
21
  "django>=6.0.0",
21
22
  "gunicorn>=23.0.0",
22
23
  "markdown>=3.10",
@@ -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"""
@@ -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)
@@ -0,0 +1,207 @@
1
+ import logging
2
+ from ninja import NinjaAPI
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
10
+
11
+ from ninja import ModelSchema
12
+ from ninja import Schema
13
+ from django.shortcuts import get_object_or_404
14
+ from datetime import date
15
+ import random
16
+ from typing import List, Optional # Import typing hints
17
+
18
+ from ninja.security import django_auth
19
+
20
+ from . import __version__
21
+
22
+ # Get the custom User model
23
+ User = get_user_model()
24
+
25
+ api = NinjaAPI(version=__version__)
26
+
27
+
28
+ class UserPublicSchema(ModelSchema):
29
+ class Meta:
30
+ model = User
31
+ fields = ["username", "first_name", "last_name", "avatar"]
32
+
33
+
34
+ class RecipeSchema(ModelSchema):
35
+ favorited_by: List[UserPublicSchema] = []
36
+
37
+ class Meta:
38
+ model = Recipe
39
+ fields = "__all__"
40
+
41
+
42
+ class TagSchema(ModelSchema):
43
+ class Meta:
44
+ model = Tag
45
+ fields = "__all__"
46
+
47
+
48
+ class UserSchema(ModelSchema):
49
+ class Meta:
50
+ model = User
51
+ exclude = ["password", "last_login", "user_permissions"]
52
+
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
+
68
+ class Error(Schema):
69
+ message: str
70
+
71
+
72
+ class RatingResponseSchema(Schema):
73
+ average: float
74
+ count: int
75
+
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
+
85
+ @api.get("ping")
86
+ def ping(request):
87
+ return {"status": "ok", "message": "pong"}
88
+
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
+
106
+ @api.get("v1/me", response={200: UserSchema, 403: Error})
107
+ def me(request):
108
+ if not request.user.is_authenticated:
109
+ return 403, {"message": "Please sign in first"}
110
+ return request.user
111
+
112
+
113
+ @api.get("v1/users", auth=django_auth, response=list[UserSchema])
114
+ def users(request):
115
+ return User.objects.all()
116
+
117
+
118
+ @api.get("v1/recipes", response=list[RecipeSchema])
119
+ def get_recipes(request):
120
+ return Recipe.objects.all().prefetch_related("favorited_by") # ty:ignore[unresolved-attribute]
121
+
122
+
123
+ @api.get("v1/recipes/{recipe_id}", response=RecipeSchema)
124
+ def get_recipe(request, recipe_id: int):
125
+ recipe = get_object_or_404(
126
+ Recipe.objects.prefetch_related("favorited_by"), # ty:ignore[unresolved-attribute]
127
+ id=recipe_id,
128
+ )
129
+ return recipe
130
+
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
+
179
+ @api.get("v1/recipe-of-the-day", response=RecipeSchema)
180
+ def get_recipe_of_the_day(request):
181
+ recipes = list(Recipe.objects.all().prefetch_related("favorited_by")) # ty:ignore[unresolved-attribute]
182
+ if not recipes:
183
+ return None
184
+ today = date.today()
185
+ random.seed(today.toordinal())
186
+ recipe = random.choice(recipes)
187
+ return recipe
188
+
189
+
190
+ @api.get("v1/recipes/{recipe_id}/rating", response=RatingResponseSchema)
191
+ def get_recipe_rating(request, recipe_id: int):
192
+ recipe = get_object_or_404(Recipe, id=recipe_id)
193
+ return {
194
+ "average": recipe.average_rating(),
195
+ "count": recipe.rating_count(),
196
+ }
197
+
198
+
199
+ @api.get("v1/tags", response=list[TagSchema])
200
+ def get_tags(request):
201
+ return Tag.objects.all() # ty:ignore[unresolved-attribute]
202
+
203
+
204
+ @api.get("v1/tags/{tag_id}", response=TagSchema)
205
+ def get_tag(request, tag_id: int):
206
+ tag = get_object_or_404(Tag, id=tag_id)
207
+ return tag
@@ -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()
@@ -0,0 +1,196 @@
1
+ from django import forms
2
+ from django.conf import settings
3
+ from django.contrib.auth import get_user_model
4
+ from django.contrib.auth.forms import UserCreationForm
5
+ from django.utils.translation import gettext_lazy as _
6
+ from .models import Recipe, Tag, Setting
7
+
8
+ User = get_user_model()
9
+
10
+
11
+ class BaseUserFormMixin:
12
+ """Mixin to handle common password validation and user field processing."""
13
+
14
+ def clean_passwords(self, cleaned_data):
15
+ p1 = cleaned_data.get("password1")
16
+ p2 = cleaned_data.get("password2")
17
+ if p1 and p2 and p1 != p2:
18
+ raise forms.ValidationError(_("Passwords do not match."))
19
+ return cleaned_data
20
+
21
+ def _set_user_attributes(self, user, data):
22
+ """Helper to apply optional name fields."""
23
+ user.first_name = data.get("first_name", "")
24
+ user.last_name = data.get("last_name", "")
25
+ user.save()
26
+ return user
27
+
28
+
29
+ class AdminSetupForm(forms.ModelForm, BaseUserFormMixin):
30
+ password1 = forms.CharField(widget=forms.PasswordInput, label=_("Password"))
31
+ password2 = forms.CharField(widget=forms.PasswordInput, label=_("Confirm Password"))
32
+
33
+ class Meta:
34
+ model = User
35
+ fields = ("username", "first_name", "last_name", "email")
36
+
37
+ def clean(self):
38
+ cleaned_data = super().clean()
39
+ return self.clean_passwords(cleaned_data)
40
+
41
+ def save(self, commit=True):
42
+ data = self.cleaned_data
43
+ user = User.objects.create_superuser(
44
+ username=data["username"], email=data["email"], password=data["password1"]
45
+ )
46
+ return self._set_user_attributes(user, data)
47
+
48
+
49
+ class UserSignupForm(UserCreationForm, BaseUserFormMixin):
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
+ )
59
+
60
+ class Meta(UserCreationForm.Meta):
61
+ model = User
62
+ fields = (
63
+ "username",
64
+ "first_name",
65
+ "last_name",
66
+ "email",
67
+ "language",
68
+ "avatar",
69
+ "bio",
70
+ )
71
+
72
+ def clean(self):
73
+ return super().clean()
74
+
75
+ def save(self, commit=True):
76
+ user = super().save(commit=False)
77
+ user.is_superuser = False
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"]
83
+ if commit:
84
+ user.save()
85
+ return user
86
+
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
+
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
+
118
+ class Meta:
119
+ model = Recipe
120
+ fields = [
121
+ "title",
122
+ "image",
123
+ "uploaded_by",
124
+ "description",
125
+ "ingredients",
126
+ "instructions",
127
+ ]
128
+ widgets = {
129
+ "image": forms.FileInput(),
130
+ }
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
+
164
+
165
+ class RatingForm(forms.Form):
166
+ """Form for rating recipes (0-10) with an optional comment."""
167
+
168
+ score = forms.FloatField(
169
+ min_value=0.0,
170
+ max_value=10.0,
171
+ widget=forms.NumberInput(
172
+ attrs={"step": "0.1", "min": "0", "max": "10", "class": "slider"}
173
+ ),
174
+ label=_("Your rating"),
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
+ }