sandwitches 1.1.0__py3-none-any.whl → 1.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sandwitches/settings.py CHANGED
@@ -12,21 +12,45 @@ https://docs.djangoproject.com/en/5.2/ref/settings/
12
12
 
13
13
  from pathlib import Path
14
14
  import os
15
+ from django.core.exceptions import ImproperlyConfigured
16
+ from . import storage
15
17
 
18
+ DEBUG = bool(os.environ.get("DEBUG", default=0)) # ty:ignore[no-matching-overload]
16
19
 
17
20
  SECRET_KEY = os.environ.get("SECRET_KEY")
18
- DEBUG = bool(os.environ.get("DEBUG", default=0))
19
- ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "127.0.0.1").split(",")
21
+ if not SECRET_KEY:
22
+ raise ImproperlyConfigured(
23
+ "The SECRET_KEY environment variable must be set in production."
24
+ )
25
+
26
+ ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "127.0.0.1, localhost").split(",")
20
27
  CSRF_TRUSTED_ORIGINS = os.environ.get("CSRF_TRUSTED_ORIGINS", "").split(",")
28
+ DATABASE_FILE = Path(os.environ.get("DATABASE_FILE", default="/db/db.sqlite3")) # ty:ignore[no-matching-overload]
29
+
30
+ storage.is_database_readable(DATABASE_FILE)
31
+ storage.is_database_writable(DATABASE_FILE)
21
32
 
22
- # RECAPTCHA_PROXY = {'http': 'http://127.0.0.1:8000', 'https': 'https://127.0.0.1:8000'}
23
- # RECAPTCHA_PUBLIC_KEY = os.environ.get("RECAPTCHA_PUBLIC_KEY")
24
- # RECAPTCHA_PRIVATE_KEY = os.environ.get("RECAPTCHA_PRIVATE_KEY")
25
33
 
26
34
  # Build paths inside the project like this: BASE_DIR / 'subdir'.
27
35
  BASE_DIR = Path(__file__).resolve().parent.parent
28
36
 
29
- TASKS = {"default": {"BACKEND": "django.tasks.backends.immediate.ImmediateBackend"}}
37
+
38
+ TASKS = {
39
+ "default": {
40
+ "BACKEND": "django_tasks.backends.database.DatabaseBackend",
41
+ "QUEUES": ["default", "emails"],
42
+ "OPTIONS": {
43
+ "queues": {
44
+ "low_priority": {
45
+ "max_attempts": 5,
46
+ }
47
+ },
48
+ "max_attempts": 10,
49
+ "backoff_factor": 3,
50
+ "purge": {"finished": "10 days", "unfinished": "20 days"},
51
+ },
52
+ }
53
+ }
30
54
 
31
55
  # Application definition
32
56
  INSTALLED_APPS = [
@@ -37,14 +61,18 @@ INSTALLED_APPS = [
37
61
  "django.contrib.messages",
38
62
  "django.contrib.staticfiles",
39
63
  "sandwitches",
64
+ "django_tasks",
65
+ "django_tasks.backends.database",
40
66
  "debug_toolbar",
41
- # "django_recaptcha",
67
+ "imagekit",
42
68
  "simple_history",
43
69
  ]
44
70
 
45
71
  MIDDLEWARE = [
46
72
  "django.middleware.security.SecurityMiddleware",
73
+ "whitenoise.middleware.WhiteNoiseMiddleware",
47
74
  "django.contrib.sessions.middleware.SessionMiddleware",
75
+ "django.middleware.locale.LocaleMiddleware",
48
76
  "django.middleware.common.CommonMiddleware",
49
77
  "django.middleware.csrf.CsrfViewMiddleware",
50
78
  "django.contrib.auth.middleware.AuthenticationMiddleware",
@@ -67,6 +95,7 @@ TEMPLATES = [
67
95
  "django.contrib.auth.context_processors.auth",
68
96
  "django.contrib.messages.context_processors.messages",
69
97
  "django.template.context_processors.csrf",
98
+ "django.template.context_processors.i18n",
70
99
  ],
71
100
  },
72
101
  },
