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
@@ -1,50 +1,133 @@
1
- {% extends "base_pico.html" %}
1
+ {% extends "base_beer.html" %}
2
2
  {% load static i18n %}
3
3
  {% block title %}{% trans "Sign Up" %}{% endblock %}
4
4
 
5
5
  {% block content %}
6
- <div class="container" style="max-width:720px; margin:2rem auto;">
7
- <article class="card">
8
- <div class="card-body">
9
- <h2>{% trans "Sign up" %}</h2>
6
+ <div class="large-space"></div>
10
7
 
11
- <form method="post" novalidate>
8
+ <div class="grid">
9
+ <div class="s12 m10 l8 xl6 middle-align center-align" style="margin: 0 auto;">
10
+ <article class="round elevate">
11
+ <div class="padding">
12
+ <h4 class="center-align primary-text">{% trans "Create your account" %}</h4>
13
+ <p class="center-align">{% trans "Join the Sandwitches community today!" %}</p>
14
+ </div>
15
+
16
+ <form method="post" enctype="multipart/form-data" novalidate>
12
17
  {% csrf_token %}
18
+
13
19
  {% if form.non_field_errors %}
14
- <div class="card-panel" role="alert">
15
- <ul>
16
- {% for err in form.non_field_errors %}
17
- <li>{{ err }}</li>
18
- {% endfor %}
19
- </ul>
20
+ <div class="padding error surface round mb">
21
+ {% for err in form.non_field_errors %}
22
+ <div class="row align-center">
23
+ <i class="error-text">warning</i>
24
+ <span class="error-text">{{ err }}</span>
25
+ </div>
26
+ {% endfor %}
20
27
  </div>
21
28
  {% endif %}
22
29
 
23
- <label for="{{ form.username.id_for_label }}">{% trans "Username" %}</label>
24
- {{ form.username }}
30
+ <div class="grid">
31
+ <div class="s12 m6">
32
+ <div class="field label border round {% if form.first_name.errors %}error{% endif %}">
33
+ <input type="text" name="{{ form.first_name.name }}" id="{{ form.first_name.id_for_label }}" value="{{ form.first_name.value|default:'' }}">
34
+ <label>{% trans "First name" %}</label>
35
+ {% if form.first_name.errors %}
36
+ <span class="helper error-text">{{ form.first_name.errors.0 }}</span>
37
+ {% endif %}
38
+ </div>
39
+ </div>
40
+ <div class="s12 m6">
41
+ <div class="field label border round {% if form.last_name.errors %}error{% endif %}">
42
+ <input type="text" name="{{ form.last_name.name }}" id="{{ form.last_name.id_for_label }}" value="{{ form.last_name.value|default:'' }}">
43
+ <label>{% trans "Last name" %}</label>
44
+ {% if form.last_name.errors %}
45
+ <span class="helper error-text">{{ form.last_name.errors.0 }}</span>
46
+ {% endif %}
47
+ </div>
48
+ </div>
49
+ </div>
25
50
 
26
- <label for="{{ form.email.id_for_label }}">{% trans "Email (optional)" %}</label>
27
- {{ form.email }}
51
+ <div class="field label border round {% if form.username.errors %}error{% endif %}">
52
+ <input type="text" name="{{ form.username.name }}" id="{{ form.username.id_for_label }}" value="{{ form.username.value|default:'' }}">
53
+ <label>{% trans "Username" %}</label>
54
+ {% if form.username.errors %}
55
+ <span class="helper error-text">{{ form.username.errors.0 }}</span>
56
+ {% endif %}
57
+ </div>
28
58
 
