sandwitches 1.1.0__py3-none-any.whl → 1.2.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/admin.py +22 -2
- sandwitches/api.py +27 -10
- sandwitches/locale/nl/LC_MESSAGES/django.mo +0 -0
- sandwitches/locale/nl/LC_MESSAGES/django.po +139 -0
- sandwitches/migrations/0004_add_uploaded_by.py +25 -0
- sandwitches/migrations/0005_historicalrecipe_uploaded_by.py +27 -0
- sandwitches/migrations/0006_profile.py +48 -0
- sandwitches/models.py +90 -20
- sandwitches/settings.py +89 -22
- sandwitches/storage.py +57 -4
- sandwitches/tasks.py +91 -8
- sandwitches/templates/base.html +2 -1
- sandwitches/templates/base_pico.html +20 -6
- sandwitches/templates/detail.html +36 -17
- sandwitches/templates/form.html +5 -4
- sandwitches/templates/index.html +10 -6
- sandwitches/templates/setup.html +9 -7
- sandwitches/templates/signup.html +11 -11
- sandwitches/urls.py +12 -12
- sandwitches/views.py +37 -10
- {sandwitches-1.1.0.dist-info → sandwitches-1.2.0.dist-info}/METADATA +5 -2
- sandwitches-1.2.0.dist-info/RECORD +33 -0
- sandwitches-1.1.0.dist-info/RECORD +0 -28
- {sandwitches-1.1.0.dist-info → sandwitches-1.2.0.dist-info}/WHEEL +0 -0
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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
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
|
|
@@ -111,16 +153,20 @@ MEDIA_ROOT = Path("/config/media")
|
|
|
111
153
|
|
|
112
154
|
# Static (for CSS etc)
|
|
113
155
|
STATIC_URL = "/static/"
|
|
114
|
-
STATIC_ROOT = Path("/
|
|
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
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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,
|
|
11
|
-
|
|
12
|
-
f"Attempt {context.attempt} to send
|
|
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
|
-
|
|
15
|
-
|
|
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()
|
sandwitches/templates/base.html
CHANGED
|
@@ -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
|
|
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' %}">← Back to all</a>
|
|
7
|
+
<a href="{% url 'index' %}">← {% 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.
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
|
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
|
|
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">
|
|
@@ -85,12 +104,12 @@
|
|
|
85
104
|
</div>
|
|
86
105
|
</fieldset>
|
|
87
106
|
<p style="margin-top:0.5rem;">
|
|
88
|
-
<button type="submit">{% if user_rating %}Update{% else %}Rate{% endif %}</button>
|
|
107
|
+
<button type="submit">{% if user_rating %}{% trans "Update" %}{% else %}{% trans "Rate" %}{% endif %}</button>
|
|
89
108
|
</p>
|
|
90
109
|
</form>
|
|
91
110
|
{% endif %}
|
|
92
111
|
{% else %}
|
|
93
|
-
<p><a href="{% url 'admin:login' %}">Log in</a> to rate this recipe
|
|
112
|
+
<p><a href="{% url 'admin:login' %}">{% trans "Log in" %}</a> {% trans "to rate this recipe." %}</p>
|
|
94
113
|
{% endif %}
|
|
95
114
|
</div>
|
|
96
115
|
|
sandwitches/templates/form.html
CHANGED
|
@@ -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
|
-
|
|
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>
|
sandwitches/templates/index.html
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
|
50
|
+
<p>{% trans "No sandwitches yet, please stay tuned." %}</p>
|
|
47
51
|
</div>
|
|
48
52
|
</article>
|
|
49
53
|
{% endfor %}
|
sandwitches/templates/setup.html
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
{% extends "base_pico.html" %}
|
|
2
|
-
{% load static %}
|
|
3
|
-
{% block title %}Initial setup — Create admin{% endblock %}
|
|
2
|
+
{% load static i18n %}
|
|
3
|
+
{% block title %}{% trans "Initial setup — Create admin" %}{% endblock %}
|
|
4
4
|
|
|
5
5
|
{% block content %}
|
|
6
6
|
<div class="container" style="max-width:720px; margin:2rem auto;">
|
|
7
7
|
<article class="card">
|
|
8
8
|
<div class="card-body">
|
|
9
|
-
<h2>Create initial administrator</h2>
|
|
9
|
+
<h2>{% trans "Create initial administrator" %}</h2>
|
|
10
10
|
<p>
|
|
11
|
-
This page is only available when there are no admin users in the database.
|
|
12
|
-
|
|
11
|
+
{% trans "This page is only available when there are no admin users in the database." %}
|
|
12
|
+
</p>
|
|
13
|
+
<p>
|
|
14
|
+
{% trans "After creating the account you will be logged in and redirected to the admin." %}
|
|
13
15
|
</p>
|
|
14
16
|
|
|
15
17
|
<form method="post" novalidate>
|
|
@@ -43,8 +45,8 @@
|
|
|
43
45
|
{{ form.password2 }}
|
|
44
46
|
|
|
45
47
|
<p style="margin-top:1rem;">
|
|
46
|
-
<button type="submit">Create admin</button>
|
|
47
|
-
<a class="contrast" href="{% url 'index' %}">Cancel</a>
|
|
48
|
+
<button type="submit">{% trans "Create admin" %}</button>
|
|
49
|
+
<a class="contrast" href="{% url 'index' %}">{% trans "Cancel" %}</a>
|
|
48
50
|
</p>
|
|
49
51
|
</form>
|
|
50
52
|
</div>
|