@@ -74,17 +103,30 @@ TEMPLATES = [
74
103
 
75
104
  WSGI_APPLICATION = "sandwitches.wsgi.application"
76
105
 
77
-
78
- # Database
79
- # https://docs.djangoproject.com/en/5.2/ref/settings/#databases
80
-
81
106
  DATABASES = {
82
107
  "default": {
83
108
  "ENGINE": "django.db.backends.sqlite3",
84
- "NAME": Path("/config/db.sqlite3"),
109
+ "NAME": DATABASE_FILE,
85
110
  }
86
111
  }
87
112
 
113
+ LOGGING = {
114
+ "version": 1,
115
+ "disable_existing_loggers": False,
116
+ "handlers": {
117
+ "console": {
118
+ "class": "logging.StreamHandler",
119
+ },
120
+ },
121
+ "loggers": {
122
+ "django": {
123
+ "handlers": ["console"],
124
+ "level": os.getenv("LOG_LEVEL", "INFO"),
125
+ "propagate": False,
126
+ },
127
+ },
128
+ }
129
+
88
130
 
89
131
  # Password validation
90
132
  # https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
@@ -107,20 +149,24 @@ AUTH_PASSWORD_VALIDATORS = [
107
149
 
108
150
  # Media files (for uploaded images)
109
151
  MEDIA_URL = "/media/"
110
- MEDIA_ROOT = Path("/config/media")
152
+ MEDIA_ROOT = Path(os.environ.get("MEDIA_ROOT", default=BASE_DIR / "media")) # ty:ignore[no-matching-overload]
111
153
 
112
154
  # Static (for CSS etc)
113
155
  STATIC_URL = "/static/"
114
- STATIC_ROOT = Path("/config/staticfiles")
115
-
116
- # Internationalization
117
- # https://docs.djangoproject.com/en/5.2/topics/i18n/
156
+ STATIC_ROOT = Path("/tmp/staticfiles")
157
+ STATIC_URL = "static/"
118
158
 
119
- LANGUAGE_CODE = "en-us"
159
+ STATICFILES_DIRS = [BASE_DIR / "static", MEDIA_ROOT]
120
160
 
161
+ LANGUAGE_CODE = "en"
121
162
  TIME_ZONE = "UTC"
122
-
123
163
  USE_I18N = True
164
+ LANGUAGES = [
165
+ ("en", "English"),
166
+ ("nl", "Nederlands"),
167
+ ]
168
+
169
+ LOCALE_PATHS = [BASE_DIR / "locale"]
124
170
 
125
171
  USE_TZ = True
126
172
 
@@ -128,11 +174,32 @@ INTERNAL_IPS = [
128
174
  "127.0.0.1",
129
175
  ]
130
176
 
177
+ STORAGES = {
178
+ "default": {
179
+ "BACKEND": "django.core.files.storage.FileSystemStorage",
180
+ },
181
+ "staticfiles": {
182
+ "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
183
+ },
184
+ }
131
185
 
132
- # Static files (CSS, JavaScript, Images)
133
- # https://docs.djangoproject.com/en/5.2/howto/static-files/
134
-
135
- STATIC_URL = "static/"
186
+ EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
187
+ EMAIL_USE_TLS = os.environ.get("SMTP_USE_TLS")
188
+ EMAIL_HOST = os.environ.get("SMTP_HOST")
189
+ EMAIL_HOST_USER = os.environ.get("SMTP_USER")
190
+ EMAIL_HOST_PASSWORD = os.environ.get("SMTP_PASSWORD")
191
+ EMAIL_PORT = os.environ.get("SMTP_PORT")
192
+ EMAIL_FROM_ADDRESS = os.environ.get("SMTP_FROM_EMAIL")
193
+ SEND_EMAIL = all(
194
+ v is not None
195
+ for v in [
196
+ EMAIL_HOST,
197
+ EMAIL_HOST_USER,
198
+ EMAIL_HOST_PASSWORD,
199
+ EMAIL_PORT,
200
+ EMAIL_FROM_ADDRESS,
201
+ ]
202
+ )
136
203
 
137
204
  # Default primary key field type
138
205
  # https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
sandwitches/storage.py CHANGED
@@ -2,6 +2,9 @@ import hashlib
2
2
  import os
3
3
  from django.core.files.base import ContentFile
4
4
  from django.core.files.storage import FileSystemStorage
5
+ from pathlib import Path
6
+ from django.conf import settings
7
+ import logging
5
8
 
6
9
 
7
10
  class HashedFilenameStorage(FileSystemStorage):
@@ -11,19 +14,69 @@ class HashedFilenameStorage(FileSystemStorage):
11
14
  """
12
15
 
13
16
  def _save(self, name, content):
14
- # ensure we read bytes
15
17
  try:
16
18
  content.seek(0)
17
19
  except Exception:
18
20
  pass
19
21
 
20
22
  data = content.read()
21
- # compute hash (use first 32 hex chars to keep names shorter)
22
23
  h = hashlib.sha256(data).hexdigest()[:32]
23
24
  ext = os.path.splitext(name)[1].lower() or ""
24
- # store all recipe images under recipes/
25
25
  name = f"recipes/{h}{ext}"
26
26
 
27
- # wrap bytes into a ContentFile so Django storage works consistently
28
27
  content = ContentFile(data)
29
28
  return super()._save(name, content)
29
+
30
+
31
+ def is_database_readable(path=None) -> bool:
32
+ """
33
+ Check whether the database file exists and is readable.
34
+
35
+ If `path` is None, uses `django.conf.settings.DATABASE_FILE` by default.
36
+
37
+ Returns:
38
+ bool: True if the file exists and is readable by the current process, False otherwise.
39
+ """
40
+
41
+ if path is None:
42
+ path = getattr(settings, "DATABASE_FILE", None)
43
+
44
+ if not path:
45
+ return False
46
+
47
+ p = Path(path)
48
+ logging.debug(f"Checking database file readability at: {p}")
49
+ readable = p.is_file() and os.access(p, os.R_OK)
50
+ if not readable:
51
+ logging.error(f"Database file at {p} is not readable or does not exist.")
52
+ return False
53
+ else:
54
+ logging.debug(f"Database file at {p} is readable.")
55
+ return readable
56
+
57
+
58
+ def is_database_writable(path=None) -> bool:
59
+ """
60
+ Check whether the database file exists and is writable.
61
+
62
+ If `path` is None, uses `django.conf.settings.DATABASE_FILE` by default.
63
+
64
+ Returns:
65
+ bool: True if the file exists and is writable by the current process, False otherwise.
66
+ """
67
+
68
+ if path is None:
69
+ path = getattr(settings, "DATABASE_FILE", None)
70
+
71
+ if not path:
72
+ return False
73
+
74
+ p = Path(path)
75
+ logging.debug(f"Checking database file writability at: {p}")
76
+ writable = p.is_file() and os.access(p, os.W_OK)
77
+ if not writable:
78
+ logging.error(f"Database file at {p} is not writable or does not exist.")
79
+ return False
80
+ else:
81
+ logging.debug(f"Database file at {p} is writable.")
82
+ return writable
sandwitches/tasks.py CHANGED
@@ -1,16 +1,99 @@
1
+ # from gunicorn.http.wsgi import log
1
2
  import logging
2
- from django.core.mail import send_mail
3
- from django.tasks import task
4
3
 
4
+ # from django.core.mail import send_mail
5
+ from django_tasks import task
6
+ from django.contrib.auth import get_user_model
5
7
 
6
- logger = logging.getLogger(__name__)
8
+ from django.core.mail import EmailMultiAlternatives
9
+ from django.conf import settings
10
+
11
+
12
+ import textwrap
7
13
 
8
14
 
9
15
  @task(takes_context=True, priority=2, queue_name="emails")
10
- def email_users(context, emails, subject, message):
11
- logger.debug(
12
- f"Attempt {context.attempt} to send user email. Task result id: {context.task_result.id}."
16
+ def email_users(context, recipe_id):
17
+ logging.debug(
18
+ f"Attempt {context.attempt} to send users an email. Task result id: {context.task_result.id}."
13
19
  )
14
- return send_mail(
15
- subject=subject, message=message, from_email=None, recipient_list=emails
20
+
21
+ User = get_user_model()
22
+ emails = list(
23
+ User.objects.exclude(email__isnull=True)
24
+ .exclude(email="")
25
+ .values_list("email", flat=True)
26
+ )
27
+
28
+ if not emails:
29
+ logging.warning("No users with valid emails found.")
30
+ return 0
31
+
32
+ send_emails(recipe_id, emails)
33
+
34
+ return True
35
+
36
+
37
+ def send_emails(recipe_id, emails):
38
+ from .models import Recipe
39
+
40
+ logging.debug(f"Preparing to send email to: {emails}")
41
+ recipe = Recipe.objects.get(pk=recipe_id) # ty:ignore[unresolved-attribute]
42
+ from_email = getattr(settings, "EMAIL_FROM_ADDRESS")
43
+
44
+ recipe_slug = recipe.get_absolute_url()
45
+ base_url = (
46
+ settings.CSRF_TRUSTED_ORIGINS[0]
47
+ if settings.CSRF_TRUSTED_ORIGINS
48
+ else "http://localhost"
49
+ ).rstrip("/")
50
+
51
+ raw_message = f"""
52
+ Hungry? We just added <strong>{recipe.title}</strong> to our collection.
53
+
54
+ It's a delicious recipe that you won't want to miss!
55
+ {recipe.description}
56
+
57
+ Check out the full recipe, ingredients, and steps here:
58
+ {base_url}{recipe_slug}
59
+
60
+ Happy Cooking!
61
+
62
+ The Sandwitches Team
63
+ """
64
+ wrapped_message = textwrap.fill(textwrap.dedent(raw_message), width=70)
65
+
66
+ html_content = f"""
67
+ <div style="font-family: 'Helvetica', sans-serif; max-width: 600px; margin: auto; border: 1px solid #eee; padding: 20px;">
68
+ <h2 style="color: #d35400; text-align: center;">New Recipe: {recipe.title} by {recipe.uploaded_by}</h2>
69
+ <div style="text-align: center; margin: 20px 0;">
70
+ <img src="{base_url}{recipe.image.url}" alt="{recipe.title}" style="width: 100%; border-radius: 8px;">
71
+ </div>
72
+ <p style="font-size: 16px; line-height: 1.5; color: #333;">
73
+ Hungry? We just added <strong>{recipe.title}</strong> to our collection.
74
+ <br>
75
+ It's a delicious recipe that you won't want to miss!
76
+ <br>
77
+ {recipe.description}
78
+ <br>
79
+ Check out the full recipe, ingredients, and steps here:
80
+ Click the button below to see how to make it!
81
+ <br>
82
+ Happy Cooking!
83
+ <br>
84
+ The Sandwitches Team
85
+ </p>
86
+ <div style="text-align: center; margin-top: 30px;">
87
+ <a href="{base_url}{recipe_slug}" style="background-color: #e67e22; color: white; padding: 12px 25px; text-decoration: none; border-radius: 5px; font-weight: bold;">VIEW RECIPE</a>
88
+ </div>
89
+ </div>
90
+ """
91
+
92
+ msg = EmailMultiAlternatives(
93
+ subject=f"Sandwitches - New Recipe: {recipe.title} by {recipe.uploaded_by}",
94
+ body=wrapped_message,
95
+ from_email=from_email,
96
+ bbc=emails,
16
97
  )
98
+ msg.attach_alternative(html_content, "text/html")
99
+ msg.send()
@@ -1,5 +1,6 @@
1
+ {% load i18n %}
1
2
  <!DOCTYPE html>
2
- <html lang="en">
3
+ <html lang="{% get_current_language as LANGUAGE_CODE %}{{ LANGUAGE_CODE }}">
3
4
  <head>
4
5
  <meta charset="utf-8" />
5
6
  <meta name="viewport" content="width=device-width,initial-scale=1.0" />
@@ -1,4 +1,4 @@
1
- {% extends "base.html" %} {% block extra_head %}
1
+ {% extends "base.html" %} {% load i18n static %} {% block extra_head %}
2
2
  <!-- Pico.css (CDN) -->
3
3
  <link
4
4
  rel="stylesheet"
@@ -128,6 +128,7 @@
128
128
  }
129
129
  }
130
130
  </style>
131
+ <link rel="icon" type="image/svg+xml" href="{% static "icons/favicon.svg" %}">
131
132
  {% endblock %} {% block navbar %}
132
133
  <header class="container">
133
134
  <nav>
@@ -168,13 +169,26 @@
168
169
  </li>
169
170
  </ul>
170
171
  <ul>
171
- <li><a href="api/docs">Docs</a></li>
172
+ <li>
173
+ <form method="post" action="{% url 'set_language' %}">
174
+ {% csrf_token %}
175
+ <select name="language" onchange="this.form.submit()" aria-label="{% trans 'Language' %}">
176
+ {% get_current_language as LANGUAGE_CODE %}
177
+ {% get_available_languages as LANGUAGES %}
178
+ {% for code,name in LANGUAGES %}
179
+ <option value="{{ code }}"{% if code == LANGUAGE_CODE %} selected{% endif %}>{{ name }}</option>
180
+ {% endfor %}
181
+ </select>
182
+ <input type="hidden" name="next" value="{{ request.get_full_path }}" />
183
+ </form>
184
+ </li>
185
+ <li><a href="api/docs">{% trans "Docs" %}</a></li>
172
186
  {% if user.is_authenticated %}
173
- <li><a href="{% url 'admin:index' %}">Admin</a></li>
174
- <li><a href="{% url 'admin:logout' %}">Logout</a></li>
187
+ <li><a href="{% url 'admin:index' %}">{% trans "Admin" %}</a></li>
188
+ <li><a href="{% url 'admin:logout' %}">{% trans "Logout" %}</a></li>
175
189
  {% else %}
176
- <li><a href="{% url 'admin:login' %}">Login</a></li>
177
- <li><a href="{% url 'signup' %}">Sign up</a></li>
190
+ <li><a href="{% url 'admin:login' %}">{% trans "Login" %}</a></li>
191
+ <li><a href="{% url 'signup' %}">{% trans "Sign up" %}</a></li>
178
192
  {% endif %}
179
193
  </ul>
180
194
  </nav>
@@ -1,31 +1,50 @@
1
1
  {% extends "base_pico.html" %}
2
2
  {% block title %}{{ recipe.title }} — Sandwitch{% endblock %}
3
-
4
3
  {% block content %}
5
4
 
6
- {% load markdown_extras %}
5
+ {% load i18n markdown_extras %}
7
6
  <nav aria-label="breadcrumb" class="container">
8
- <a href="{% url 'index' %}">&larr; Back to all</a>
7
+ <a href="{% url 'index' %}">&larr; {% trans "Back to all" %}</a>
9
8
  </nav>
10
9
 
11
10
  <div class="grid">
12
11
  <article class="card">
13
12
  <figure>
14
13
  {% if recipe.image %}
15
- <img src="{{ recipe.image.url }}" alt="{{ recipe.title }}">
14
+ <img src="{{ recipe.image_large.url }}"
15
+ srcset="{{ recipe.image_medium.url }} 700w, {{ recipe.image_large.url }} 1200w"
16
+ sizes="(max-width: 768px) 95vw, 900px"
17
+ alt="{{ recipe.title }}"
18
+ loading="lazy">
16
19
  {% endif %}
17
20
  </figure>
21
+ {% trans "Description" %}
18
22
  <header>
19
23
  {{ recipe.title }}
24
+ {% if recipe.uploaded_by %}
25
+ <small>{% trans "Uploaded by" %}: {{ recipe.uploaded_by.get_full_name|default:recipe.uploaded_by.username }}</small>
26
+ {% endif %}
20
27
  </header>
21
- <h4>Description</h4>
22
- {{ recipe.description|convert_markdown|safe|default:"No description yet." }}
28
+ <h4>{% trans "Description" %}</h4>
29
+ {% if recipe.description %}
30
+ {{ recipe.description|convert_markdown|safe }}
31
+ {% else %}
32
+ <p>{% trans "No description yet." %}</p>
33
+ {% endif %}
23
34
 
24
- <h4>Ingredients</h4>
25
- {{ recipe.ingredients|convert_markdown|safe|default:"No ingredients listed." }}
35
+ <h4>{% trans "Ingredients" %}</h4>
36
+ {% if recipe.ingredients %}
37
+ {{ recipe.ingredients|convert_markdown|safe }}
38
+ {% else %}
39
+ <p>{% trans "No ingredients listed." %}</p>
40
+ {% endif %}
26
41
 
27
- <h4>Instructions</h4>
28
- {{ recipe.instructions|convert_markdown|safe|default:"No instructions yet." }}
42
+ <h4>{% trans "Instructions" %}</h4>
43
+ {% if recipe.instructions %}
44
+ {{ recipe.instructions|convert_markdown|safe }}
45
+ {% else %}
46
+ <p>{% trans "No instructions yet." %}</p>
47
+ {% endif %}
29
48
 
30
49
  <div class="tags-row">
31
50
  <div style="margin-top:12px;">
@@ -36,22 +55,22 @@
36
55
  </div>
37
56
 
38
57
  <div style="margin-top:1rem;">
39
- <h4>Rating</h4>
58
+ <h4>{% trans "Rating" %}</h4>
40
59
  {% if rating_count %}
41
- <p>Average: {{ avg_rating|floatformat:1 }} ({{ rating_count }} vote{% if rating_count %}s{% endif %})</p>
60
+ <p>{% trans "Average:" %} {{ avg_rating|floatformat:1 }} ({{ rating_count }} {% trans "vote" %}{% if rating_count %}s{% endif %})</p>
42
61
  {% else %}
43
- <p>No ratings yet.</p>
62
+ <p>{% trans "No ratings yet." %}</p>
44
63
  {% endif %}
45
64
 
46
65
  {% if user.is_authenticated %}
47
66
  {% if user_rating %}
48
- <p>Your rating: {{ user_rating.score }}</p>
67
+ <p>{% trans "Your rating:" %} {{ user_rating.score }}</p>
49
68
  {% else %}
50
69
  <form method="post" action="{% url 'recipe_rate' pk=recipe.pk %}">
51
70
  {% csrf_token %}
52
71
 
53
72
  <fieldset>
54
- <legend>What would you rate this sandwich?</legend>
73
+ <legend>{% trans "What would you rate this sandwich?" %}</legend>
55
74
 
56
75
  {% if rating_form.score.errors %}
57
76
  <div class="card-panel" role="alert">
@@ -64,33 +83,31 @@
64
83
  {% endif %}
65
84
 
66
85
  <div class="row">
67
- {% for i in "12345"|make_list %}
68
- <div class="col-sm-2">
69
- <label class="form-check">
70
- <input
71
- type="radio"
72
- name="{{ rating_form.score.name }}"
73
- id="rating-{{ i }}"
74
- value="{{ i }}"
75
- {% if user_rating and user_rating.score|stringformat:"s" == i %}
76
- checked
77
- {% elif rating_form.score.value == i %}
78
- checked
79
- {% endif %}
80
- />
81
- <span>{{ i }}</span>
82
- </label>
83
- </div>
84
- {% endfor %}
86
+ <div class="col-sm-12">
87
+ <label for="{{ rating_form.score.id_for_label }}">
88
+ Score (0-10): <span id="score-output">{{ rating_form.score.value|default:"5.0" }}</span>
89
+ </label>
90
+ <input
91
+ type="range"
92
+ name="{{ rating_form.score.name }}"
93
+ id="{{ rating_form.score.id_for_label }}"
94
+ min="0"
95
+ max="10"
96
+ step="0.1"
97
+ value="{{ rating_form.score.value|default:'5.0' }}"
98
+ oninput="document.getElementById('score-output').innerText = parseFloat(this.value).toFixed(1)"
99
+ style="width: 100%;"
100
+ >
101
+ </div>
85
102
  </div>
86
103
  </fieldset>
87
104
  <p style="margin-top:0.5rem;">
88
- <button type="submit">{% if user_rating %}Update{% else %}Rate{% endif %}</button>
105
+ <button type="submit">{% if user_rating %}{% trans "Update" %}{% else %}{% trans "Rate" %}{% endif %}</button>
89
106
  </p>
90
107
  </form>
91
108
  {% endif %}
92
109
  {% else %}
93
- <p><a href="{% url 'admin:login' %}">Log in</a> to rate this recipe.</p>
110
+ <p><a href="{% url 'admin:login' %}">{% trans "Log in" %}</a> {% trans "to rate this recipe." %}</p>
94
111
  {% endif %}
95
112
  </div>
96
113
 
@@ -2,14 +2,15 @@
2
2
  <html>
3
3
  <head>
4
4
  <meta charset="utf-8" />
5
- <title>Edit</title>
5
+ <title>{% trans "Edit" %}</title>
6
6
  </head>
7
7
  <body>
8
- <h1>Edit Recipe: {{ recipe.title }}</h1>
8
+ {% load i18n %}
9
+ <h1>{% trans "Edit Recipe:" %} {{ recipe.title }}</h1>
9
10
  <form method="post" enctype="multipart/form-data">
10
11
  {% csrf_token %} {{ form.as_p }}
11
- <button type="submit">Save</button>
12
- <a href="{% url 'recipes:admin_list' %}">Cancel</a>
12
+ <button type="submit">{% trans "Save" %}</button>
13
+ <a href="{% url 'recipes:admin_list' %}">{% trans "Cancel" %}</a>
13
14
  </form>
14
15
  </body>
15
16
  </html>
@@ -1,15 +1,15 @@
1
- {% extends "base_pico.html" %}
1
+ {% extends "base_pico.html" %} {% load i18n static %}
2
2
 
3
3
  {% block title %}Sandwitches{% endblock %}
4
4
 
5
5
  {% block content %}
6
6
  <div class="grid search-row">
7
7
  <div>
8
- <h4>Sandwitches: sandwiches so good, they haunt you!</h4>
8
+ <h4>{% trans 'Sandwitches: sandwiches so good, they haunt you!' %}</h4>
9
9
  </div>
10
10
  <div>
11
11
  <form role="search" onsubmit="return false;">
12
- <input id="search" type="search" placeholder="Search by title or tag" aria-label="Search">
12
+ <input id="search" type="search" placeholder="{% trans "Search by title or tag" %}" aria-label="{% trans "Search" %}">
13
13
  </form>
14
14
  </div>
15
15
  </div>
@@ -19,7 +19,11 @@
19
19
  <article class="card" onclick="location.href='{% url 'recipe_detail' recipe.slug %}';" style="cursor:pointer;">
20
20
  {% if recipe.image %}
21
21
  <figure class="recipe-figure">
22
- <img src="{{ recipe.image.url }}" alt="{{ recipe.title }}">
22
+ <img src="{{ recipe.image_medium.url }}"
23
+ srcset="{{ recipe.image_thumbnail.url }} 150w, {{ recipe.image_medium.url }} 700w"
24
+ sizes="(max-width: 640px) 90vw, 45vw"
25
+ alt="{{ recipe.title }}"
26
+ loading="lazy">
23
27
  </figure>
24
28
  {% endif %}
25
29
 
@@ -35,7 +39,7 @@
35
39
 
36
40
  {% if user.is_authenticated and user.is_staff %}
37
41
  <footer>
38
- <a href="/admin/sandwitches/recipe/{{ recipe.pk }}/change/" rel="noopener">Edit</a>
42
+ <a href="/admin/sandwitches/recipe/{{ recipe.pk }}/change/" rel="noopener">{% trans "Edit" %}</a>
39
43
  </footer>
40
44
  {% endif %}
41
45
  </article>
@@ -43,7 +47,7 @@
43
47
  {% empty %}
44
48
  <article class="card">
45
49
  <div class="card-body">
46
- <p>No sandwitches yet, please stay tuned.</p>
50
+ <p>{% trans "No sandwitches yet, please stay tuned." %}</p>
47
51
  </div>
48
52
  </article>
49
53
  {% endfor %}