sandwitches 1.0.3__tar.gz → 1.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-1.0.3 → sandwitches-1.1.0}/PKG-INFO +3 -3
- {sandwitches-1.0.3 → sandwitches-1.1.0}/pyproject.toml +3 -3
- sandwitches-1.1.0/src/sandwitches/api.py +65 -0
- sandwitches-1.1.0/src/sandwitches/forms.py +89 -0
- sandwitches-1.1.0/src/sandwitches/migrations/0003_rating.py +57 -0
- {sandwitches-1.0.3 → sandwitches-1.1.0}/src/sandwitches/models.py +27 -0
- {sandwitches-1.0.3 → sandwitches-1.1.0}/src/sandwitches/settings.py +7 -1
- sandwitches-1.1.0/src/sandwitches/tasks.py +16 -0
- {sandwitches-1.0.3 → sandwitches-1.1.0}/src/sandwitches/templates/base_pico.html +2 -0
- sandwitches-1.1.0/src/sandwitches/templates/detail.html +104 -0
- sandwitches-1.1.0/src/sandwitches/templates/signup.html +50 -0
- {sandwitches-1.0.3 → sandwitches-1.1.0}/src/sandwitches/urls.py +5 -0
- sandwitches-1.1.0/src/sandwitches/views.py +121 -0
- sandwitches-1.0.3/src/sandwitches/forms.py +0 -60
- sandwitches-1.0.3/src/sandwitches/templates/detail.html +0 -44
- sandwitches-1.0.3/src/sandwitches/views.py +0 -58
- {sandwitches-1.0.3 → sandwitches-1.1.0}/README.md +0 -0
- {sandwitches-1.0.3 → sandwitches-1.1.0}/src/sandwitches/__init__.py +0 -0
- {sandwitches-1.0.3 → sandwitches-1.1.0}/src/sandwitches/admin.py +0 -0
- {sandwitches-1.0.3 → sandwitches-1.1.0}/src/sandwitches/asgi.py +0 -0
- {sandwitches-1.0.3 → sandwitches-1.1.0}/src/sandwitches/migrations/0001_initial.py +0 -0
- {sandwitches-1.0.3 → sandwitches-1.1.0}/src/sandwitches/migrations/0002_historicalrecipe.py +0 -0
- {sandwitches-1.0.3 → sandwitches-1.1.0}/src/sandwitches/migrations/__init__.py +0 -0
- {sandwitches-1.0.3 → sandwitches-1.1.0}/src/sandwitches/storage.py +0 -0
- {sandwitches-1.0.3 → sandwitches-1.1.0}/src/sandwitches/templates/base.html +0 -0
- {sandwitches-1.0.3 → sandwitches-1.1.0}/src/sandwitches/templates/form.html +0 -0
- {sandwitches-1.0.3 → sandwitches-1.1.0}/src/sandwitches/templates/index.html +0 -0
- {sandwitches-1.0.3 → sandwitches-1.1.0}/src/sandwitches/templates/setup.html +0 -0
- {sandwitches-1.0.3 → sandwitches-1.1.0}/src/sandwitches/templatetags/__init__.py +0 -0
- {sandwitches-1.0.3 → sandwitches-1.1.0}/src/sandwitches/templatetags/markdown_extras.py +0 -0
- {sandwitches-1.0.3 → sandwitches-1.1.0}/src/sandwitches/wsgi.py +0 -0
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: sandwitches
|
|
3
|
-
Version: 1.0
|
|
3
|
+
Version: 1.1.0
|
|
4
4
|
Summary: Add your description here
|
|
5
5
|
Author: Martyn van Dijke
|
|
6
6
|
Author-email: Martyn van Dijke <martijnvdijke600@gmail.com>
|
|
7
7
|
Requires-Dist: django-debug-toolbar>=6.1.0
|
|
8
8
|
Requires-Dist: django-filter>=25.2
|
|
9
|
+
Requires-Dist: django-ninja>=1.5.1
|
|
9
10
|
Requires-Dist: django-simple-history>=3.10.1
|
|
10
|
-
Requires-Dist: django>=
|
|
11
|
-
Requires-Dist: djangorestframework>=3.16.1
|
|
11
|
+
Requires-Dist: django>=6.0.0
|
|
12
12
|
Requires-Dist: gunicorn>=23.0.0
|
|
13
13
|
Requires-Dist: markdown>=3.10
|
|
14
14
|
Requires-Dist: pillow>=12.0.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "sandwitches"
|
|
3
|
-
version = "1.0
|
|
3
|
+
version = "1.1.0"
|
|
4
4
|
description = "Add your description here"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [
|
|
@@ -12,9 +12,9 @@ requires-python = ">=3.12"
|
|
|
12
12
|
dependencies = [
|
|
13
13
|
"django-debug-toolbar>=6.1.0",
|
|
14
14
|
"django-filter>=25.2",
|
|
15
|
+
"django-ninja>=1.5.1",
|
|
15
16
|
"django-simple-history>=3.10.1",
|
|
16
|
-
"django>=
|
|
17
|
-
"djangorestframework>=3.16.1",
|
|
17
|
+
"django>=6.0.0",
|
|
18
18
|
"gunicorn>=23.0.0",
|
|
19
19
|
"markdown>=3.10",
|
|
20
20
|
"pillow>=12.0.0",
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from ninja import NinjaAPI
|
|
2
|
+
from .models import Recipe
|
|
3
|
+
|
|
4
|
+
from ninja import ModelSchema
|
|
5
|
+
from ninja import Schema
|
|
6
|
+
from django.contrib.auth.models import User
|
|
7
|
+
from django.shortcuts import get_object_or_404
|
|
8
|
+
from datetime import date
|
|
9
|
+
import random
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
api = NinjaAPI()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RecipeSchema(ModelSchema):
|
|
16
|
+
class Meta:
|
|
17
|
+
model = Recipe
|
|
18
|
+
fields = "__all__"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class UserSchema(ModelSchema):
|
|
22
|
+
class Meta:
|
|
23
|
+
model = User
|
|
24
|
+
exclude = ["password", "last_login", "user_permissions"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Error(Schema):
|
|
28
|
+
message: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@api.get("v1/me", response={200: UserSchema, 403: Error})
|
|
32
|
+
def me(request):
|
|
33
|
+
if not request.user.is_authenticated:
|
|
34
|
+
return 403, {"message": "Please sign in first"}
|
|
35
|
+
return request.user
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# TODO: enable recipe creation via API
|
|
39
|
+
# @api.post("v1/recipe", auth=django_auth, response=RecipeSchema)
|
|
40
|
+
# def create_recipe(request, payload: RecipeSchema):
|
|
41
|
+
# recipe = Recipe.objects.create(**payload.dict())
|
|
42
|
+
# return recipe
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@api.get("v1/recipes", response=list[RecipeSchema])
|
|
46
|
+
def get_recipes(request):
|
|
47
|
+
recipes = Recipe.objects.all()
|
|
48
|
+
return recipes
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@api.get("v1/recipes/{recipe_id}", response=RecipeSchema)
|
|
52
|
+
def get_recipe(request, recipe_id: int):
|
|
53
|
+
recipe = get_object_or_404(Recipe, id=recipe_id)
|
|
54
|
+
return recipe
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@api.get("v1/recipe-of-the-day", response=RecipeSchema)
|
|
58
|
+
def get_recipe_of_the_day(request):
|
|
59
|
+
recipes = list(Recipe.objects.all())
|
|
60
|
+
if not recipes:
|
|
61
|
+
return None
|
|
62
|
+
today = date.today()
|
|
63
|
+
random.seed(today.toordinal())
|
|
64
|
+
recipe = random.choice(recipes)
|
|
65
|
+
return recipe
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from django import forms
|
|
2
|
+
from django.contrib.auth import get_user_model
|
|
3
|
+
from django.contrib.auth.forms import UserCreationForm
|
|
4
|
+
from .models import Recipe
|
|
5
|
+
|
|
6
|
+
User = get_user_model()
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BaseUserFormMixin:
|
|
10
|
+
"""Mixin to handle common password validation and user field processing."""
|
|
11
|
+
|
|
12
|
+
def clean_passwords(self, cleaned_data):
|
|
13
|
+
p1 = cleaned_data.get("password1")
|
|
14
|
+
p2 = cleaned_data.get("password2")
|
|
15
|
+
if p1 and p2 and p1 != p2:
|
|
16
|
+
raise forms.ValidationError("Passwords do not match.")
|
|
17
|
+
return cleaned_data
|
|
18
|
+
|
|
19
|
+
def _set_user_attributes(self, user, data):
|
|
20
|
+
"""Helper to apply optional name fields."""
|
|
21
|
+
user.first_name = data.get("first_name", "")
|
|
22
|
+
user.last_name = data.get("last_name", "")
|
|
23
|
+
user.save()
|
|
24
|
+
return user
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AdminSetupForm(forms.ModelForm, BaseUserFormMixin):
|
|
28
|
+
password1 = forms.CharField(widget=forms.PasswordInput, label="Password")
|
|
29
|
+
password2 = forms.CharField(widget=forms.PasswordInput, label="Confirm Password")
|
|
30
|
+
|
|
31
|
+
class Meta:
|
|
32
|
+
model = User
|
|
33
|
+
fields = ("username", "first_name", "last_name", "email")
|
|
34
|
+
|
|
35
|
+
def clean(self):
|
|
36
|
+
cleaned_data = super().clean()
|
|
37
|
+
return self.clean_passwords(cleaned_data)
|
|
38
|
+
|
|
39
|
+
def save(self, commit=True):
|
|
40
|
+
data = self.cleaned_data
|
|
41
|
+
user = User.objects.create_superuser(
|
|
42
|
+
username=data["username"], email=data["email"], password=data["password1"]
|
|
43
|
+
)
|
|
44
|
+
return self._set_user_attributes(user, data)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class UserSignupForm(UserCreationForm, BaseUserFormMixin):
|
|
48
|
+
"""Refactored Regular User Form inheriting from Django's UserCreationForm"""
|
|
49
|
+
|
|
50
|
+
class Meta(UserCreationForm.Meta):
|
|
51
|
+
model = User
|
|
52
|
+
fields = ("username", "first_name", "last_name", "email")
|
|
53
|
+
|
|
54
|
+
def clean(self):
|
|
55
|
+
return super().clean()
|
|
56
|
+
|
|
57
|
+
def save(self, commit=True):
|
|
58
|
+
user = super().save(commit=False)
|
|
59
|
+
user.is_superuser = False
|
|
60
|
+
user.is_staff = False
|
|
61
|
+
if commit:
|
|
62
|
+
user.save()
|
|
63
|
+
return user
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class RecipeForm(forms.ModelForm):
|
|
67
|
+
class Meta:
|
|
68
|
+
model = Recipe
|
|
69
|
+
fields = [
|
|
70
|
+
"title",
|
|
71
|
+
"description",
|
|
72
|
+
"ingredients",
|
|
73
|
+
"instructions",
|
|
74
|
+
"image",
|
|
75
|
+
"tags",
|
|
76
|
+
]
|
|
77
|
+
widgets = {
|
|
78
|
+
"tags": forms.TextInput(attrs={"placeholder": "tag1,tag2"}),
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class RatingForm(forms.Form):
|
|
83
|
+
"""Simple form for rating recipes (1-5)."""
|
|
84
|
+
|
|
85
|
+
score = forms.ChoiceField(
|
|
86
|
+
choices=[(str(i), str(i)) for i in range(1, 6)],
|
|
87
|
+
widget=forms.RadioSelect,
|
|
88
|
+
label="Your rating",
|
|
89
|
+
)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Generated by Django 6.0 on 2025-12-21 21:17
|
|
2
|
+
|
|
3
|
+
import django.db.models.deletion
|
|
4
|
+
from django.conf import settings
|
|
5
|
+
from django.db import migrations, models
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Migration(migrations.Migration):
|
|
9
|
+
dependencies = [
|
|
10
|
+
("sandwitches", "0002_historicalrecipe"),
|
|
11
|
+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
operations = [
|
|
15
|
+
migrations.CreateModel(
|
|
16
|
+
name="Rating",
|
|
17
|
+
fields=[
|
|
18
|
+
(
|
|
19
|
+
"id",
|
|
20
|
+
models.BigAutoField(
|
|
21
|
+
auto_created=True,
|
|
22
|
+
primary_key=True,
|
|
23
|
+
serialize=False,
|
|
24
|
+
verbose_name="ID",
|
|
25
|
+
),
|
|
26
|
+
),
|
|
27
|
+
(
|
|
28
|
+
"score",
|
|
29
|
+
models.PositiveSmallIntegerField(
|
|
30
|
+
choices=[(1, 1), (2, 2), (3, 3), (4, 4), (5, 5)]
|
|
31
|
+
),
|
|
32
|
+
),
|
|
33
|
+
("created_at", models.DateTimeField(auto_now_add=True)),
|
|
34
|
+
("updated_at", models.DateTimeField(auto_now=True)),
|
|
35
|
+
(
|
|
36
|
+
"recipe",
|
|
37
|
+
models.ForeignKey(
|
|
38
|
+
on_delete=django.db.models.deletion.CASCADE,
|
|
39
|
+
related_name="ratings",
|
|
40
|
+
to="sandwitches.recipe",
|
|
41
|
+
),
|
|
42
|
+
),
|
|
43
|
+
(
|
|
44
|
+
"user",
|
|
45
|
+
models.ForeignKey(
|
|
46
|
+
on_delete=django.db.models.deletion.CASCADE,
|
|
47
|
+
related_name="ratings",
|
|
48
|
+
to=settings.AUTH_USER_MODEL,
|
|
49
|
+
),
|
|
50
|
+
),
|
|
51
|
+
],
|
|
52
|
+
options={
|
|
53
|
+
"ordering": ("-updated_at",),
|
|
54
|
+
"unique_together": {("recipe", "user")},
|
|
55
|
+
},
|
|
56
|
+
),
|
|
57
|
+
]
|
|
@@ -3,9 +3,13 @@ from django.urls import reverse
|
|
|
3
3
|
from django.utils.text import slugify
|
|
4
4
|
from .storage import HashedFilenameStorage
|
|
5
5
|
from simple_history.models import HistoricalRecords
|
|
6
|
+
from django.contrib.auth import get_user_model
|
|
7
|
+
from django.db.models import Avg
|
|
6
8
|
|
|
7
9
|
hashed_storage = HashedFilenameStorage()
|
|
8
10
|
|
|
11
|
+
User = get_user_model()
|
|
12
|
+
|
|
9
13
|
|
|
10
14
|
class Tag(models.Model):
|
|
11
15
|
name = models.CharField(max_length=50, unique=True)
|
|
@@ -87,8 +91,31 @@ class Recipe(models.Model):
|
|
|
87
91
|
self.tags.set(tags)
|
|
88
92
|
return self.tags.all()
|
|
89
93
|
|
|
94
|
+
# add helper methods for ratings
|
|
95
|
+
def average_rating(self):
|
|
96
|
+
agg = self.ratings.aggregate(avg=Avg("score"))
|
|
97
|
+
return agg["avg"] or 0
|
|
98
|
+
|
|
99
|
+
def rating_count(self):
|
|
100
|
+
return self.ratings.count()
|
|
101
|
+
|
|
90
102
|
def get_absolute_url(self):
|
|
91
103
|
return reverse("recipe_detail", kwargs={"pk": self.pk, "slug": self.slug})
|
|
92
104
|
|
|
93
105
|
def __str__(self):
|
|
94
106
|
return self.title
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class Rating(models.Model):
|
|
110
|
+
recipe = models.ForeignKey(Recipe, related_name="ratings", on_delete=models.CASCADE)
|
|
111
|
+
user = models.ForeignKey(User, related_name="ratings", on_delete=models.CASCADE)
|
|
112
|
+
score = models.PositiveSmallIntegerField(choices=[(i, i) for i in range(1, 6)])
|
|
113
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
114
|
+
updated_at = models.DateTimeField(auto_now=True)
|
|
115
|
+
|
|
116
|
+
class Meta:
|
|
117
|
+
unique_together = ("recipe", "user")
|
|
118
|
+
ordering = ("-updated_at",)
|
|
119
|
+
|
|
120
|
+
def __str__(self):
|
|
121
|
+
return f"{self.recipe} — {self.score} by {self.user}"
|
|
@@ -19,11 +19,16 @@ DEBUG = bool(os.environ.get("DEBUG", default=0))
|
|
|
19
19
|
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "127.0.0.1").split(",")
|
|
20
20
|
CSRF_TRUSTED_ORIGINS = os.environ.get("CSRF_TRUSTED_ORIGINS", "").split(",")
|
|
21
21
|
|
|
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
|
+
|
|
22
26
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
|
23
27
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
|
24
28
|
|
|
25
|
-
|
|
29
|
+
TASKS = {"default": {"BACKEND": "django.tasks.backends.immediate.ImmediateBackend"}}
|
|
26
30
|
|
|
31
|
+
# Application definition
|
|
27
32
|
INSTALLED_APPS = [
|
|
28
33
|
"django.contrib.admin",
|
|
29
34
|
"django.contrib.auth",
|
|
@@ -33,6 +38,7 @@ INSTALLED_APPS = [
|
|
|
33
38
|
"django.contrib.staticfiles",
|
|
34
39
|
"sandwitches",
|
|
35
40
|
"debug_toolbar",
|
|
41
|
+
# "django_recaptcha",
|
|
36
42
|
"simple_history",
|
|
37
43
|
]
|
|
38
44
|
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from django.core.mail import send_mail
|
|
3
|
+
from django.tasks import task
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@task(takes_context=True, priority=2, queue_name="emails")
|
|
10
|
+
def email_users(context, emails, subject, message):
|
|
11
|
+
logger.debug(
|
|
12
|
+
f"Attempt {context.attempt} to send user email. Task result id: {context.task_result.id}."
|
|
13
|
+
)
|
|
14
|
+
return send_mail(
|
|
15
|
+
subject=subject, message=message, from_email=None, recipient_list=emails
|
|
16
|
+
)
|
|
@@ -168,11 +168,13 @@
|
|
|
168
168
|
</li>
|
|
169
169
|
</ul>
|
|
170
170
|
<ul>
|
|
171
|
+
<li><a href="api/docs">Docs</a></li>
|
|
171
172
|
{% if user.is_authenticated %}
|
|
172
173
|
<li><a href="{% url 'admin:index' %}">Admin</a></li>
|
|
173
174
|
<li><a href="{% url 'admin:logout' %}">Logout</a></li>
|
|
174
175
|
{% else %}
|
|
175
176
|
<li><a href="{% url 'admin:login' %}">Login</a></li>
|
|
177
|
+
<li><a href="{% url 'signup' %}">Sign up</a></li>
|
|
176
178
|
{% endif %}
|
|
177
179
|
</ul>
|
|
178
180
|
</nav>
|
|
@@ -0,0 +1,104 @@
|
|
|
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
|
+
|
|
38
|
+
<div style="margin-top:1rem;">
|
|
39
|
+
<h4>Rating</h4>
|
|
40
|
+
{% if rating_count %}
|
|
41
|
+
<p>Average: {{ avg_rating|floatformat:1 }} ({{ rating_count }} vote{% if rating_count %}s{% endif %})</p>
|
|
42
|
+
{% else %}
|
|
43
|
+
<p>No ratings yet.</p>
|
|
44
|
+
{% endif %}
|
|
45
|
+
|
|
46
|
+
{% if user.is_authenticated %}
|
|
47
|
+
{% if user_rating %}
|
|
48
|
+
<p>Your rating: {{ user_rating.score }}</p>
|
|
49
|
+
{% else %}
|
|
50
|
+
<form method="post" action="{% url 'recipe_rate' pk=recipe.pk %}">
|
|
51
|
+
{% csrf_token %}
|
|
52
|
+
|
|
53
|
+
<fieldset>
|
|
54
|
+
<legend>What would you rate this sandwich?</legend>
|
|
55
|
+
|
|
56
|
+
{% if rating_form.score.errors %}
|
|
57
|
+
<div class="card-panel" role="alert">
|
|
58
|
+
<ul>
|
|
59
|
+
{% for err in rating_form.score.errors %}
|
|
60
|
+
<li>{{ err }}</li>
|
|
61
|
+
{% endfor %}
|
|
62
|
+
</ul>
|
|
63
|
+
</div>
|
|
64
|
+
{% endif %}
|
|
65
|
+
|
|
66
|
+
<div class="row">
|
|
67
|
+
{% for i in "12345"|make_list %}
|
|
68
|
+
<div class="col-sm-2">
|
|
69
|
+
<label class="form-check">
|
|
70
|
+
<input
|
|
71
|
+
type="radio"
|
|
72
|
+
name="{{ rating_form.score.name }}"
|
|
73
|
+
id="rating-{{ i }}"
|
|
74
|
+
value="{{ i }}"
|
|
75
|
+
{% if user_rating and user_rating.score|stringformat:"s" == i %}
|
|
76
|
+
checked
|
|
77
|
+
{% elif rating_form.score.value == i %}
|
|
78
|
+
checked
|
|
79
|
+
{% endif %}
|
|
80
|
+
/>
|
|
81
|
+
<span>{{ i }}</span>
|
|
82
|
+
</label>
|
|
83
|
+
</div>
|
|
84
|
+
{% endfor %}
|
|
85
|
+
</div>
|
|
86
|
+
</fieldset>
|
|
87
|
+
<p style="margin-top:0.5rem;">
|
|
88
|
+
<button type="submit">{% if user_rating %}Update{% else %}Rate{% endif %}</button>
|
|
89
|
+
</p>
|
|
90
|
+
</form>
|
|
91
|
+
{% endif %}
|
|
92
|
+
{% else %}
|
|
93
|
+
<p><a href="{% url 'admin:login' %}">Log in</a> to rate this recipe.</p>
|
|
94
|
+
{% endif %}
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
{% if user.is_authenticated and user.is_staff %}
|
|
98
|
+
<footer>
|
|
99
|
+
<a href="/admin/sandwitches/recipe/{{ recipe.pk }}/change/">Edit</a>
|
|
100
|
+
</footer>
|
|
101
|
+
{% endif %}
|
|
102
|
+
</article>
|
|
103
|
+
</div>
|
|
104
|
+
{% endblock %}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{% extends "base_pico.html" %}
|
|
2
|
+
{% load static %}
|
|
3
|
+
{% block title %}Sign Up{% 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>Sign up</h2>
|
|
10
|
+
|
|
11
|
+
<form method="post" novalidate>
|
|
12
|
+
{% csrf_token %}
|
|
13
|
+
{% if form.non_field_errors %}
|
|
14
|
+
<div class="card-panel" role="alert">
|
|
15
|
+
<ul>
|
|
16
|
+
{% for err in form.non_field_errors %}
|
|
17
|
+
<li>{{ err }}</li>
|
|
18
|
+
{% endfor %}
|
|
19
|
+
</ul>
|
|
20
|
+
</div>
|
|
21
|
+
{% endif %}
|
|
22
|
+
|
|
23
|
+
<label for="{{ form.username.id_for_label }}">Username</label>
|
|
24
|
+
{{ form.username }}
|
|
25
|
+
|
|
26
|
+
<label for="{{ form.email.id_for_label }}">Email (optional)</label>
|
|
27
|
+
{{ form.email }}
|
|
28
|
+
|
|
29
|
+
<label for="{{ form.first_name.id_for_label }}">First name</label>
|
|
30
|
+
{{ form.first_name }}
|
|
31
|
+
|
|
32
|
+
<label for="{{ form.last_name.id_for_label }}">Last name</label>
|
|
33
|
+
{{ form.last_name }}
|
|
34
|
+
|
|
35
|
+
<label for="{{ form.password1.id_for_label }}">Password</label>
|
|
36
|
+
{{ form.password1 }}
|
|
37
|
+
|
|
38
|
+
<label for="{{ form.password2.id_for_label }}">Confirm password</label>
|
|
39
|
+
{{ form.password2 }}
|
|
40
|
+
|
|
41
|
+
<p style="margin-top:1rem;">
|
|
42
|
+
<button type="submit">Sign Up</button>
|
|
43
|
+
<a class="contrast" href="{% url 'index' %}">Cancel</a>
|
|
44
|
+
</p>
|
|
45
|
+
</form>
|
|
46
|
+
</div>
|
|
47
|
+
</article>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
{% endblock %}
|
|
@@ -18,6 +18,8 @@ Including another URLconf
|
|
|
18
18
|
from django.contrib import admin
|
|
19
19
|
from django.urls import path
|
|
20
20
|
from . import views
|
|
21
|
+
from .api import api
|
|
22
|
+
|
|
21
23
|
|
|
22
24
|
from django.conf import settings
|
|
23
25
|
from django.conf.urls.static import static
|
|
@@ -31,6 +33,9 @@ urlpatterns = [
|
|
|
31
33
|
path("admin/", admin.site.urls),
|
|
32
34
|
path("recipes/<slug:slug>/", views.recipe_detail, name="recipe_detail"),
|
|
33
35
|
path("setup/", views.setup, name="setup"),
|
|
36
|
+
path("api/", api.urls),
|
|
37
|
+
path("signup/", views.signup, name="signup"),
|
|
38
|
+
path("recipes/<int:pk>/rate/", views.recipe_rate, name="recipe_rate"),
|
|
34
39
|
]
|
|
35
40
|
|
|
36
41
|
if "test" not in sys.argv or "PYTEST_VERSION" in os.environ:
|
|
@@ -0,0 +1,121 @@
|
|
|
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
|
+
from django.contrib.auth.decorators import login_required
|
|
7
|
+
|
|
8
|
+
from .models import Recipe, Rating
|
|
9
|
+
from .forms import RecipeForm, AdminSetupForm, UserSignupForm, RatingForm
|
|
10
|
+
|
|
11
|
+
User = get_user_model()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def recipe_edit(request, pk):
|
|
15
|
+
recipe = get_object_or_404(Recipe, pk=pk)
|
|
16
|
+
if request.method == "POST":
|
|
17
|
+
form = RecipeForm(request.POST, request.FILES, instance=recipe)
|
|
18
|
+
if form.is_valid():
|
|
19
|
+
form.save()
|
|
20
|
+
return redirect("recipes:admin_list")
|
|
21
|
+
else:
|
|
22
|
+
form = RecipeForm(instance=recipe)
|
|
23
|
+
return render(request, "recipe_form.html", {"form": form, "recipe": recipe})
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def recipe_detail(request, slug):
|
|
27
|
+
recipe = get_object_or_404(Recipe, slug=slug)
|
|
28
|
+
avg = recipe.average_rating()
|
|
29
|
+
count = recipe.rating_count()
|
|
30
|
+
user_rating = None
|
|
31
|
+
rating_form = None
|
|
32
|
+
if request.user.is_authenticated:
|
|
33
|
+
try:
|
|
34
|
+
user_rating = Rating.objects.get(recipe=recipe, user=request.user)
|
|
35
|
+
except Rating.DoesNotExist:
|
|
36
|
+
user_rating = None
|
|
37
|
+
# show form prefilled when possible
|
|
38
|
+
initial = {"score": str(user_rating.score)} if user_rating else None
|
|
39
|
+
rating_form = RatingForm(initial=initial)
|
|
40
|
+
return render(
|
|
41
|
+
request,
|
|
42
|
+
"detail.html",
|
|
43
|
+
{
|
|
44
|
+
"recipe": recipe,
|
|
45
|
+
"avg_rating": avg,
|
|
46
|
+
"rating_count": count,
|
|
47
|
+
"user_rating": user_rating,
|
|
48
|
+
"rating_form": rating_form,
|
|
49
|
+
},
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@login_required
|
|
54
|
+
def recipe_rate(request, pk):
|
|
55
|
+
"""
|
|
56
|
+
Create or update a rating for the given recipe by the logged-in user.
|
|
57
|
+
"""
|
|
58
|
+
recipe = get_object_or_404(Recipe, pk=pk)
|
|
59
|
+
if request.method != "POST":
|
|
60
|
+
return redirect("recipe_detail", slug=recipe.slug)
|
|
61
|
+
|
|
62
|
+
form = RatingForm(request.POST)
|
|
63
|
+
if form.is_valid():
|
|
64
|
+
score = int(form.cleaned_data["score"])
|
|
65
|
+
Rating.objects.update_or_create(
|
|
66
|
+
recipe=recipe, user=request.user, defaults={"score": score}
|
|
67
|
+
)
|
|
68
|
+
messages.success(request, "Your rating has been saved.")
|
|
69
|
+
else:
|
|
70
|
+
messages.error(request, "Could not save rating.")
|
|
71
|
+
return redirect("recipe_detail", slug=recipe.slug)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def index(request):
|
|
75
|
+
if not User.objects.filter(is_superuser=True).exists():
|
|
76
|
+
return redirect("setup")
|
|
77
|
+
recipes = Recipe.objects.order_by("-created_at")
|
|
78
|
+
return render(request, "index.html", {"recipes": recipes})
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def setup(request):
|
|
82
|
+
"""
|
|
83
|
+
First-time setup page: create initial superuser if none exists.
|
|
84
|
+
Visible only while there are no superusers in the DB.
|
|
85
|
+
"""
|
|
86
|
+
# do not allow access if a superuser already exists
|
|
87
|
+
if User.objects.filter(is_superuser=True).exists():
|
|
88
|
+
return redirect("index")
|
|
89
|
+
|
|
90
|
+
if request.method == "POST":
|
|
91
|
+
form = AdminSetupForm(request.POST)
|
|
92
|
+
if form.is_valid():
|
|
93
|
+
user = form.save()
|
|
94
|
+
# log in the newly created admin
|
|
95
|
+
user.backend = "django.contrib.auth.backends.ModelBackend"
|
|
96
|
+
login(request, user)
|
|
97
|
+
messages.success(request, "Admin account created and signed in.")
|
|
98
|
+
return redirect(reverse("admin:index"))
|
|
99
|
+
else:
|
|
100
|
+
form = AdminSetupForm()
|
|
101
|
+
|
|
102
|
+
return render(request, "setup.html", {"form": form})
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def signup(request):
|
|
106
|
+
"""
|
|
107
|
+
User signup page: create new regular user accounts.
|
|
108
|
+
"""
|
|
109
|
+
if request.method == "POST":
|
|
110
|
+
form = UserSignupForm(request.POST)
|
|
111
|
+
if form.is_valid():
|
|
112
|
+
user = form.save()
|
|
113
|
+
# log in the newly created user
|
|
114
|
+
user.backend = "django.contrib.auth.backends.ModelBackend"
|
|
115
|
+
login(request, user)
|
|
116
|
+
messages.success(request, "Account created and signed in.")
|
|
117
|
+
return redirect("index")
|
|
118
|
+
else:
|
|
119
|
+
form = UserSignupForm()
|
|
120
|
+
|
|
121
|
+
return render(request, "signup.html", {"form": form})
|
|
@@ -1,60 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,44 +0,0 @@
|
|
|
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 %}
|
|
@@ -1,58 +0,0 @@
|
|
|
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})
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|