sandwitches 2.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/__init__.py +6 -0
- sandwitches/admin.py +69 -0
- sandwitches/api.py +207 -0
- sandwitches/asgi.py +16 -0
- sandwitches/feeds.py +23 -0
- sandwitches/forms.py +196 -0
- sandwitches/locale/nl/LC_MESSAGES/django.mo +0 -0
- sandwitches/locale/nl/LC_MESSAGES/django.po +1010 -0
- sandwitches/migrations/0001_initial.py +328 -0
- sandwitches/migrations/0002_historicalrecipe_servings_recipe_servings.py +27 -0
- sandwitches/migrations/0003_setting.py +35 -0
- sandwitches/migrations/0004_alter_setting_ai_api_key_and_more.py +37 -0
- sandwitches/migrations/0005_rating_comment.py +17 -0
- sandwitches/migrations/0006_historicalrecipe_is_highlighted_and_more.py +22 -0
- sandwitches/migrations/__init__.py +0 -0
- sandwitches/models.py +218 -0
- sandwitches/settings.py +220 -0
- sandwitches/storage.py +114 -0
- sandwitches/tasks.py +115 -0
- sandwitches/templates/admin/admin_base.html +118 -0
- sandwitches/templates/admin/confirm_delete.html +23 -0
- sandwitches/templates/admin/dashboard.html +262 -0
- sandwitches/templates/admin/rating_list.html +38 -0
- sandwitches/templates/admin/recipe_form.html +184 -0
- sandwitches/templates/admin/recipe_list.html +64 -0
- sandwitches/templates/admin/tag_form.html +30 -0
- sandwitches/templates/admin/tag_list.html +37 -0
- sandwitches/templates/admin/task_detail.html +91 -0
- sandwitches/templates/admin/task_list.html +41 -0
- sandwitches/templates/admin/user_form.html +37 -0
- sandwitches/templates/admin/user_list.html +60 -0
- sandwitches/templates/base.html +94 -0
- sandwitches/templates/base_beer.html +57 -0
- sandwitches/templates/components/carousel_scripts.html +59 -0
- sandwitches/templates/components/favorites_search_form.html +85 -0
- sandwitches/templates/components/footer.html +14 -0
- sandwitches/templates/components/ingredients_scripts.html +50 -0
- sandwitches/templates/components/ingredients_section.html +11 -0
- sandwitches/templates/components/instructions_section.html +9 -0
- sandwitches/templates/components/language_dialog.html +26 -0
- sandwitches/templates/components/navbar.html +27 -0
- sandwitches/templates/components/rating_section.html +66 -0
- sandwitches/templates/components/recipe_header.html +32 -0
- sandwitches/templates/components/search_form.html +106 -0
- sandwitches/templates/components/search_scripts.html +98 -0
- sandwitches/templates/components/side_menu.html +35 -0
- sandwitches/templates/components/user_menu.html +10 -0
- sandwitches/templates/detail.html +178 -0
- sandwitches/templates/favorites.html +42 -0
- sandwitches/templates/index.html +76 -0
- sandwitches/templates/login.html +57 -0
- sandwitches/templates/partials/recipe_list.html +87 -0
- sandwitches/templates/recipe_form.html +119 -0
- sandwitches/templates/setup.html +105 -0
- sandwitches/templates/signup.html +133 -0
- sandwitches/templatetags/__init__.py +0 -0
- sandwitches/templatetags/custom_filters.py +15 -0
- sandwitches/templatetags/markdown_extras.py +17 -0
- sandwitches/urls.py +109 -0
- sandwitches/utils.py +222 -0
- sandwitches/views.py +647 -0
- sandwitches/wsgi.py +16 -0
- sandwitches-2.2.0.dist-info/METADATA +104 -0
- sandwitches-2.2.0.dist-info/RECORD +65 -0
- sandwitches-2.2.0.dist-info/WHEEL +4 -0
sandwitches/settings.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Django settings for sandwitches project.
|
|
3
|
+
|
|
4
|
+
Generated by 'django-admin startproject' using Django 5.2.7.
|
|
5
|
+
|
|
6
|
+
For more information on this file, see
|
|
7
|
+
https://docs.djangoproject.com/en/5.2/topics/settings/
|
|
8
|
+
|
|
9
|
+
For the full list of settings and their values, see
|
|
10
|
+
https://docs.djangoproject.com/en/5.2/ref/settings/
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
import os
|
|
15
|
+
|
|
16
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
17
|
+
from . import storage
|
|
18
|
+
|
|
19
|
+
DEBUG = bool(os.environ.get("DEBUG", default=0))
|
|
20
|
+
|
|
21
|
+
SECRET_KEY = os.environ.get("SECRET_KEY")
|
|
22
|
+
if not SECRET_KEY:
|
|
23
|
+
raise ImproperlyConfigured(
|
|
24
|
+
"The SECRET_KEY environment variable must be set in production."
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "127.0.0.1, localhost").split(",")
|
|
28
|
+
CSRF_TRUSTED_ORIGINS = os.environ.get("CSRF_TRUSTED_ORIGINS", "").split(",")
|
|
29
|
+
DATABASE_FILE = Path(os.environ.get("DATABASE_FILE", default="/db/db.sqlite3"))
|
|
30
|
+
|
|
31
|
+
storage.is_database_readable(DATABASE_FILE)
|
|
32
|
+
storage.is_database_writable(DATABASE_FILE)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
|
36
|
+
BASE_DIR = Path(__file__).resolve().parent.parent
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
TASKS = {
|
|
40
|
+
"default": {
|
|
41
|
+
"BACKEND": "django_tasks.backends.database.DatabaseBackend",
|
|
42
|
+
"QUEUES": ["default", "emails"],
|
|
43
|
+
"OPTIONS": {
|
|
44
|
+
"queues": {
|
|
45
|
+
"low_priority": {
|
|
46
|
+
"max_attempts": 5,
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"max_attempts": 10,
|
|
50
|
+
"backoff_factor": 3,
|
|
51
|
+
"purge": {"finished": "10 days", "unfinished": "20 days"},
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
# Application definition
|
|
57
|
+
INSTALLED_APPS = [
|
|
58
|
+
"django.contrib.admin",
|
|
59
|
+
"django.contrib.auth",
|
|
60
|
+
"django.contrib.contenttypes",
|
|
61
|
+
"django.contrib.sessions",
|
|
62
|
+
"django.contrib.messages",
|
|
63
|
+
"django.contrib.staticfiles",
|
|
64
|
+
"sandwitches",
|
|
65
|
+
"django_tasks",
|
|
66
|
+
"django_tasks.backends.database",
|
|
67
|
+
"debug_toolbar",
|
|
68
|
+
"imagekit",
|
|
69
|
+
"import_export",
|
|
70
|
+
"simple_history",
|
|
71
|
+
"solo",
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
MIDDLEWARE = [
|
|
75
|
+
"django.middleware.security.SecurityMiddleware",
|
|
76
|
+
"whitenoise.middleware.WhiteNoiseMiddleware",
|
|
77
|
+
"django.contrib.sessions.middleware.SessionMiddleware",
|
|
78
|
+
"django.middleware.locale.LocaleMiddleware",
|
|
79
|
+
"django.middleware.common.CommonMiddleware",
|
|
80
|
+
"django.middleware.csrf.CsrfViewMiddleware",
|
|
81
|
+
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
|
82
|
+
"django.contrib.messages.middleware.MessageMiddleware",
|
|
83
|
+
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
|
84
|
+
"debug_toolbar.middleware.DebugToolbarMiddleware",
|
|
85
|
+
"simple_history.middleware.HistoryRequestMiddleware",
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
ROOT_URLCONF = "sandwitches.urls"
|
|
89
|
+
|
|
90
|
+
TEMPLATES = [
|
|
91
|
+
{
|
|
92
|
+
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
|
93
|
+
"DIRS": [],
|
|
94
|
+
"APP_DIRS": True,
|
|
95
|
+
"OPTIONS": {
|
|
96
|
+
"context_processors": [
|
|
97
|
+
"django.template.context_processors.request",
|
|
98
|
+
"django.contrib.auth.context_processors.auth",
|
|
99
|
+
"django.contrib.messages.context_processors.messages",
|
|
100
|
+
"django.template.context_processors.csrf",
|
|
101
|
+
"django.template.context_processors.i18n",
|
|
102
|
+
],
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
WSGI_APPLICATION = "sandwitches.wsgi.application"
|
|
108
|
+
|
|
109
|
+
DATABASES = {
|
|
110
|
+
"default": {
|
|
111
|
+
"ENGINE": "django.db.backends.sqlite3",
|
|
112
|
+
"NAME": DATABASE_FILE,
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
LOGGING = {
|
|
117
|
+
"version": 1,
|
|
118
|
+
"disable_existing_loggers": False,
|
|
119
|
+
"handlers": {
|
|
120
|
+
"console": {
|
|
121
|
+
"class": "logging.StreamHandler",
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
"loggers": {
|
|
125
|
+
"django": {
|
|
126
|
+
"handlers": ["console"],
|
|
127
|
+
"level": os.getenv("LOG_LEVEL", "INFO"),
|
|
128
|
+
"propagate": False,
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
LOGIN_REDIRECT_URL = "index"
|
|
134
|
+
LOGOUT_REDIRECT_URL = "index"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# Password validation
|
|
138
|
+
# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
|
|
139
|
+
|
|
140
|
+
AUTH_USER_MODEL = "sandwitches.User"
|
|
141
|
+
|
|
142
|
+
AUTH_PASSWORD_VALIDATORS = [
|
|
143
|
+
{
|
|
144
|
+
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
|
|
154
|
+
},
|
|
155
|
+
]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# Media files (for uploaded images)
|
|
159
|
+
MEDIA_URL = "/media/"
|
|
160
|
+
MEDIA_ROOT = Path(os.environ.get("MEDIA_ROOT", default=BASE_DIR / "media"))
|
|
161
|
+
|
|
162
|
+
# Static (for CSS etc)
|
|
163
|
+
STATIC_URL = "/static/"
|
|
164
|
+
STATIC_ROOT = Path("/tmp/staticfiles")
|
|
165
|
+
STATICFILES_DIRS = [BASE_DIR / "static", MEDIA_ROOT]
|
|
166
|
+
|
|
167
|
+
LANGUAGE_CODE = "en"
|
|
168
|
+
TIME_ZONE = "UTC"
|
|
169
|
+
USE_I18N = True
|
|
170
|
+
LANGUAGES = [
|
|
171
|
+
("en", "English"),
|
|
172
|
+
("nl", "Nederlands"),
|
|
173
|
+
]
|
|
174
|
+
|
|
175
|
+
LOCALE_PATHS = [BASE_DIR / "locale"]
|
|
176
|
+
|
|
177
|
+
USE_TZ = True
|
|
178
|
+
|
|
179
|
+
# EU Date formats
|
|
180
|
+
DATE_FORMAT = "d/m/Y"
|
|
181
|
+
DATETIME_FORMAT = "d/m/Y H:i:s"
|
|
182
|
+
SHORT_DATE_FORMAT = "d/m/Y"
|
|
183
|
+
SHORT_DATETIME_FORMAT = "d/m/Y H:i"
|
|
184
|
+
|
|
185
|
+
INTERNAL_IPS = [
|
|
186
|
+
"127.0.0.1",
|
|
187
|
+
]
|
|
188
|
+
|
|
189
|
+
STORAGES = {
|
|
190
|
+
"default": {
|
|
191
|
+
"BACKEND": "django.core.files.storage.FileSystemStorage",
|
|
192
|
+
},
|
|
193
|
+
"staticfiles": {
|
|
194
|
+
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
|
|
195
|
+
},
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
|
|
200
|
+
EMAIL_USE_TLS = os.environ.get("SMTP_USE_TLS")
|
|
201
|
+
EMAIL_HOST = os.environ.get("SMTP_HOST")
|
|
202
|
+
EMAIL_HOST_USER = os.environ.get("SMTP_USER")
|
|
203
|
+
EMAIL_HOST_PASSWORD = os.environ.get("SMTP_PASSWORD")
|
|
204
|
+
EMAIL_PORT = os.environ.get("SMTP_PORT")
|
|
205
|
+
EMAIL_FROM_ADDRESS = os.environ.get("SMTP_FROM_EMAIL")
|
|
206
|
+
SEND_EMAIL = all(
|
|
207
|
+
v is not None
|
|
208
|
+
for v in [
|
|
209
|
+
EMAIL_HOST,
|
|
210
|
+
EMAIL_HOST_USER,
|
|
211
|
+
EMAIL_HOST_PASSWORD,
|
|
212
|
+
EMAIL_PORT,
|
|
213
|
+
EMAIL_FROM_ADDRESS,
|
|
214
|
+
]
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
# Default primary key field type
|
|
218
|
+
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
|
219
|
+
|
|
220
|
+
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
sandwitches/storage.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import os
|
|
3
|
+
from django.core.files.base import ContentFile
|
|
4
|
+
from django.core.files.storage import FileSystemStorage
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from django.conf import settings
|
|
7
|
+
import logging
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class HashedFilenameStorage(FileSystemStorage):
|
|
11
|
+
"""
|
|
12
|
+
Save uploaded files under a hash of their contents + original extension.
|
|
13
|
+
Example output: media/recipes/3f8a9d...png
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def _save(self, name, content):
|
|
17
|
+
try:
|
|
18
|
+
content.seek(0)
|
|
19
|
+
except Exception:
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
data = content.read()
|
|
23
|
+
h = hashlib.sha256(data).hexdigest()[:32]
|
|
24
|
+
ext = os.path.splitext(name)[1].lower() or ""
|
|
25
|
+
name = f"recipes/{h}{ext}"
|
|
26
|
+
|
|
27
|
+
content = ContentFile(data)
|
|
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
|
+
try:
|
|
50
|
+
if p.is_file():
|
|
51
|
+
with open(p, "r"): # Removed 'as f'
|
|
52
|
+
# If we can open it, it's readable. No need to read content.
|
|
53
|
+
pass
|
|
54
|
+
logging.debug(f"Database file at {p} is readable.")
|
|
55
|
+
return True
|
|
56
|
+
else:
|
|
57
|
+
logging.error(f"Database file at {p} does not exist.")
|
|
58
|
+
return False
|
|
59
|
+
except IOError:
|
|
60
|
+
logging.error(
|
|
61
|
+
f"Database file at {p} is not readable due to permission or other IO error."
|
|
62
|
+
)
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def is_database_writable(path=None) -> bool:
|
|
67
|
+
"""
|
|
68
|
+
Check whether the database file exists and is writable.
|
|
69
|
+
|
|
70
|
+
If `path` is None, uses `django.conf.settings.DATABASE_FILE` by default.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
bool: True if the file exists and is writable by the current process, False otherwise.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
if path is None:
|
|
77
|
+
path = getattr(settings, "DATABASE_FILE", None)
|
|
78
|
+
|
|
79
|
+
if not path:
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
p = Path(path)
|
|
83
|
+
logging.debug(f"Checking database file writability at: {p}")
|
|
84
|
+
try:
|
|
85
|
+
# If the file exists, try to open it for appending
|
|
86
|
+
if p.is_file():
|
|
87
|
+
with open(p, "a"): # Removed 'as f'
|
|
88
|
+
pass
|
|
89
|
+
logging.debug(f"Database file at {p} is writable.")
|
|
90
|
+
return True
|
|
91
|
+
else:
|
|
92
|
+
# If file does not exist, check if its parent directory is writable
|
|
93
|
+
if p.parent.is_dir() and os.access(p.parent, os.W_OK):
|
|
94
|
+
# Try creating a dummy file to confirm writability
|
|
95
|
+
dummy_file = p.parent / f".tmp_writable_test_{os.getpid()}"
|
|
96
|
+
try:
|
|
97
|
+
dummy_file.touch()
|
|
98
|
+
dummy_file.unlink()
|
|
99
|
+
logging.debug(f"Database path at {p.parent} is writable.")
|
|
100
|
+
return True
|
|
101
|
+
except IOError:
|
|
102
|
+
logging.error(f"Cannot create dummy file in {p.parent}.")
|
|
103
|
+
return False
|
|
104
|
+
else:
|
|
105
|
+
logging.error(
|
|
106
|
+
f"Parent directory {p.parent} is not writable or does not exist."
|
|
107
|
+
)
|
|
108
|
+
return False
|
|
109
|
+
|
|
110
|
+
except IOError:
|
|
111
|
+
logging.error(
|
|
112
|
+
f"Database file at {p} is not writable due to permission or other IO error."
|
|
113
|
+
)
|
|
114
|
+
return False
|
sandwitches/tasks.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# from gunicorn.http.wsgi import log
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
# from django.core.mail import send_mail
|
|
5
|
+
from django_tasks import task
|
|
6
|
+
from django.contrib.auth import get_user_model
|
|
7
|
+
|
|
8
|
+
from django.core.mail import EmailMultiAlternatives
|
|
9
|
+
from django.conf import settings
|
|
10
|
+
from django.utils.translation import gettext as _
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
import textwrap
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@task(takes_context=True, priority=2, queue_name="emails")
|
|
17
|
+
def email_users(context, recipe_id):
|
|
18
|
+
logging.debug(
|
|
19
|
+
f"Attempt {context.attempt} to send users an email. Task result id: {context.task_result.id}."
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
User = get_user_model()
|
|
23
|
+
emails = list(
|
|
24
|
+
User.objects.exclude(email__isnull=True)
|
|
25
|
+
.exclude(email="")
|
|
26
|
+
.values_list("email", flat=True)
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
if not emails:
|
|
30
|
+
logging.warning("No users with valid emails found.")
|
|
31
|
+
return 0
|
|
32
|
+
|
|
33
|
+
send_emails(recipe_id, emails)
|
|
34
|
+
|
|
35
|
+
return True
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def send_emails(recipe_id, emails):
|
|
39
|
+
from .models import Recipe
|
|
40
|
+
|
|
41
|
+
logging.debug(f"Preparing to send email to: {emails}")
|
|
42
|
+
recipe = Recipe.objects.get(pk=recipe_id) # ty:ignore[unresolved-attribute]
|
|
43
|
+
from_email = getattr(settings, "EMAIL_FROM_ADDRESS")
|
|
44
|
+
|
|
45
|
+
recipe_slug = recipe.get_absolute_url()
|
|
46
|
+
base_url = (
|
|
47
|
+
settings.CSRF_TRUSTED_ORIGINS[0]
|
|
48
|
+
if settings.CSRF_TRUSTED_ORIGINS
|
|
49
|
+
else "http://localhost"
|
|
50
|
+
).rstrip("/")
|
|
51
|
+
|
|
52
|
+
raw_message_fmt = _("""
|
|
53
|
+
Hungry? We just added <strong>%(title)s</strong> to our collection.
|
|
54
|
+
|
|
55
|
+
It's a delicious recipe that you won't want to miss!
|
|
56
|
+
%(description)s
|
|
57
|
+
|
|
58
|
+
Check out the full recipe, ingredients, and steps here:
|
|
59
|
+
%(url)s
|
|
60
|
+
|
|
61
|
+
Happy Cooking!
|
|
62
|
+
|
|
63
|
+
The Sandwitches Team
|
|
64
|
+
""")
|
|
65
|
+
|
|
66
|
+
context_data = {
|
|
67
|
+
"title": recipe.title,
|
|
68
|
+
"uploaded_by": recipe.uploaded_by,
|
|
69
|
+
"description": recipe.description,
|
|
70
|
+
"url": f"{base_url}{recipe_slug}",
|
|
71
|
+
"image_url": f"{base_url}{recipe.image.url}" if recipe.image else "",
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
wrapped_message = textwrap.fill(
|
|
75
|
+
textwrap.dedent(raw_message_fmt) % context_data, width=70
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
html_content_fmt = _("""
|
|
79
|
+
<div style="font-family: 'Helvetica', sans-serif; max-width: 600px; margin: auto; border: 1px solid #eee; padding: 20px;">
|
|
80
|
+
<h2 style="color: #d35400; text-align: center;">New Recipe: %(title)s by %(uploaded_by)s</h2>
|
|
81
|
+
<div style="text-align: center; margin: 20px 0;">
|
|
82
|
+
<img src="%(image_url)s" alt="%(title)s" style="width: 100%%; border-radius: 8px;">
|
|
83
|
+
</div>
|
|
84
|
+
<p style="font-size: 16px; line-height: 1.5; color: #333;">
|
|
85
|
+
Hungry? We just added <strong>%(title)s</strong> to our collection.
|
|
86
|
+
<br>
|
|
87
|
+
It's a delicious recipe that you won't want to miss!
|
|
88
|
+
<br>
|
|
89
|
+
%(description)s
|
|
90
|
+
<br>
|
|
91
|
+
Check out the full recipe, ingredients, and steps here:
|
|
92
|
+
Click the button below to see how to make it!
|
|
93
|
+
<br>
|
|
94
|
+
Happy Cooking!
|
|
95
|
+
<br>
|
|
96
|
+
The Sandwitches Team
|
|
97
|
+
</p>
|
|
98
|
+
<div style="text-align: center; margin-top: 30px;">
|
|
99
|
+
<a href="%(url)s" style="background-color: #e67e22; color: white; padding: 12px 25px; text-decoration: none; border-radius: 5px; font-weight: bold;">VIEW RECIPE</a>
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
""")
|
|
103
|
+
|
|
104
|
+
html_content = html_content_fmt % context_data
|
|
105
|
+
|
|
106
|
+
subject = _("Sandwitches - New Recipe: %(title)s by %(uploaded_by)s") % context_data
|
|
107
|
+
for email in emails:
|
|
108
|
+
msg = EmailMultiAlternatives(
|
|
109
|
+
subject=subject,
|
|
110
|
+
body=wrapped_message,
|
|
111
|
+
from_email=from_email,
|
|
112
|
+
to=[email],
|
|
113
|
+
)
|
|
114
|
+
msg.attach_alternative(html_content, "text/html")
|
|
115
|
+
msg.send()
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
{% extends "base.html" %}
|
|
2
|
+
{% load i18n static %}
|
|
3
|
+
|
|
4
|
+
{% block extra_head %}
|
|
5
|
+
<link href="https://cdn.jsdelivr.net/npm/beercss@3.7.12/dist/cdn/beer.min.css" rel="stylesheet">
|
|
6
|
+
<script type="module" src="https://cdn.jsdelivr.net/npm/beercss@3.7.12/dist/cdn/beer.min.js"></script>
|
|
7
|
+
<script type="module" src="https://cdn.jsdelivr.net/npm/material-dynamic-colors@1.1.2/dist/cdn/material-dynamic-colors.min.js"></script>
|
|
8
|
+
<script src="https://unpkg.com/htmx.org@2.0.8/dist/htmx.min.js"></script>
|
|
9
|
+
<link rel="icon" type="image/svg+xml" href="{% static "icons/favicon.svg" %}">
|
|
10
|
+
<style>
|
|
11
|
+
main.container {
|
|
12
|
+
padding-top: 2rem;
|
|
13
|
+
padding-bottom: 2rem;
|
|
14
|
+
}
|
|
15
|
+
.admin-thumb {
|
|
16
|
+
width: 80px;
|
|
17
|
+
height: 80px;
|
|
18
|
+
object-fit: cover;
|
|
19
|
+
}
|
|
20
|
+
</style>
|
|
21
|
+
{% endblock %}
|
|
22
|
+
|
|
23
|
+
{% block navbar %}
|
|
24
|
+
<nav class="top primary-container">
|
|
25
|
+
<button class="circle transparent" data-ui="#admin-menu">
|
|
26
|
+
<i>menu</i>
|
|
27
|
+
</button>
|
|
28
|
+
<a href="{% url 'admin_dashboard' %}" class="row align-center">
|
|
29
|
+
<img src="{% static 'icons/icon.svg' %}" class="circle small">
|
|
30
|
+
<h6 class="max">{% block admin_title %}{% trans "Sandwitches Admin" %}{% endblock %}</h6>
|
|
31
|
+
</a>
|
|
32
|
+
<div class="max"></div>
|
|
33
|
+
<button class="circle transparent" onclick="toggleMode()">
|
|
34
|
+
<i>dark_mode</i>
|
|
35
|
+
</button>
|
|
36
|
+
|
|
37
|
+
{% if user.avatar %}
|
|
38
|
+
<img src="{{ user.avatar.url }}" class="circle" data-ui="#user-menu">
|
|
39
|
+
{% else %}
|
|
40
|
+
<img src="https://www.w3schools.com/howto/img_avatar.png" class="circle" data-ui="#user-menu">
|
|
41
|
+
{% endif %}
|
|
42
|
+
</nav>
|
|
43
|
+
|
|
44
|
+
<dialog id="admin-menu" class="left">
|
|
45
|
+
<nav class="drawer vertical">
|
|
46
|
+
<header>
|
|
47
|
+
<img src="{% static 'icons/icon.svg' %}" class="circle large">
|
|
48
|
+
<h5 class="max">{% trans "Admin" %}</h5>
|
|
49
|
+
</header>
|
|
50
|
+
<div class="space"></div>
|
|
51
|
+
<a href="{% url 'admin_dashboard' %}" class="{% if request.resolver_match.url_name == 'admin_dashboard' %}active{% endif %}">
|
|
52
|
+
<i>dashboard</i>
|
|
53
|
+
<span>{% trans "Dashboard" %}</span>
|
|
54
|
+
</a>
|
|
55
|
+
<a href="{% url 'admin_recipe_list' %}" class="{% if request.resolver_match.url_name == 'admin_recipe_list' %}active{% endif %}">
|
|
56
|
+
<i>restaurant</i>
|
|
57
|
+
<span>{% trans "Recipes" %}</span>
|
|
58
|
+
</a>
|
|
59
|
+
<a href="{% url 'admin_user_list' %}" class="{% if request.resolver_match.url_name == 'admin_user_list' %}active{% endif %}">
|
|
60
|
+
<i>people</i>
|
|
61
|
+
<span>{% trans "Users" %}</span>
|
|
62
|
+
</a>
|
|
63
|
+
<a href="{% url 'admin_tag_list' %}" class="{% if request.resolver_match.url_name == 'admin_tag_list' %}active{% endif %}">
|
|
64
|
+
<i>label</i>
|
|
65
|
+
<span>{% trans "Tags" %}</span>
|
|
66
|
+
</a>
|
|
67
|
+
<a href="{% url 'admin_rating_list' %}" class="{% if request.resolver_match.url_name == 'admin_rating_list' %}active{% endif %}">
|
|
68
|
+
<i>star</i>
|
|
69
|
+
<span>{% trans "Ratings" %}</span>
|
|
70
|
+
</a>
|
|
71
|
+
<a href="{% url 'admin_task_list' %}" class="{% if request.resolver_match.url_name == 'admin_task_list' %}active{% endif %}">
|
|
72
|
+
<i>assignment</i>
|
|
73
|
+
<span>{% trans "Tasks" %}</span>
|
|
74
|
+
</a>
|
|
75
|
+
<div class="divider"></div>
|
|
76
|
+
<a href="{% url 'index' %}">
|
|
77
|
+
<i>home</i>
|
|
78
|
+
<span>{% trans "Public Site" %}</span>
|
|
79
|
+
</a>
|
|
80
|
+
</nav>
|
|
81
|
+
</dialog>
|
|
82
|
+
|
|
83
|
+
{% if user.is_authenticated %}
|
|
84
|
+
<menu id="user-menu" class="no-wrap left">
|
|
85
|
+
<a href="{% url 'admin:logout' %}" class="row"><i>logout</i>{% trans "Logout" %}</a>
|
|
86
|
+
</menu>
|
|
87
|
+
{% endif %}
|
|
88
|
+
|
|
89
|
+
{% if messages %}
|
|
90
|
+
<div class="padding">
|
|
91
|
+
{% for message in messages %}
|
|
92
|
+
<div class="snackbar active {% if message.tags == 'error' %}error{% else %}primary{% endif %}">
|
|
93
|
+
<i>{% if message.tags == 'error' %}error{% else %}info{% endif %}</i>
|
|
94
|
+
<span>{{ message }}</span>
|
|
95
|
+
</div>
|
|
96
|
+
{% endfor %}
|
|
97
|
+
</div>
|
|
98
|
+
{% endif %}
|
|
99
|
+
{% endblock %}
|
|
100
|
+
|
|
101
|
+
{% block footer %}
|
|
102
|
+
<footer class="padding">
|
|
103
|
+
<div class="row align-center">
|
|
104
|
+
<div class="max">
|
|
105
|
+
<h6 class="small">Sandwitches Admin</h6>
|
|
106
|
+
<div class="small-text">© {% now "Y" %} Sandwitches Inc.</div>
|
|
107
|
+
</div>
|
|
108
|
+
<nav>
|
|
109
|
+
version {{ version }}
|
|
110
|
+
</nav>
|
|
111
|
+
</div>
|
|
112
|
+
</footer>
|
|
113
|
+
{% endblock %}
|
|
114
|
+
|
|
115
|
+
{% block extra_scripts %}
|
|
116
|
+
{{ block.super }}
|
|
117
|
+
{% block admin_scripts %}{% endblock %}
|
|
118
|
+
{% endblock %}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{% extends "admin/admin_base.html" %}
|
|
2
|
+
{% load i18n %}
|
|
3
|
+
|
|
4
|
+
{% block admin_title %}{% trans "Confirm Delete" %}{% endblock %}
|
|
5
|
+
|
|
6
|
+
{% block content %}
|
|
7
|
+
<article class="round padding center-align">
|
|
8
|
+
<i class="extra error-text">warning</i>
|
|
9
|
+
<h5>{% trans "Are you sure?" %}</h5>
|
|
10
|
+
<p>{% trans "You are about to delete the following" %} {{ type }}: <b>{{ object }}</b></p>
|
|
11
|
+
<p>{% trans "This action cannot be undone." %}</p>
|
|
12
|
+
|
|
13
|
+
<div class="space"></div>
|
|
14
|
+
|
|
15
|
+
<form method="post">
|
|
16
|
+
{% csrf_token %}
|
|
17
|
+
<nav class="center-align">
|
|
18
|
+
<button type="button" class="button transparent" onclick="history.back()">{% trans "Cancel" %}</button>
|
|
19
|
+
<button type="submit" class="button error round">{% trans "Yes, Delete" %}</button>
|
|
20
|
+
</nav>
|
|
21
|
+
</form>
|
|
22
|
+
</article>
|
|
23
|
+
{% endblock %}
|