sandwitches 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sandwitches-0.1.0/PKG-INFO +21 -0
- sandwitches-0.1.0/README.md +3 -0
- sandwitches-0.1.0/pyproject.toml +42 -0
- sandwitches-0.1.0/src/sandwitches/__init__.py +0 -0
- sandwitches-0.1.0/src/sandwitches/admin.py +6 -0
- sandwitches-0.1.0/src/sandwitches/asgi.py +16 -0
- sandwitches-0.1.0/src/sandwitches/forms.py +60 -0
- sandwitches-0.1.0/src/sandwitches/migrations/0001_initial.py +75 -0
- sandwitches-0.1.0/src/sandwitches/migrations/0002_historicalrecipe.py +61 -0
- sandwitches-0.1.0/src/sandwitches/migrations/__init__.py +0 -0
- sandwitches-0.1.0/src/sandwitches/models.py +94 -0
- sandwitches-0.1.0/src/sandwitches/settings.py +134 -0
- sandwitches-0.1.0/src/sandwitches/storage.py +29 -0
- sandwitches-0.1.0/src/sandwitches/templates/base.html +14 -0
- sandwitches-0.1.0/src/sandwitches/templates/base_pico.html +244 -0
- sandwitches-0.1.0/src/sandwitches/templates/detail.html +44 -0
- sandwitches-0.1.0/src/sandwitches/templates/form.html +15 -0
- sandwitches-0.1.0/src/sandwitches/templates/index.html +71 -0
- sandwitches-0.1.0/src/sandwitches/templates/setup.html +53 -0
- sandwitches-0.1.0/src/sandwitches/templatetags/__init__.py +0 -0
- sandwitches-0.1.0/src/sandwitches/templatetags/markdown_extras.py +17 -0
- sandwitches-0.1.0/src/sandwitches/urls.py +45 -0
- sandwitches-0.1.0/src/sandwitches/views.py +58 -0
- sandwitches-0.1.0/src/sandwitches/wsgi.py +16 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: sandwitches
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Author: Martyn van Dijke
|
|
6
|
+
Author-email: Martyn van Dijke <martijnvdijke600@gmail.com>
|
|
7
|
+
Requires-Dist: django-debug-toolbar>=6.1.0
|
|
8
|
+
Requires-Dist: django-filter>=25.2
|
|
9
|
+
Requires-Dist: django-simple-history>=3.10.1
|
|
10
|
+
Requires-Dist: django>=5.2.7
|
|
11
|
+
Requires-Dist: djangorestframework>=3.16.1
|
|
12
|
+
Requires-Dist: gunicorn>=23.0.0
|
|
13
|
+
Requires-Dist: markdown>=3.10
|
|
14
|
+
Requires-Dist: pillow>=12.0.0
|
|
15
|
+
Requires-Dist: uwsgi>=2.0.31
|
|
16
|
+
Requires-Python: >=3.12
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# broodjes.vandijke.xyz
|
|
20
|
+
|
|
21
|
+
Website files for broodjes.vandijke.xyz
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "sandwitches"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Add your description here"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Martyn van Dijke", email = "martijnvdijke600@gmail.com" }
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
|
|
12
|
+
dependencies = [
|
|
13
|
+
"django-debug-toolbar>=6.1.0",
|
|
14
|
+
"django-filter>=25.2",
|
|
15
|
+
"django-simple-history>=3.10.1",
|
|
16
|
+
"django>=5.2.7",
|
|
17
|
+
"djangorestframework>=3.16.1",
|
|
18
|
+
"gunicorn>=23.0.0",
|
|
19
|
+
"markdown>=3.10",
|
|
20
|
+
"pillow>=12.0.0",
|
|
21
|
+
"uwsgi>=2.0.31",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
[build-system]
|
|
26
|
+
requires = ["uv_build>=0.8.19,<0.9.0"]
|
|
27
|
+
build-backend = "uv_build"
|
|
28
|
+
|
|
29
|
+
[dependency-groups]
|
|
30
|
+
dev = [
|
|
31
|
+
"invoke>=2.2.1",
|
|
32
|
+
"pytest-django>=4.11.1",
|
|
33
|
+
"pytest>=8.4.2",
|
|
34
|
+
"ruff>=0.14.3",
|
|
35
|
+
"ty>=0.0.1a26",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[tool.uv]
|
|
39
|
+
package = true
|
|
40
|
+
|
|
41
|
+
[tool.pytest.ini_options]
|
|
42
|
+
DJANGO_SETTINGS_MODULE = "sandwitches.settings"
|
|
File without changes
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ASGI config for sandwitches project.
|
|
3
|
+
|
|
4
|
+
It exposes the ASGI callable as a module-level variable named ``application``.
|
|
5
|
+
|
|
6
|
+
For more information on this file, see
|
|
7
|
+
https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
from django.core.asgi import get_asgi_application
|
|
13
|
+
|
|
14
|
+
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "sandwitches.settings")
|
|
15
|
+
|
|
16
|
+
application = get_asgi_application()
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from django import forms
|
|
2
|
+
from django.contrib.auth import get_user_model
|
|
3
|
+
from .models import Recipe
|
|
4
|
+
|
|
5
|
+
User = get_user_model()
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AdminSetupForm(forms.Form):
|
|
9
|
+
username = forms.CharField(max_length=150, label="Username")
|
|
10
|
+
email = forms.EmailField(required=False, label="Email (optional)")
|
|
11
|
+
password1 = forms.CharField(label="Password", widget=forms.PasswordInput)
|
|
12
|
+
password2 = forms.CharField(label="Confirm password", widget=forms.PasswordInput)
|
|
13
|
+
first_name = forms.CharField(max_length=30, required=False, label="First name")
|
|
14
|
+
last_name = forms.CharField(max_length=150, required=False, label="Last name")
|
|
15
|
+
|
|
16
|
+
def clean_username(self):
|
|
17
|
+
username = self.cleaned_data.get("username")
|
|
18
|
+
if User.objects.filter(username=username).exists():
|
|
19
|
+
raise forms.ValidationError("A user with that username already exists.")
|
|
20
|
+
return username
|
|
21
|
+
|
|
22
|
+
def clean(self):
|
|
23
|
+
cleaned = super().clean()
|
|
24
|
+
p1 = cleaned.get("password1")
|
|
25
|
+
p2 = cleaned.get("password2")
|
|
26
|
+
if p1 and p2 and p1 != p2:
|
|
27
|
+
raise forms.ValidationError("Passwords do not match.")
|
|
28
|
+
return cleaned
|
|
29
|
+
|
|
30
|
+
def save(self):
|
|
31
|
+
data = self.cleaned_data
|
|
32
|
+
user = User.objects.create_superuser(
|
|
33
|
+
username=data["username"],
|
|
34
|
+
email=data.get("email") or "",
|
|
35
|
+
password=data["password1"],
|
|
36
|
+
)
|
|
37
|
+
# set optional names
|
|
38
|
+
if data.get("first_name"):
|
|
39
|
+
user.first_name = data["first_name"]
|
|
40
|
+
if data.get("last_name"):
|
|
41
|
+
user.last_name = data["last_name"]
|
|
42
|
+
user.is_staff = True
|
|
43
|
+
user.save()
|
|
44
|
+
return user
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class RecipeForm(forms.ModelForm):
|
|
48
|
+
class Meta:
|
|
49
|
+
model = Recipe
|
|
50
|
+
fields = [
|
|
51
|
+
"title",
|
|
52
|
+
"description",
|
|
53
|
+
"ingredients",
|
|
54
|
+
"instructions",
|
|
55
|
+
"image",
|
|
56
|
+
"tags",
|
|
57
|
+
]
|
|
58
|
+
widgets = {
|
|
59
|
+
"tags": forms.TextInput(attrs={"placeholder": "tag1,tag2"}),
|
|
60
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Generated by Django 5.2.8 on 2025-11-05 19:15
|
|
2
|
+
|
|
3
|
+
import sandwitches.storage
|
|
4
|
+
from django.db import migrations, models
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Migration(migrations.Migration):
|
|
8
|
+
initial = True
|
|
9
|
+
|
|
10
|
+
dependencies = []
|
|
11
|
+
|
|
12
|
+
operations = [
|
|
13
|
+
migrations.CreateModel(
|
|
14
|
+
name="Tag",
|
|
15
|
+
fields=[
|
|
16
|
+
(
|
|
17
|
+
"id",
|
|
18
|
+
models.BigAutoField(
|
|
19
|
+
auto_created=True,
|
|
20
|
+
primary_key=True,
|
|
21
|
+
serialize=False,
|
|
22
|
+
verbose_name="ID",
|
|
23
|
+
),
|
|
24
|
+
),
|
|
25
|
+
("name", models.CharField(max_length=50, unique=True)),
|
|
26
|
+
("slug", models.SlugField(blank=True, max_length=60, unique=True)),
|
|
27
|
+
],
|
|
28
|
+
options={
|
|
29
|
+
"verbose_name": "Tag",
|
|
30
|
+
"verbose_name_plural": "Tags",
|
|
31
|
+
"ordering": ("name",),
|
|
32
|
+
},
|
|
33
|
+
),
|
|
34
|
+
migrations.CreateModel(
|
|
35
|
+
name="Recipe",
|
|
36
|
+
fields=[
|
|
37
|
+
(
|
|
38
|
+
"id",
|
|
39
|
+
models.BigAutoField(
|
|
40
|
+
auto_created=True,
|
|
41
|
+
primary_key=True,
|
|
42
|
+
serialize=False,
|
|
43
|
+
verbose_name="ID",
|
|
44
|
+
),
|
|
45
|
+
),
|
|
46
|
+
("title", models.CharField(max_length=255, unique=True)),
|
|
47
|
+
("slug", models.SlugField(blank=True, max_length=255, unique=True)),
|
|
48
|
+
("description", models.TextField(blank=True)),
|
|
49
|
+
("ingredients", models.TextField(blank=True)),
|
|
50
|
+
("instructions", models.TextField(blank=True)),
|
|
51
|
+
(
|
|
52
|
+
"image",
|
|
53
|
+
models.ImageField(
|
|
54
|
+
blank=True,
|
|
55
|
+
null=True,
|
|
56
|
+
storage=sandwitches.storage.HashedFilenameStorage(),
|
|
57
|
+
upload_to="recipes/",
|
|
58
|
+
),
|
|
59
|
+
),
|
|
60
|
+
("created_at", models.DateTimeField(auto_now_add=True)),
|
|
61
|
+
("updated_at", models.DateTimeField(auto_now=True)),
|
|
62
|
+
(
|
|
63
|
+
"tags",
|
|
64
|
+
models.ManyToManyField(
|
|
65
|
+
blank=True, related_name="recipes", to="sandwitches.tag"
|
|
66
|
+
),
|
|
67
|
+
),
|
|
68
|
+
],
|
|
69
|
+
options={
|
|
70
|
+
"verbose_name": "Recipe",
|
|
71
|
+
"verbose_name_plural": "Recipes",
|
|
72
|
+
"ordering": ("-created_at",),
|
|
73
|
+
},
|
|
74
|
+
),
|
|
75
|
+
]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Generated by Django 5.2.8 on 2025-11-10 18:55
|
|
2
|
+
|
|
3
|
+
import django.db.models.deletion
|
|
4
|
+
import simple_history.models
|
|
5
|
+
from django.conf import settings
|
|
6
|
+
from django.db import migrations, models
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Migration(migrations.Migration):
|
|
10
|
+
dependencies = [
|
|
11
|
+
("sandwitches", "0001_initial"),
|
|
12
|
+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
operations = [
|
|
16
|
+
migrations.CreateModel(
|
|
17
|
+
name="HistoricalRecipe",
|
|
18
|
+
fields=[
|
|
19
|
+
(
|
|
20
|
+
"id",
|
|
21
|
+
models.BigIntegerField(
|
|
22
|
+
auto_created=True, blank=True, db_index=True, verbose_name="ID"
|
|
23
|
+
),
|
|
24
|
+
),
|
|
25
|
+
("title", models.CharField(db_index=True, max_length=255)),
|
|
26
|
+
("slug", models.SlugField(blank=True, max_length=255)),
|
|
27
|
+
("description", models.TextField(blank=True)),
|
|
28
|
+
("ingredients", models.TextField(blank=True)),
|
|
29
|
+
("instructions", models.TextField(blank=True)),
|
|
30
|
+
("image", models.TextField(blank=True, max_length=100, null=True)),
|
|
31
|
+
("created_at", models.DateTimeField(blank=True, editable=False)),
|
|
32
|
+
("updated_at", models.DateTimeField(blank=True, editable=False)),
|
|
33
|
+
("history_id", models.AutoField(primary_key=True, serialize=False)),
|
|
34
|
+
("history_date", models.DateTimeField(db_index=True)),
|
|
35
|
+
("history_change_reason", models.CharField(max_length=100, null=True)),
|
|
36
|
+
(
|
|
37
|
+
"history_type",
|
|
38
|
+
models.CharField(
|
|
39
|
+
choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
|
|
40
|
+
max_length=1,
|
|
41
|
+
),
|
|
42
|
+
),
|
|
43
|
+
(
|
|
44
|
+
"history_user",
|
|
45
|
+
models.ForeignKey(
|
|
46
|
+
null=True,
|
|
47
|
+
on_delete=django.db.models.deletion.SET_NULL,
|
|
48
|
+
related_name="+",
|
|
49
|
+
to=settings.AUTH_USER_MODEL,
|
|
50
|
+
),
|
|
51
|
+
),
|
|
52
|
+
],
|
|
53
|
+
options={
|
|
54
|
+
"verbose_name": "historical Recipe",
|
|
55
|
+
"verbose_name_plural": "historical Recipes",
|
|
56
|
+
"ordering": ("-history_date", "-history_id"),
|
|
57
|
+
"get_latest_by": ("history_date", "history_id"),
|
|
58
|
+
},
|
|
59
|
+
bases=(simple_history.models.HistoricalChanges, models.Model),
|
|
60
|
+
),
|
|
61
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from django.db import models
|
|
2
|
+
from django.urls import reverse
|
|
3
|
+
from django.utils.text import slugify
|
|
4
|
+
from .storage import HashedFilenameStorage
|
|
5
|
+
from simple_history.models import HistoricalRecords
|
|
6
|
+
|
|
7
|
+
hashed_storage = HashedFilenameStorage()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Tag(models.Model):
|
|
11
|
+
name = models.CharField(max_length=50, unique=True)
|
|
12
|
+
slug = models.SlugField(max_length=60, unique=True, blank=True)
|
|
13
|
+
|
|
14
|
+
class Meta:
|
|
15
|
+
ordering = ("name",)
|
|
16
|
+
verbose_name = "Tag"
|
|
17
|
+
verbose_name_plural = "Tags"
|
|
18
|
+
|
|
19
|
+
def save(self, *args, **kwargs):
|
|
20
|
+
if not self.slug:
|
|
21
|
+
base = slugify(self.name)[:55]
|
|
22
|
+
slug = base
|
|
23
|
+
n = 1
|
|
24
|
+
while Tag.objects.filter(slug=slug).exclude(pk=self.pk).exists():
|
|
25
|
+
slug = f"{base}-{n}"
|
|
26
|
+
n += 1
|
|
27
|
+
self.slug = slug
|
|
28
|
+
super().save(*args, **kwargs)
|
|
29
|
+
|
|
30
|
+
def __str__(self):
|
|
31
|
+
return self.name
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Recipe(models.Model):
|
|
35
|
+
title = models.CharField(max_length=255, unique=True)
|
|
36
|
+
slug = models.SlugField(max_length=255, unique=True, blank=True)
|
|
37
|
+
description = models.TextField(blank=True)
|
|
38
|
+
ingredients = models.TextField(blank=True)
|
|
39
|
+
instructions = models.TextField(blank=True)
|
|
40
|
+
image = models.ImageField(
|
|
41
|
+
upload_to="recipes/", # storage will replace with hashed path
|
|
42
|
+
storage=hashed_storage,
|
|
43
|
+
blank=True,
|
|
44
|
+
null=True,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# ManyToMany: tags are reusable and shared between recipes
|
|
48
|
+
tags = models.ManyToManyField(Tag, blank=True, related_name="recipes")
|
|
49
|
+
|
|
50
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
51
|
+
updated_at = models.DateTimeField(auto_now=True)
|
|
52
|
+
history = HistoricalRecords()
|
|
53
|
+
|
|
54
|
+
class Meta:
|
|
55
|
+
ordering = ("-created_at",)
|
|
56
|
+
verbose_name = "Recipe"
|
|
57
|
+
verbose_name_plural = "Recipes"
|
|
58
|
+
|
|
59
|
+
def save(self, *args, **kwargs):
|
|
60
|
+
if not self.slug:
|
|
61
|
+
base = slugify(self.title)[:240]
|
|
62
|
+
slug = base
|
|
63
|
+
n = 1
|
|
64
|
+
while Recipe.objects.filter(slug=slug).exclude(pk=self.pk).exists():
|
|
65
|
+
slug = f"{base}-{n}"
|
|
66
|
+
n += 1
|
|
67
|
+
self.slug = slug
|
|
68
|
+
super().save(*args, **kwargs)
|
|
69
|
+
|
|
70
|
+
def tag_list(self):
|
|
71
|
+
# returns list of tag names
|
|
72
|
+
return list(self.tags.values_list("name", flat=True))
|
|
73
|
+
|
|
74
|
+
def set_tags_from_string(self, tag_string):
|
|
75
|
+
"""
|
|
76
|
+
Accepts a comma separated string like "tag1, tag2" and attaches existing tags
|
|
77
|
+
or creates new ones as needed. Returns the Tag queryset assigned.
|
|
78
|
+
"""
|
|
79
|
+
names = [t.strip() for t in (tag_string or "").split(",") if t.strip()]
|
|
80
|
+
tags = []
|
|
81
|
+
for name in names:
|
|
82
|
+
tag = Tag.objects.filter(name__iexact=name).first()
|
|
83
|
+
if not tag:
|
|
84
|
+
tag = Tag.objects.create(name=name)
|
|
85
|
+
tags.append(tag)
|
|
86
|
+
# replace existing tags with these
|
|
87
|
+
self.tags.set(tags)
|
|
88
|
+
return self.tags.all()
|
|
89
|
+
|
|
90
|
+
def get_absolute_url(self):
|
|
91
|
+
return reverse("recipe_detail", kwargs={"pk": self.pk, "slug": self.slug})
|
|
92
|
+
|
|
93
|
+
def __str__(self):
|
|
94
|
+
return self.title
|
|
@@ -0,0 +1,134 @@
|
|
|
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
|
+
|
|
17
|
+
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(",")
|
|
20
|
+
CSRF_TRUSTED_ORIGINS = os.environ.get("CSRF_TRUSTED_ORIGINS", "").split(",")
|
|
21
|
+
|
|
22
|
+
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
|
23
|
+
BASE_DIR = Path(__file__).resolve().parent.parent
|
|
24
|
+
|
|
25
|
+
# Application definition
|
|
26
|
+
|
|
27
|
+
INSTALLED_APPS = [
|
|
28
|
+
"django.contrib.admin",
|
|
29
|
+
"django.contrib.auth",
|
|
30
|
+
"django.contrib.contenttypes",
|
|
31
|
+
"django.contrib.sessions",
|
|
32
|
+
"django.contrib.messages",
|
|
33
|
+
"django.contrib.staticfiles",
|
|
34
|
+
"sandwitches",
|
|
35
|
+
"debug_toolbar",
|
|
36
|
+
"simple_history",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
MIDDLEWARE = [
|
|
40
|
+
"django.middleware.security.SecurityMiddleware",
|
|
41
|
+
"django.contrib.sessions.middleware.SessionMiddleware",
|
|
42
|
+
"django.middleware.common.CommonMiddleware",
|
|
43
|
+
"django.middleware.csrf.CsrfViewMiddleware",
|
|
44
|
+
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
|
45
|
+
"django.contrib.messages.middleware.MessageMiddleware",
|
|
46
|
+
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
|
47
|
+
"debug_toolbar.middleware.DebugToolbarMiddleware",
|
|
48
|
+
"simple_history.middleware.HistoryRequestMiddleware",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
ROOT_URLCONF = "sandwitches.urls"
|
|
52
|
+
|
|
53
|
+
TEMPLATES = [
|
|
54
|
+
{
|
|
55
|
+
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
|
56
|
+
"DIRS": [],
|
|
57
|
+
"APP_DIRS": True,
|
|
58
|
+
"OPTIONS": {
|
|
59
|
+
"context_processors": [
|
|
60
|
+
"django.template.context_processors.request",
|
|
61
|
+
"django.contrib.auth.context_processors.auth",
|
|
62
|
+
"django.contrib.messages.context_processors.messages",
|
|
63
|
+
"django.template.context_processors.csrf",
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
WSGI_APPLICATION = "sandwitches.wsgi.application"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# Database
|
|
73
|
+
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
|
|
74
|
+
|
|
75
|
+
DATABASES = {
|
|
76
|
+
"default": {
|
|
77
|
+
"ENGINE": "django.db.backends.sqlite3",
|
|
78
|
+
"NAME": Path("/config/db.sqlite3"),
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# Password validation
|
|
84
|
+
# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
|
|
85
|
+
|
|
86
|
+
AUTH_PASSWORD_VALIDATORS = [
|
|
87
|
+
{
|
|
88
|
+
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
|
|
98
|
+
},
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# Media files (for uploaded images)
|
|
103
|
+
MEDIA_URL = "/media/"
|
|
104
|
+
MEDIA_ROOT = Path("/config/media")
|
|
105
|
+
|
|
106
|
+
# Static (for CSS etc)
|
|
107
|
+
STATIC_URL = "/static/"
|
|
108
|
+
STATIC_ROOT = Path("/config/staticfiles")
|
|
109
|
+
|
|
110
|
+
# Internationalization
|
|
111
|
+
# https://docs.djangoproject.com/en/5.2/topics/i18n/
|
|
112
|
+
|
|
113
|
+
LANGUAGE_CODE = "en-us"
|
|
114
|
+
|
|
115
|
+
TIME_ZONE = "UTC"
|
|
116
|
+
|
|
117
|
+
USE_I18N = True
|
|
118
|
+
|
|
119
|
+
USE_TZ = True
|
|
120
|
+
|
|
121
|
+
INTERNAL_IPS = [
|
|
122
|
+
"127.0.0.1",
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# Static files (CSS, JavaScript, Images)
|
|
127
|
+
# https://docs.djangoproject.com/en/5.2/howto/static-files/
|
|
128
|
+
|
|
129
|
+
STATIC_URL = "static/"
|
|
130
|
+
|
|
131
|
+
# Default primary key field type
|
|
132
|
+
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
|
133
|
+
|
|
134
|
+
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import os
|
|
3
|
+
from django.core.files.base import ContentFile
|
|
4
|
+
from django.core.files.storage import FileSystemStorage
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class HashedFilenameStorage(FileSystemStorage):
|
|
8
|
+
"""
|
|
9
|
+
Save uploaded files under a hash of their contents + original extension.
|
|
10
|
+
Example output: media/recipes/3f8a9d...png
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def _save(self, name, content):
|
|
14
|
+
# ensure we read bytes
|
|
15
|
+
try:
|
|
16
|
+
content.seek(0)
|
|
17
|
+
except Exception:
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
data = content.read()
|
|
21
|
+
# compute hash (use first 32 hex chars to keep names shorter)
|
|
22
|
+
h = hashlib.sha256(data).hexdigest()[:32]
|
|
23
|
+
ext = os.path.splitext(name)[1].lower() or ""
|
|
24
|
+
# store all recipe images under recipes/
|
|
25
|
+
name = f"recipes/{h}{ext}"
|
|
26
|
+
|
|
27
|
+
# wrap bytes into a ContentFile so Django storage works consistently
|
|
28
|
+
content = ContentFile(data)
|
|
29
|
+
return super()._save(name, content)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
|
|
6
|
+
<title>{% block title %}Sandwitches{% endblock %}</title>
|
|
7
|
+
{% block extra_head %}{% endblock %}
|
|
8
|
+
</head>
|
|
9
|
+
<body class="{% block body_class %}{% endblock %}">
|
|
10
|
+
{% block navbar %}{% endblock %}
|
|
11
|
+
<main class="container" role="main">{% block content %}{% endblock %}</main>
|
|
12
|
+
{% block extra_scripts %}{% endblock %}
|
|
13
|
+
</body>
|
|
14
|
+
</html>
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
{% extends "base.html" %} {% block extra_head %}
|
|
2
|
+
<!-- Pico.css (CDN) -->
|
|
3
|
+
<link
|
|
4
|
+
rel="stylesheet"
|
|
5
|
+
href="https://unpkg.com/@picocss/pico@1.*/css/pico.min.css"
|
|
6
|
+
/>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
--card-radius: 12px;
|
|
10
|
+
--card-shadow-sm: 0 6px 18px rgba(15, 23, 42, 0.06);
|
|
11
|
+
--card-shadow-lg: 0 18px 40px rgba(15, 23, 42, 0.12);
|
|
12
|
+
--accent: #ff7a18;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/* responsive grid for cards */
|
|
16
|
+
section.grid {
|
|
17
|
+
display: grid;
|
|
18
|
+
gap: 1.25rem;
|
|
19
|
+
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
|
20
|
+
align-items: start;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/* unified card */
|
|
24
|
+
article.card {
|
|
25
|
+
background: var(--pico-card-background);
|
|
26
|
+
border-radius: var(--card-radius);
|
|
27
|
+
overflow: hidden;
|
|
28
|
+
padding: 0.12rem 0.45rem;
|
|
29
|
+
box-shadow: var(--card-shadow-sm);
|
|
30
|
+
transition: transform 0.18s ease, box-shadow 0.18s ease;
|
|
31
|
+
display: flex;
|
|
32
|
+
flex-direction: column;
|
|
33
|
+
border: 1px solid rgba(0, 0, 0, 0.04);
|
|
34
|
+
}
|
|
35
|
+
article.card:hover {
|
|
36
|
+
transform: translateY(-8px);
|
|
37
|
+
box-shadow: var(--card-shadow-lg);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/* image area on top */
|
|
41
|
+
.recipe-figure {
|
|
42
|
+
width: 100%;
|
|
43
|
+
display: block;
|
|
44
|
+
position: relative;
|
|
45
|
+
background: #f6f6f6;
|
|
46
|
+
flex: 0 0 auto;
|
|
47
|
+
}
|
|
48
|
+
.recipe-figure img {
|
|
49
|
+
width: 100%;
|
|
50
|
+
height: auto;
|
|
51
|
+
object-fit: cover;
|
|
52
|
+
display: block;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/* card body below image */
|
|
56
|
+
.card-body {
|
|
57
|
+
padding: 0.05rem 0.05rem;
|
|
58
|
+
display: flex;
|
|
59
|
+
flex-direction: column;
|
|
60
|
+
gap: 0.4rem;
|
|
61
|
+
min-height: 64px;
|
|
62
|
+
flex: 1 1 auto;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.card-title,
|
|
66
|
+
article header {
|
|
67
|
+
margin: 0;
|
|
68
|
+
font-size: 1.02rem;
|
|
69
|
+
line-height: 1.2;
|
|
70
|
+
font-weight: 600;
|
|
71
|
+
color: inherit;
|
|
72
|
+
word-break: break-word;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/* compact chips */
|
|
76
|
+
.chip,
|
|
77
|
+
.tag {
|
|
78
|
+
display: inline-block;
|
|
79
|
+
padding: 0.12rem 0.45rem;
|
|
80
|
+
margin: 0 0.35rem 0.35rem 0;
|
|
81
|
+
border-radius: 999px;
|
|
82
|
+
font-size: 0.78rem;
|
|
83
|
+
line-height: 1;
|
|
84
|
+
white-space: nowrap;
|
|
85
|
+
overflow: hidden;
|
|
86
|
+
text-overflow: ellipsis;
|
|
87
|
+
max-width: 9.5rem;
|
|
88
|
+
vertical-align: middle;
|
|
89
|
+
border: 1px solid rgba(0, 0, 0, 0.06);
|
|
90
|
+
background: rgba(0, 0, 0, 0.03);
|
|
91
|
+
color: rgba(0, 0, 0, 0.85);
|
|
92
|
+
}
|
|
93
|
+
.chip--accent {
|
|
94
|
+
background: rgba(255, 122, 24, 0.09);
|
|
95
|
+
color: var(--accent);
|
|
96
|
+
border-color: rgba(255, 122, 24, 0.12);
|
|
97
|
+
}
|
|
98
|
+
.tags-row {
|
|
99
|
+
display: flex;
|
|
100
|
+
flex-wrap: wrap;
|
|
101
|
+
gap: 0.15rem;
|
|
102
|
+
align-items: center;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
footer.card-footer,
|
|
106
|
+
article.card footer {
|
|
107
|
+
padding: 0.5rem 0.9rem;
|
|
108
|
+
border-top: 1px solid rgba(0, 0, 0, 0.04);
|
|
109
|
+
background: rgba(0, 0, 0, 0.01);
|
|
110
|
+
display: flex;
|
|
111
|
+
justify-content: flex-start;
|
|
112
|
+
gap: 1rem;
|
|
113
|
+
flex: 0 0 auto;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
footer a {
|
|
117
|
+
font-weight: 600;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
@media (min-width: 900px) {
|
|
121
|
+
.recipe-figure img {
|
|
122
|
+
height: 200px;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
@media (min-width: 1200px) {
|
|
126
|
+
.recipe-figure img {
|
|
127
|
+
height: 220px;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
</style>
|
|
131
|
+
{% endblock %} {% block navbar %}
|
|
132
|
+
<header class="container">
|
|
133
|
+
<nav>
|
|
134
|
+
<ul>
|
|
135
|
+
<li>
|
|
136
|
+
<a href="{% url 'index' %}"><strong>Sandwitches</strong></a>
|
|
137
|
+
</li>
|
|
138
|
+
</ul>
|
|
139
|
+
<ul class="icons">
|
|
140
|
+
<li>
|
|
141
|
+
<a
|
|
142
|
+
rel="noopener noreferrer"
|
|
143
|
+
class="contrast"
|
|
144
|
+
aria-label="GitHub repository"
|
|
145
|
+
href="https://github.com/martynvdijke/sandwitches"
|
|
146
|
+
target="_blank"
|
|
147
|
+
><svg
|
|
148
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
149
|
+
height="24"
|
|
150
|
+
width="24.25"
|
|
151
|
+
viewBox="0 0 496 512"
|
|
152
|
+
class="icon-github"
|
|
153
|
+
>
|
|
154
|
+
<path
|
|
155
|
+
d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"
|
|
156
|
+
></path></svg
|
|
157
|
+
></a>
|
|
158
|
+
</li>
|
|
159
|
+
<li>
|
|
160
|
+
<a
|
|
161
|
+
href="#"
|
|
162
|
+
id="theme-toggle"
|
|
163
|
+
aria-label="Toggle theme"
|
|
164
|
+
role="button"
|
|
165
|
+
title="Toggle theme"
|
|
166
|
+
>☀️</a
|
|
167
|
+
>
|
|
168
|
+
</li>
|
|
169
|
+
</ul>
|
|
170
|
+
<ul>
|
|
171
|
+
{% if user.is_authenticated %}
|
|
172
|
+
<li><a href="{% url 'admin:index' %}">Admin</a></li>
|
|
173
|
+
<li><a href="{% url 'admin:logout' %}">Logout</a></li>
|
|
174
|
+
{% else %}
|
|
175
|
+
<li><a href="{% url 'admin:login' %}">Login</a></li>
|
|
176
|
+
{% endif %}
|
|
177
|
+
</ul>
|
|
178
|
+
</nav>
|
|
179
|
+
</header>
|
|
180
|
+
|
|
181
|
+
<script>
|
|
182
|
+
// Simple toggle-style theme switcher (stores choice in localStorage)
|
|
183
|
+
const themeSwitcher = {
|
|
184
|
+
localStorageKey: "picoPreferredColorScheme",
|
|
185
|
+
buttonSelector: "#theme-toggle",
|
|
186
|
+
rootAttribute: "data-theme",
|
|
187
|
+
init() {
|
|
188
|
+
// read saved choice, fallback to system preference
|
|
189
|
+
const saved = window.localStorage?.getItem(this.localStorageKey);
|
|
190
|
+
this._scheme =
|
|
191
|
+
saved ??
|
|
192
|
+
(window.matchMedia &&
|
|
193
|
+
window.matchMedia("(prefers-color-scheme: dark)").matches
|
|
194
|
+
? "dark"
|
|
195
|
+
: "light");
|
|
196
|
+
this.applyScheme();
|
|
197
|
+
this.initButton();
|
|
198
|
+
},
|
|
199
|
+
initButton() {
|
|
200
|
+
const btn = document.querySelector(this.buttonSelector);
|
|
201
|
+
if (!btn) return;
|
|
202
|
+
this.updateButton(btn);
|
|
203
|
+
btn.addEventListener("click", (e) => {
|
|
204
|
+
e.preventDefault();
|
|
205
|
+
this.toggle();
|
|
206
|
+
this.updateButton(btn);
|
|
207
|
+
});
|
|
208
|
+
// react to storage changes from other tabs
|
|
209
|
+
window.addEventListener("storage", (ev) => {
|
|
210
|
+
if (ev.key === this.localStorageKey) {
|
|
211
|
+
this._scheme =
|
|
212
|
+
ev.newValue ||
|
|
213
|
+
(window.matchMedia &&
|
|
214
|
+
window.matchMedia("(prefers-color-scheme: dark)").matches
|
|
215
|
+
? "dark"
|
|
216
|
+
: "light");
|
|
217
|
+
this.applyScheme();
|
|
218
|
+
this.updateButton(document.querySelector(this.buttonSelector));
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
},
|
|
222
|
+
toggle() {
|
|
223
|
+
this._scheme = this._scheme === "dark" ? "light" : "dark";
|
|
224
|
+
this.applyScheme();
|
|
225
|
+
window.localStorage?.setItem(this.localStorageKey, this._scheme);
|
|
226
|
+
},
|
|
227
|
+
applyScheme() {
|
|
228
|
+
document.documentElement?.setAttribute(this.rootAttribute, this._scheme);
|
|
229
|
+
},
|
|
230
|
+
updateButton(btn) {
|
|
231
|
+
if (!btn) return;
|
|
232
|
+
btn.textContent = this._scheme === "dark" ? "🌙" : "☀️";
|
|
233
|
+
btn.title =
|
|
234
|
+
this._scheme === "dark" ? "Switch to light" : "Switch to dark";
|
|
235
|
+
btn.setAttribute(
|
|
236
|
+
"aria-pressed",
|
|
237
|
+
this._scheme === "dark" ? "true" : "false"
|
|
238
|
+
);
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
themeSwitcher.init();
|
|
242
|
+
</script>
|
|
243
|
+
{% endblock %} {% block extra_scripts %} {% block page_scripts %}{% endblock %}
|
|
244
|
+
{% endblock %}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{% extends "base_pico.html" %}
|
|
2
|
+
{% block title %}{{ recipe.title }} — Sandwitch{% endblock %}
|
|
3
|
+
|
|
4
|
+
{% block content %}
|
|
5
|
+
|
|
6
|
+
{% load markdown_extras %}
|
|
7
|
+
<nav aria-label="breadcrumb" class="container">
|
|
8
|
+
<a href="{% url 'index' %}">← Back to all</a>
|
|
9
|
+
</nav>
|
|
10
|
+
|
|
11
|
+
<div class="grid">
|
|
12
|
+
<article class="card">
|
|
13
|
+
<figure>
|
|
14
|
+
{% if recipe.image %}
|
|
15
|
+
<img src="{{ recipe.image.url }}" alt="{{ recipe.title }}">
|
|
16
|
+
{% endif %}
|
|
17
|
+
</figure>
|
|
18
|
+
<header>
|
|
19
|
+
{{ recipe.title }}
|
|
20
|
+
</header>
|
|
21
|
+
<h4>Description</h4>
|
|
22
|
+
{{ recipe.description|convert_markdown|safe|default:"No description yet." }}
|
|
23
|
+
|
|
24
|
+
<h4>Ingredients</h4>
|
|
25
|
+
{{ recipe.ingredients|convert_markdown|safe|default:"No ingredients listed." }}
|
|
26
|
+
|
|
27
|
+
<h4>Instructions</h4>
|
|
28
|
+
{{ recipe.instructions|convert_markdown|safe|default:"No instructions yet." }}
|
|
29
|
+
|
|
30
|
+
<div class="tags-row">
|
|
31
|
+
<div style="margin-top:12px;">
|
|
32
|
+
{% for tag in recipe.tags.all %}
|
|
33
|
+
<span class="tag">{{ tag.name }}</span>
|
|
34
|
+
{% endfor %}
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
{% if user.is_authenticated and user.is_staff %}
|
|
38
|
+
<footer>
|
|
39
|
+
<a href="/admin/sandwitches/recipe/{{ recipe.pk }}/change/">Edit</a>
|
|
40
|
+
</footer>
|
|
41
|
+
{% endif %}
|
|
42
|
+
</article>
|
|
43
|
+
</div>
|
|
44
|
+
{% endblock %}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<title>Edit</title>
|
|
6
|
+
</head>
|
|
7
|
+
<body>
|
|
8
|
+
<h1>Edit Recipe: {{ recipe.title }}</h1>
|
|
9
|
+
<form method="post" enctype="multipart/form-data">
|
|
10
|
+
{% csrf_token %} {{ form.as_p }}
|
|
11
|
+
<button type="submit">Save</button>
|
|
12
|
+
<a href="{% url 'recipes:admin_list' %}">Cancel</a>
|
|
13
|
+
</form>
|
|
14
|
+
</body>
|
|
15
|
+
</html>
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{% extends "base_pico.html" %}
|
|
2
|
+
|
|
3
|
+
{% block title %}Sandwitches{% endblock %}
|
|
4
|
+
|
|
5
|
+
{% block content %}
|
|
6
|
+
<div class="grid search-row">
|
|
7
|
+
<div>
|
|
8
|
+
<h4>Sandwitches: sandwiches so good, they haunt you!</h4>
|
|
9
|
+
</div>
|
|
10
|
+
<div>
|
|
11
|
+
<form role="search" onsubmit="return false;">
|
|
12
|
+
<input id="search" type="search" placeholder="Search by title or tag" aria-label="Search">
|
|
13
|
+
</form>
|
|
14
|
+
</div>
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
<section class="grid">
|
|
18
|
+
{% for recipe in recipes %}
|
|
19
|
+
<article class="card" onclick="location.href='{% url 'recipe_detail' recipe.slug %}';" style="cursor:pointer;">
|
|
20
|
+
{% if recipe.image %}
|
|
21
|
+
<figure class="recipe-figure">
|
|
22
|
+
<img src="{{ recipe.image.url }}" alt="{{ recipe.title }}">
|
|
23
|
+
</figure>
|
|
24
|
+
{% endif %}
|
|
25
|
+
|
|
26
|
+
<div class="card-body">
|
|
27
|
+
<header class="card-title">{{ recipe.title }}</header>
|
|
28
|
+
|
|
29
|
+
<div class="tags-row" aria-hidden="false">
|
|
30
|
+
{% for tag in recipe.tags.all %}
|
|
31
|
+
<span class="chip{% if tag|length < 8 %} chip--accent{% endif %}">{{ tag.name }}</span>
|
|
32
|
+
{% endfor %}
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
{% if user.is_authenticated and user.is_staff %}
|
|
37
|
+
<footer>
|
|
38
|
+
<a href="/admin/sandwitches/recipe/{{ recipe.pk }}/change/" rel="noopener">Edit</a>
|
|
39
|
+
</footer>
|
|
40
|
+
{% endif %}
|
|
41
|
+
</article>
|
|
42
|
+
|
|
43
|
+
{% empty %}
|
|
44
|
+
<article class="card">
|
|
45
|
+
<div class="card-body">
|
|
46
|
+
<p>No sandwitches yet, please stay tuned.</p>
|
|
47
|
+
</div>
|
|
48
|
+
</article>
|
|
49
|
+
{% endfor %}
|
|
50
|
+
</section>
|
|
51
|
+
|
|
52
|
+
{% endblock %}
|
|
53
|
+
|
|
54
|
+
{% block page_scripts %}
|
|
55
|
+
<script>
|
|
56
|
+
document.addEventListener('DOMContentLoaded', function() {
|
|
57
|
+
const search = document.getElementById('search');
|
|
58
|
+
if (!search) return;
|
|
59
|
+
search.addEventListener('input', function() {
|
|
60
|
+
const q = this.value.toLowerCase().trim();
|
|
61
|
+
document.querySelectorAll('section.grid article.card').forEach(card => {
|
|
62
|
+
const title = (card.querySelector('.card-title')?.textContent || '').toLowerCase();
|
|
63
|
+
const tags = Array.from(card.querySelectorAll('.tag, .chip')).map(t => t.textContent.toLowerCase());
|
|
64
|
+
const descr = (card.querySelector('.card-body p')?.textContent || '').toLowerCase();
|
|
65
|
+
const match = !q || title.includes(q) || descr.includes(q) || tags.some(t => t.includes(q));
|
|
66
|
+
card.style.display = match ? '' : 'none';
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
</script>
|
|
71
|
+
{% endblock %}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{% extends "base_pico.html" %}
|
|
2
|
+
{% load static %}
|
|
3
|
+
{% block title %}Initial setup — Create admin{% endblock %}
|
|
4
|
+
|
|
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>Create initial administrator</h2>
|
|
10
|
+
<p>
|
|
11
|
+
This page is only available when there are no admin users in the database.
|
|
12
|
+
After creating the account you will be logged in and redirected to the admin.
|
|
13
|
+
</p>
|
|
14
|
+
|
|
15
|
+
<form method="post" novalidate>
|
|
16
|
+
{% csrf_token %}
|
|
17
|
+
{% if form.non_field_errors %}
|
|
18
|
+
<div class="card-panel" role="alert">
|
|
19
|
+
<ul>
|
|
20
|
+
{% for err in form.non_field_errors %}
|
|
21
|
+
<li>{{ err }}</li>
|
|
22
|
+
{% endfor %}
|
|
23
|
+
</ul>
|
|
24
|
+
</div>
|
|
25
|
+
{% endif %}
|
|
26
|
+
|
|
27
|
+
<label for="{{ form.username.id_for_label }}">Username</label>
|
|
28
|
+
{{ form.username }}
|
|
29
|
+
|
|
30
|
+
<label for="{{ form.email.id_for_label }}">Email (optional)</label>
|
|
31
|
+
{{ form.email }}
|
|
32
|
+
|
|
33
|
+
<label for="{{ form.first_name.id_for_label }}">First name</label>
|
|
34
|
+
{{ form.first_name }}
|
|
35
|
+
|
|
36
|
+
<label for="{{ form.last_name.id_for_label }}">Last name</label>
|
|
37
|
+
{{ form.last_name }}
|
|
38
|
+
|
|
39
|
+
<label for="{{ form.password1.id_for_label }}">Password</label>
|
|
40
|
+
{{ form.password1 }}
|
|
41
|
+
|
|
42
|
+
<label for="{{ form.password2.id_for_label }}">Confirm password</label>
|
|
43
|
+
{{ form.password2 }}
|
|
44
|
+
|
|
45
|
+
<p style="margin-top:1rem;">
|
|
46
|
+
<button type="submit">Create admin</button>
|
|
47
|
+
<a class="contrast" href="{% url 'index' %}">Cancel</a>
|
|
48
|
+
</p>
|
|
49
|
+
</form>
|
|
50
|
+
</div>
|
|
51
|
+
</article>
|
|
52
|
+
</div>
|
|
53
|
+
{% endblock %}
|
|
File without changes
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import markdown
|
|
2
|
+
|
|
3
|
+
from django import template
|
|
4
|
+
from django.template.defaultfilters import stringfilter
|
|
5
|
+
|
|
6
|
+
register = template.Library()
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@register.filter
|
|
10
|
+
@stringfilter
|
|
11
|
+
def convert_markdown(value):
|
|
12
|
+
md = markdown.markdown(
|
|
13
|
+
value,
|
|
14
|
+
extensions=["markdown.extensions.fenced_code", "markdown.extensions.tables"],
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
return md
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""
|
|
2
|
+
URL configuration for sandwitches project.
|
|
3
|
+
|
|
4
|
+
The `urlpatterns` list routes URLs to views. For more information please see:
|
|
5
|
+
https://docs.djangoproject.com/en/5.2/topics/http/urls/
|
|
6
|
+
Examples:
|
|
7
|
+
Function views
|
|
8
|
+
1. Add an import: from my_app import views
|
|
9
|
+
2. Add a URL to urlpatterns: path('', views.home, name='home')
|
|
10
|
+
Class-based views
|
|
11
|
+
1. Add an import: from other_app.views import Home
|
|
12
|
+
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
|
|
13
|
+
Including another URLconf
|
|
14
|
+
1. Import the include() function: from django.urls import include, path
|
|
15
|
+
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from django.contrib import admin
|
|
19
|
+
from django.urls import path
|
|
20
|
+
from . import views
|
|
21
|
+
|
|
22
|
+
from django.conf import settings
|
|
23
|
+
from django.conf.urls.static import static
|
|
24
|
+
from debug_toolbar.toolbar import debug_toolbar_urls
|
|
25
|
+
import os
|
|
26
|
+
import sys
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
urlpatterns = [
|
|
30
|
+
path("", views.index, name="index"),
|
|
31
|
+
path("admin/", admin.site.urls),
|
|
32
|
+
path("recipes/<slug:slug>/", views.recipe_detail, name="recipe_detail"),
|
|
33
|
+
path("setup/", views.setup, name="setup"),
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
if "test" not in sys.argv or "PYTEST_VERSION" in os.environ:
|
|
37
|
+
from debug_toolbar.toolbar import debug_toolbar_urls
|
|
38
|
+
|
|
39
|
+
urlpatterns = [
|
|
40
|
+
*urlpatterns,
|
|
41
|
+
] + debug_toolbar_urls()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
if settings.DEBUG:
|
|
45
|
+
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from django.shortcuts import render, get_object_or_404, redirect
|
|
2
|
+
from django.urls import reverse
|
|
3
|
+
from django.contrib import messages
|
|
4
|
+
from django.contrib.auth import login
|
|
5
|
+
from django.contrib.auth import get_user_model
|
|
6
|
+
|
|
7
|
+
from .models import Recipe
|
|
8
|
+
from .forms import RecipeForm, AdminSetupForm
|
|
9
|
+
|
|
10
|
+
User = get_user_model()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def recipe_edit(request, pk):
|
|
14
|
+
recipe = get_object_or_404(Recipe, pk=pk)
|
|
15
|
+
if request.method == "POST":
|
|
16
|
+
form = RecipeForm(request.POST, request.FILES, instance=recipe)
|
|
17
|
+
if form.is_valid():
|
|
18
|
+
form.save()
|
|
19
|
+
return redirect("recipes:admin_list")
|
|
20
|
+
else:
|
|
21
|
+
form = RecipeForm(instance=recipe)
|
|
22
|
+
return render(request, "recipe_form.html", {"form": form, "recipe": recipe})
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def recipe_detail(request, slug):
|
|
26
|
+
recipe = get_object_or_404(Recipe, slug=slug)
|
|
27
|
+
return render(request, "detail.html", {"recipe": recipe})
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def index(request):
|
|
31
|
+
if not User.objects.filter(is_superuser=True).exists():
|
|
32
|
+
return redirect("setup")
|
|
33
|
+
recipes = Recipe.objects.order_by("-created_at")
|
|
34
|
+
return render(request, "index.html", {"recipes": recipes})
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def setup(request):
|
|
38
|
+
"""
|
|
39
|
+
First-time setup page: create initial superuser if none exists.
|
|
40
|
+
Visible only while there are no superusers in the DB.
|
|
41
|
+
"""
|
|
42
|
+
# do not allow access if a superuser already exists
|
|
43
|
+
if User.objects.filter(is_superuser=True).exists():
|
|
44
|
+
return redirect("index")
|
|
45
|
+
|
|
46
|
+
if request.method == "POST":
|
|
47
|
+
form = AdminSetupForm(request.POST)
|
|
48
|
+
if form.is_valid():
|
|
49
|
+
user = form.save()
|
|
50
|
+
# log in the newly created admin
|
|
51
|
+
user.backend = "django.contrib.auth.backends.ModelBackend"
|
|
52
|
+
login(request, user)
|
|
53
|
+
messages.success(request, "Admin account created and signed in.")
|
|
54
|
+
return redirect(reverse("admin:index"))
|
|
55
|
+
else:
|
|
56
|
+
form = AdminSetupForm()
|
|
57
|
+
|
|
58
|
+
return render(request, "setup.html", {"form": form})
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""
|
|
2
|
+
WSGI config for sandwitches project.
|
|
3
|
+
|
|
4
|
+
It exposes the WSGI callable as a module-level variable named ``application``.
|
|
5
|
+
|
|
6
|
+
For more information on this file, see
|
|
7
|
+
https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
from django.core.wsgi import get_wsgi_application
|
|
13
|
+
|
|
14
|
+
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "sandwitches.settings")
|
|
15
|
+
|
|
16
|
+
application = get_wsgi_application()
|