29
- <label for="{{ form.first_name.id_for_label }}">{% trans "First name" %}</label>
30
- {{ form.first_name }}
59
+ <div class="field label border round {% if form.email.errors %}error{% endif %}">
60
+ <input type="email" name="{{ form.email.name }}" id="{{ form.email.id_for_label }}" value="{{ form.email.value|default:'' }}">
61
+ <label>{% trans "Email (optional)" %}</label>
62
+ {% if form.email.errors %}
63
+ <span class="helper error-text">{{ form.email.errors.0 }}</span>
64
+ {% endif %}
65
+ </div>
66
+
67
+ <div class="field label border round {% if form.language.errors %}error{% endif %}">
68
+ <select name="{{ form.language.name }}" id="{{ form.language.id_for_label }}">
69
+ {% for value, label in form.language.field.choices %}
70
+ <option value="{{ value }}" {% if form.language.value == value %}selected{% endif %}>{{ label }}</option>
71
+ {% endfor %}
72
+ </select>
73
+ <label>{% trans "Preferred Language" %}</label>
74
+ {% if form.language.errors %}
75
+ <span class="helper error-text">{{ form.language.errors.0 }}</span>
76
+ {% endif %}
77
+ </div>
31
78
 
32
- <label for="{{ form.last_name.id_for_label }}">{% trans "Last name" %}</label>
33
- {{ form.last_name }}
79
+ <div class="field label border round textarea {% if form.bio.errors %}error{% endif %}">
80
+ <textarea name="{{ form.bio.name }}" id="{{ form.bio.id_for_label }}" rows="3">{{ form.bio.value|default:'' }}</textarea>
81
+ <label>{% trans "Bio" %}</label>
82
+ {% if form.bio.errors %}
83
+ <span class="helper error-text">{{ form.bio.errors.0 }}</span>
84
+ {% endif %}
85
+ </div>
86
+
87
+ <div class="field middle-align {% if form.avatar.errors %}error{% endif %}">
88
+ <nav>
89
+ <div class="max">
90
+ <label class="button border transparent round">
91
+ <input type="file" name="{{ form.avatar.name }}" id="{{ form.avatar.id_for_label }}" accept="image/*">
92
+ <i>upload</i>
93
+ <span>{% trans "Upload Profile Picture" %}</span>
94
+ </label>
95
+ </div>
96
+ </nav>
97
+ {% if form.avatar.errors %}
98
+ <span class="helper error-text">{{ form.avatar.errors.0 }}</span>
99
+ {% endif %}
100
+ </div>
34
101
 
35
- <label for="{{ form.password1.id_for_label }}">{% trans "Password" %}</label>
36
- {{ form.password1 }}
102
+ <div class="space"></div>
103
+ <div class="divider"></div>
104
+ <div class="space"></div>
37
105
 
38
- <label for="{{ form.password2.id_for_label }}">{% trans "Confirm password" %}</label>
39
- {{ form.password2 }}
106
+ <div class="field label border round {% if form.password1.errors %}error{% endif %}">
107
+ <input type="password" name="{{ form.password1.name }}" id="{{ form.password1.id_for_label }}">
108
+ <label>{% trans "Password" %}</label>
109
+ {% if form.password1.errors %}
110
+ <span class="helper error-text">{{ form.password1.errors.0 }}</span>
111
+ {% endif %}
112
+ </div>
113
+
114
+ <div class="field label border round {% if form.password2.errors %}error{% endif %}">
115
+ <input type="password" name="{{ form.password2.name }}" id="{{ form.password2.id_for_label }}">
116
+ <label>{% trans "Confirm password" %}</label>
117
+ {% if form.password2.errors %}
118
+ <span class="helper error-text">{{ form.password2.errors.0 }}</span>
119
+ {% endif %}
120
+ </div>
121
+
122
+ <div class="large-space"></div>
123
+
124
+ <nav class="right-align">
125
+ <a class="button transparent border round" href="{% url 'index' %}">{% trans "Cancel" %}</a>
126
+ <button type="submit" class="button primary round">{% trans "Sign Up" %}</button>
127
+ </nav>
40
128
 
41
- <p style="margin-top:1rem;">
42
- <button type="submit">{% trans "Sign Up" %}</button>
43
- <a class="contrast" href="{% url 'index' %}">{% trans "Cancel" %}</a>
44
- </p>
45
129
  </form>
46
- </div>
47
- </article>
130
+ </article>
131
+ </div>
48
132
  </div>
49
-
50
133
  {% endblock %}
@@ -0,0 +1,15 @@
1
+ from django import template
2
+
3
+ register = template.Library()
4
+
5
+
6
+ @register.filter
7
+ def split(value, arg):
8
+ return value.split(arg)
9
+
10
+
11
+ @register.filter
12
+ def strip(value):
13
+ if isinstance(value, str):
14
+ return value.strip()
15
+ return value
sandwitches/urls.py CHANGED
@@ -20,6 +20,7 @@ from django.urls import path, include
20
20
  from . import views
21
21
  from .api import api
22
22
  from django.conf.urls.i18n import i18n_patterns
23
+ from .feeds import LatestRecipesFeed # Import the feed class
23
24
 
24
25
 
25
26
  import os
@@ -32,13 +33,68 @@ urlpatterns = [
32
33
  path("admin/", admin.site.urls),
33
34
  path("api/", api.urls),
34
35
  path("media/<path:file_path>", views.media, name="media"),
36
+ path("favorites/", views.favorites, name="favorites"),
35
37
  path("", views.index, name="index"),
38
+ path("feeds/latest/", LatestRecipesFeed(), name="latest_recipes_feed"),
39
+ path(
40
+ "feeds/latest/", LatestRecipesFeed(), name="latest_recipes_feed"
41
+ ), # Add this line
36
42
  ]
37
43
 
38
44
  urlpatterns += i18n_patterns(
39
45
  path("recipes/<slug:slug>/", views.recipe_detail, name="recipe_detail"),
40
46
  path("setup/", views.setup, name="setup"),
41
47
  path("recipes/<int:pk>/rate/", views.recipe_rate, name="recipe_rate"),
48
+ path("recipes/<int:pk>/favorite/", views.toggle_favorite, name="toggle_favorite"),
49
+ path("dashboard/", views.admin_dashboard, name="admin_dashboard"),
50
+ path("dashboard/recipes/", views.admin_recipe_list, name="admin_recipe_list"),
51
+ path("dashboard/recipes/add/", views.admin_recipe_add, name="admin_recipe_add"),
52
+ path(
53
+ "dashboard/recipes/<int:pk>/edit/",
54
+ views.admin_recipe_edit,
55
+ name="admin_recipe_edit",
56
+ ),
57
+ path(
58
+ "dashboard/recipes/<int:pk>/delete/",
59
+ views.admin_recipe_delete,
60
+ name="admin_recipe_delete",
61
+ ),
62
+ path(
63
+ "dashboard/recipes/<int:pk>/rotate/",
64
+ views.admin_recipe_rotate,
65
+ name="admin_recipe_rotate",
66
+ ),
67
+ path("dashboard/users/", views.admin_user_list, name="admin_user_list"),
68
+ path(
69
+ "dashboard/users/<int:pk>/edit/", views.admin_user_edit, name="admin_user_edit"
70
+ ),
71
+ path(
72
+ "dashboard/users/<int:pk>/delete/",
73
+ views.admin_user_delete,
74
+ name="admin_user_delete",
75
+ ),
76
+ path("dashboard/tags/", views.admin_tag_list, name="admin_tag_list"),
77
+ path("dashboard/tags/add/", views.admin_tag_add, name="admin_tag_add"),
78
+ path(
79
+ "dashboard/tags/<int:pk>/edit/",
80
+ views.admin_tag_edit,
81
+ name="admin_tag_edit",
82
+ ),
83
+ path(
84
+ "dashboard/tags/<int:pk>/delete/",
85
+ views.admin_tag_delete,
86
+ name="admin_tag_delete",
87
+ ),
88
+ path("dashboard/tasks/", views.admin_task_list, name="admin_task_list"),
89
+ path(
90
+ "dashboard/tasks/<str:pk>/", views.admin_task_detail, name="admin_task_detail"
91
+ ),
92
+ path("dashboard/ratings/", views.admin_rating_list, name="admin_rating_list"),
93
+ path(
94
+ "dashboard/ratings/<int:pk>/delete/",
95
+ views.admin_rating_delete,
96
+ name="admin_rating_delete",
97
+ ),
42
98
  prefix_default_language=True,
43
99
  )
44
100
 
sandwitches/utils.py ADDED
@@ -0,0 +1,222 @@
1
+ import re
2
+
3
+ # Define a set of common units for better parsing
4
+ COMMON_UNITS = {
5
+ "cup",
6
+ "cups",
7
+ "oz",
8
+ "ounce",
9
+ "ounces",
10
+ "g",
11
+ "gram",
12
+ "grams",
13
+ "kg",
14
+ "kilogram",
15
+ "kilograms",
16
+ "lb",
17
+ "lbs",
18
+ "pound",
19
+ "pounds",
20
+ "ml",
21
+ "milliliter",
22
+ "milliliters",
23
+ "l",
24
+ "liter",
25
+ "liters",
26
+ "tsp",
27
+ "teaspoon",
28
+ "teaspoons",
29
+ "tbsp",
30
+ "tablespoon",
31
+ "tablespoons",
32
+ "pinch",
33
+ "pinches",
34
+ "slice",
35
+ "slices",
36
+ "clove",
37
+ "cloves",
38
+ "large",
39
+ "medium",
40
+ "small",
41
+ "can",
42
+ "cans",
43
+ "package",
44
+ "packages",
45
+ "piece",
46
+ "pieces",
47
+ "dash",
48
+ "dashes",
49
+ "sprig",
50
+ "sprigs",
51
+ "to taste",
52
+ }
53
+
54
+
55
+ def parse_ingredient_line(line):
56
+ """
57
+ Parses a single ingredient line to extract quantity, unit, and ingredient name.
58
+ This is a heuristic approach and may not cover all possible formats.
59
+ Returns a dictionary with 'quantity', 'unit', 'name', and 'original_line'.
60
+ Quantity will be a float, unit and name strings.
61
+ If parsing fails, returns None for quantity/unit but includes original_line.
62
+ """
63
+ original_line = line.strip()
64
+
65
+ quantity = None
66
+ unit = None
67
+ name = original_line
68
+
69
+ # Regex to capture quantity at the beginning:
70
+ # 1. Whole number with fraction (e.g., "1 1/2", "2 3/4")
71
+ # 2. Simple fraction (e.g., "1/2", "3/4")
72
+ # 3. Decimal number (e.g., "0.5", "2.0", ".75")
73
+ # 4. Whole number (e.g., "1", "100")
74
+ # The quantity is optional to handle lines like "Salt to taste".
75
+ #
76
+ # This pattern specifically tries to get the quantity part.
77
+ # It covers:
78
+ # - `1 1/2` (whole number followed by space and fraction)
79
+ # - `1/2` (fraction)
80
+ # - `1.5` (decimal)
81
+ # - `1` (integer)
82
+ quantity_match = re.match(
83
+ r"^\s*(\d+\s+\d/\d|\d/\d|\d+\.?\d*|\.\d+)\s*(.*)", original_line
84
+ )
85
+
86
+ remaining_line = original_line
87
+
88
+ if quantity_match:
89
+ quantity_str = quantity_match.group(1)
90
+ remaining_line = quantity_match.group(2).strip()
91
+
92
+ try:
93
+ # Handle fractions like "1 1/2" or "1/2"
94
+ if " " in quantity_str and "/" in quantity_str:
95
+ parts = quantity_str.split(" ", 1)
96
+ whole = float(parts[0])
97
+ fraction_parts = parts[1].split("/")
98
+ numerator = float(fraction_parts[0])
99
+ denominator = float(fraction_parts[1])
100
+ quantity = whole + (numerator / denominator)
101
+ elif "/" in quantity_str:
102
+ parts = quantity_str.split("/")
103
+ numerator = float(parts[0])
104
+ denominator = float(parts[1])
105
+ quantity = numerator / denominator
106
+ else:
107
+ quantity = float(quantity_str)
108
+ except ValueError:
109
+ quantity = None # Parsing quantity failed
110
+
111
+ # Now, try to find a unit in the remaining_line
112
+ # We iterate through common units to find the longest match first
113
+ found_unit = None
114
+ for common_unit in sorted(list(COMMON_UNITS), key=len, reverse=True):
115
+ # Check if the unit is at the beginning of the remaining_line, followed by a space or end of string
116
+ # using word boundary to avoid partial matches (e.g., "cup" in "cupholder")
117
+ unit_regex = r"^\s*" + re.escape(common_unit) + r"(\b|\s|$)" # ty:ignore[invalid-argument-type]
118
+ unit_match = re.match(unit_regex, remaining_line, re.IGNORECASE)
119
+ if unit_match:
120
+ found_unit = remaining_line[: unit_match.end(1)].strip()
121
+ remaining_line = remaining_line[unit_match.end(1) :].strip()
122
+ break # Found the unit, stop searching
123
+
124
+ if found_unit:
125
+ unit = found_unit
126
+
127
+ # The rest of the line is the name
128
+ name = (
129
+ remaining_line
130
+ if remaining_line
131
+ else original_line
132
+ if quantity is None and unit is None
133
+ else ""
134
+ )
135
+
136
+ # If quantity was not parsed, and no unit was found, then the whole line is the name
137
+ if quantity is None and unit is None:
138
+ name = original_line
139
+ elif name == "" and (quantity is not None or unit is not None):
140
+ # If we parsed a quantity or unit but no name, it means the name was part of the unit search
141
+ # or it was implicitly part of the quantity_match that took everything.
142
+ # This is a fallback to ensure something is in name if it's not a unit.
143
+ if unit and original_line.lower().startswith(
144
+ f"{quantity_str.lower() if quantity_str else ''} {unit.lower()}".strip()
145
+ ):
146
+ name_start_index = original_line.lower().find(unit.lower()) + len(unit)
147
+ potential_name = original_line[name_start_index:].strip()
148
+ if potential_name:
149
+ name = potential_name
150
+ elif quantity_match:
151
+ # Fallback if name is empty and quantity was matched, it means quantity was everything
152
+ # or the unit was consumed.
153
+ if not unit: # If no unit, the remaining_line should be the name
154
+ name = remaining_line
155
+
156
+ return {
157
+ "quantity": quantity,
158
+ "unit": unit,
159
+ "name": name.strip(),
160
+ "original_line": original_line,
161
+ }
162
+
163
+
164
+ def scale_ingredient(parsed_ingredient, current_servings, target_servings):
165
+ """
166
+ Scales a parsed ingredient based on current and target servings.
167
+ """
168
+ if parsed_ingredient["quantity"] is None or current_servings == 0:
169
+ # Cannot scale if quantity is unknown or current servings is zero
170
+ return parsed_ingredient
171
+
172
+ scale_factor = target_servings / current_servings
173
+ scaled_quantity = parsed_ingredient["quantity"] * scale_factor
174
+
175
+ return {
176
+ "quantity": scaled_quantity,
177
+ "unit": parsed_ingredient["unit"],
178
+ "name": parsed_ingredient["name"],
179
+ "original_line": parsed_ingredient[
180
+ "original_line"
181
+ ], # Keep original line reference
182
+ }
183
+
184
+
185
+ def format_scaled_ingredient(scaled_ingredient):
186
+ """
187
+ Formats a scaled ingredient back into a human-readable string.
188
+ This will try to reconstruct the string based on the parsed components.
189
+ """
190
+ quantity = scaled_ingredient["quantity"]
191
+ unit = scaled_ingredient["unit"]
192
+ name = scaled_ingredient["name"]
193
+
194
+ if quantity is None:
195
+ return scaled_ingredient[
196
+ "original_line"
197
+ ] # Fallback to original if quantity was not parsed
198
+
199
+ # Simple formatting for now. Could add fraction handling later.
200
+ if quantity == int(quantity):
201
+ quantity_str = str(int(quantity))
202
+ else:
203
+ # Format to 2 decimal places, remove trailing zeros, and remove trailing dot if integer
204
+ quantity_str = f"{quantity:.2f}".rstrip("0").rstrip(".")
205
+ if not quantity_str: # Handle cases like 0.0 -> ""
206
+ quantity_str = "0"
207
+
208
+ parts = []
209
+ if quantity_str:
210
+ parts.append(quantity_str)
211
+
212
+ # Check for units that usually don't have a space before them
213
+ if unit:
214
+ if unit.lower() in ["g", "mg"]: # Only g and mg are typically appended directly
215
+ parts[-1] += unit # Append unit directly to the last part (quantity)
216
+ else:
217
+ parts.append(unit)
218
+
219
+ if name:
220
+ parts.append(name)
221
+
222
+ return " ".join(parts).strip()