feat: 📘 add recipes to posts

This commit is contained in:
m5ka 2024-03-25 21:46:47 +00:00
parent e48e88ead2
commit 6f3ed061b4
17 changed files with 433 additions and 7 deletions

View file

@ -21,6 +21,14 @@ from django.urls import include, path
from moku.views.auth import LoginView, LogoutView
from moku.views.post import FeedView
from moku.views.recipe import (
DeleteRecipeView,
DeleteStepView,
EditStepView,
IndexRecipeView,
NewRecipeView,
ShowRecipeView,
)
from moku.views.static import ChangelogView, PrivacyView, TermsView
from moku.views.user import EditProfileView, EditSettingsView, ProfileView, SignupView
@ -36,6 +44,12 @@ urlpatterns = [
path("privacy", PrivacyView.as_view(), name="privacy"),
path("terms", TermsView.as_view(), name="terms"),
path("user/<str:username>", ProfileView.as_view(), name="profile"),
path("recipes", IndexRecipeView.as_view(), name="recipe.index"),
path("recipes/new", NewRecipeView.as_view(), name="recipe.new"),
path("recipes/<str:uuid>", ShowRecipeView.as_view(), name="recipe.show"),
path("recipes/<str:uuid>/delete", DeleteRecipeView.as_view(), name="recipe.delete"),
path("recipes/<str:uuid>/<str:step>", EditStepView.as_view(), name="step.edit"),
path("recipes/<str:uuid>/<str:step>/delete", DeleteStepView.as_view(), name="step.delete"),
]
if settings.DEBUG_TOOLBAR:

View file

@ -7,10 +7,11 @@ from moku.models import Post
class PostForm(ModelForm):
class Meta:
model = Post
fields = ("emoji", "verb", "food", "image")
fields = ("emoji", "verb", "food", "recipe", "image")
labels = {
"emoji": _("emoji"),
"verb": _("verb"),
"food": _("food"),
"recipe": _("recipe"),
"image": _("image"),
}

19
moku/forms/recipe.py Normal file
View file

@ -0,0 +1,19 @@
from django.forms import Form, ModelForm
from django.utils.translation import gettext_lazy as _
from moku.models.recipe import Recipe, RecipeStep
class RecipeForm(ModelForm):
class Meta:
model = Recipe
fields = ("title",)
labels = {
"title": _("recipe title"),
}
class RecipeStepForm(ModelForm):
class Meta:
model = RecipeStep
fields = ("instructions",)

View file

@ -0,0 +1,42 @@
# Generated by Django 5.0.3 on 2024-03-25 21:09
import django.db.models.deletion
import shortuuid.django_fields
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('moku', '0005_usersettings'),
]
operations = [
migrations.CreateModel(
name='Recipe',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('uuid', shortuuid.django_fields.ShortUUIDField(alphabet=None, help_text='the unique id that identifies this recipe.', length=22, max_length=22, prefix='', verbose_name='unique id')),
('title', models.CharField(help_text='give the recipe a title, just so you know the recipe is for.', max_length=64, verbose_name='recipe title')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('created_by', models.ForeignKey(db_column='created_by_user_id', on_delete=django.db.models.deletion.CASCADE, related_name='recipes', to=settings.AUTH_USER_MODEL)),
],
),
migrations.AddField(
model_name='post',
name='recipe',
field=models.ForeignKey(blank=True, help_text='the recipe for what you ate, if you have one.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='moku.recipe', verbose_name='recipe'),
),
migrations.CreateModel(
name='RecipeStep',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('uuid', shortuuid.django_fields.ShortUUIDField(alphabet=None, help_text='the unique id that identifies this step.', length=22, max_length=22, prefix='', verbose_name='step id')),
('instructions', models.CharField(help_text='the instructions for this step of the recipe. try to keep it clear and concise!', max_length=128, verbose_name='step instructions')),
('order', models.IntegerField(db_index=True, default=0, help_text='which step in the recipe is this. this affects the order the recipe steps are shown.', verbose_name='step number')),
('recipe', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='steps', to='moku.recipe')),
],
),
]

View file

@ -1,8 +1,12 @@
from moku.models.post import Post
from moku.models.user import User
from moku.models.recipe import Recipe, RecipeStep
from moku.models.user import User, UserSettings
__all__ = [
"Post",
"Recipe",
"RecipeStep",
"User",
"UserSettings",
]

View file

@ -4,6 +4,7 @@ from django.utils.translation import gettext_lazy as _
from shortuuid.django_fields import ShortUUIDField
from moku.constants import Verbs
from moku.models.recipe import RecipeStep
from moku.validators import validate_emoji
@ -11,6 +12,15 @@ def post_image_filename(instance, _):
return f"posts/{instance.created_by.username}__{instance.uuid}.webp"
class PostManager(models.Manager):
def get_queryset(self):
return super().get_queryset().select_related("created_by") \
.prefetch_related(
models.Prefetch("recipe__steps", queryset=RecipeStep.objects.order_by("order"))
) \
.order_by("-created_at")
class Post(models.Model):
uuid = ShortUUIDField(
verbose_name=_("unique id"),
@ -41,6 +51,15 @@ class Post(models.Model):
upload_to=post_image_filename,
help_text=_("here you can upload a picture of what you ate!"),
)
recipe = models.ForeignKey(
"Recipe",
verbose_name=_("recipe"),
blank=True,
null=True,
related_name="+",
on_delete=models.SET_NULL,
help_text=_("the recipe for what you ate, if you have one."),
)
created_by = models.ForeignKey(
"User",
related_name="posts",
@ -57,6 +76,8 @@ class Post(models.Model):
help_text=_("when this post was last updated."),
)
objects = PostManager()
def __str__(self):
return f"{self.text} on {self.created_at}"

76
moku/models/recipe.py Normal file
View file

@ -0,0 +1,76 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
from django.urls import reverse
from shortuuid.django_fields import ShortUUIDField
class RecipeManager(models.Manager):
def get_queryset(self):
return super().get_queryset() \
.prefetch_related(
models.Prefetch("steps", queryset=RecipeStep.objects.order_by("order"))
)
class Recipe(models.Model):
uuid = ShortUUIDField(
verbose_name=_("unique id"),
max_length=22,
length=22,
help_text=_("the unique id that identifies this recipe."),
)
title = models.CharField(
verbose_name=_("recipe title"),
max_length=64,
help_text=_("give the recipe a title, just so you know the recipe is for."),
)
created_by = models.ForeignKey(
"User",
related_name="recipes",
db_index=True,
db_column="created_by_user_id",
on_delete=models.CASCADE,
)
created_at = models.DateTimeField(
auto_now_add=True,
)
updated_at = models.DateTimeField(
auto_now=True,
)
objects = RecipeManager()
def __str__(self):
return f"{self.title} by @{self.created_by.username}"
def get_absolute_url(self):
return reverse("recipe.show", kwargs={"uuid": self.uuid})
class RecipeStep(models.Model):
uuid = ShortUUIDField(
verbose_name=_("step id"),
max_length=22,
length=22,
help_text=_("the unique id that identifies this step."),
)
instructions = models.CharField(
verbose_name=_("step instructions"),
max_length=128,
help_text=_("the instructions for this step of the recipe. try to keep it clear and concise!"),
)
order = models.IntegerField(
verbose_name=_("step number"),
default=0,
db_index=True,
help_text=_("which step in the recipe is this. this affects the order the recipe steps are shown."),
)
recipe = models.ForeignKey(
"Recipe",
related_name="steps",
db_index=True,
on_delete=models.CASCADE,
)
def __str__(self):
return f"step #{self.order + 1} of {self.recipe}"

View file

@ -39,6 +39,14 @@ body {
margin-block-end: 1.6rem;
}
.small {
font-size: 1.4rem;
}
.subtle {
color: var(--charcoal);
}
.messages {
margin-block-end: 1.6rem;
display: grid;
@ -150,6 +158,12 @@ form .field {
row-gap: .4rem;
}
form .field .field-button {
display: grid;
grid-template-columns: 1fr auto;
column-gap: .8rem;
}
form .checkbox {
display: grid;
grid-template-columns: auto 1fr;
@ -341,13 +355,21 @@ header nav ul {
margin-block-start: 1.2rem;
}
.block ul {
.block ul,
.block ol {
padding-inline-start: 3.2rem;
list-style: disc;
display: grid;
row-gap: .4rem;
}
.block ul {
list-style: disc;
}
.block ol {
list-style: decimal;
}
.block strong {
font-weight: bold;
}

View file

@ -14,7 +14,8 @@
<ul>
{% if request.user.is_authenticated %}
<li><a href="{{ url('feed') }}">{% trans %}feed{% endtrans %}</a></li>
<li><a href="{{ url('profile', username=request.user.username) }}">{% trans %}my profile{% endtrans %}</a></li>
<li><a href="{{ url('recipe.index') }}">{% trans %}recipes{% endtrans %}</a></li>
<li><a href="{{ url('profile', username=request.user.username) }}">{% trans %}profile{% endtrans %}</a></li>
<li><a href="{{ url('settings') }}">{% trans %}settings{% endtrans %}</a></li>
<li>
<form action="{{ url('logout') }}" method="POST" class="logout">

View file

@ -38,6 +38,11 @@
</select>
<span class="help" id="help_verb">{{ form.verb.help_text }}</span>
</div>
<div class="field">
<label for="id_recipe">{{ form.recipe.label }}</label>
{{ form.recipe }}
<span class="help">{{ form.recipe.help_text }}</span>
</div>
<div class="field">
<label for="id_image">{{ form.image.label }}</label>
{{ form.image }}

View file

@ -0,0 +1,16 @@
{% extends "moku/base.jinja" %}
{% block content %}
<div class="content">
<form action="" method="POST" class="auth">
{% include "moku/snippets/form_errors.jinja" %}
{% csrf_token %}
<div class="field">
<label for="id_instructions">{% trans %}instructions{% endtrans %}</label>
{{ form.instructions }}
<span class="help">{{ form.instructions.help_text }}</span>
</div>
<button type="submit">{% trans %}update!{% endtrans %}</button>
</form>
</div>
{% endblock content %}

View file

@ -0,0 +1,16 @@
{% extends "moku/base.jinja" %}
{% block content %}
<div class="content">
<form action="" method="POST" class="auth">
{% include "moku/snippets/form_errors.jinja" %}
{% csrf_token %}
<div class="field">
<label for="id_title">{{ form.title.label }}</label>
{{ form.title }}
<span class="help">{{ form.title.help_text }}</span>
</div>
<button type="submit">{% trans %}create!{% endtrans %}</button>
</form>
</div>
{% endblock content %}

View file

@ -0,0 +1,16 @@
{% extends "moku/base.jinja" %}
{% block content %}
<div class="content block">
<p class="mb">👏 <a href="{{ url('recipe.new') }}">new recipe</a></p>
<h2>your recipes</h2>
<ul>
{% if not recipes %}
<li>{% trans %}no recipes yet... 😴{% endtrans %}</li>
{% endif %}
{% for recipe in recipes %}
<li><a href="{{ recipe.get_absolute_url() }}">{{ recipe.title }}</a></li>
{% endfor %}
</ul>
</div>
{% endblock content %}

View file

@ -0,0 +1,43 @@
{% extends "moku/base.jinja" %}
{% block content %}
<div class="content block">
{% if request.user.id == recipe.created_by.id %}
<p class="mb">🗑️ <a href="{{ url('recipe.delete', uuid=recipe.uuid) }}">{% trans %}delete recipe{% endtrans %}</a></p>
{% endif %}
<h2>{{ recipe.title }}</h2>
<ol>
{% set count = recipe.steps.count() %}
{% for step in recipe.steps.all() %}
<li>
{{ step.instructions }}
{% if request.user.is_authenticated and request.user.id == recipe.created_by.id %}
<span class="small subtle">
[<a href="{{ url('step.edit', uuid=recipe.uuid, step=step.uuid) }}">{% trans %}edit{% endtrans %}</a>]
{% if loop.index0 == (count-1) %}
[<a href="{{ url('step.delete', uuid=recipe.uuid, step=step.uuid) }}">{% trans %}delete{% endtrans %}</a>]
{% endif %}
</span>
{% endif %}
</li>
{% endfor %}
{% if request.user.is_authenticated and request.user.id == recipe.created_by.id %}
{% if count < 16 %}
<li>
<form action="" method="POST">
{% csrf_token %}
<div class="field">
<label for="id_instructions">👉 <strong>{% trans %}add a step{% endtrans %}</strong></label>
<div class="field-button">
{{ form.instructions }}
<button type="submit">{% trans %}add!{% endtrans %}</button>
</div>
<span class="help">{{ form.instructions.help_text }}</span>
</div>
</form>
</li>
{% endif %}
{% endif %}
</ol>
</div>
{% endblock content %}

View file

@ -10,6 +10,15 @@
<div class="body">
<p class="food">{{ post.text|safe }}</p>
<p class="metadata">{{ post.created_at|naturaltime }}</p>
<!-- <details class="recipe"><summary>recipe</summary><ol>...</ol></details> -->
{% if post.recipe %}
<details class="recipe">
<summary>recipe</summary>
<ol>
{% for step in post.recipe.steps.all() %}
<li>{{ step.instructions }}</li>
{% endfor %}
</ol>
</details>
{% endif %}
</div>
</article>

View file

@ -27,7 +27,7 @@ class FeedView(FormView):
def get_context_data(self, **kwargs):
context = {
**super().get_context_data(**kwargs),
"posts": Post.objects.order_by("-created_at").all()[:128]
"posts": Post.objects.all()[:128]
}
if self.request.user.is_authenticated:
return self.get_authenticated_context_data(context)

121
moku/views/recipe.py Normal file
View file

@ -0,0 +1,121 @@
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.shortcuts import get_object_or_404, redirect
from django.views.generic import FormView, TemplateView
from django.utils.functional import cached_property
from django.utils.translation import gettext as _
from moku.forms.recipe import RecipeForm, RecipeStepForm
from moku.models.recipe import Recipe, RecipeStep
class DeleteRecipeView(LoginRequiredMixin, UserPassesTestMixin, TemplateView):
def get(self, request, *args, **kwargs):
self.recipe.delete()
messages.success(self.request, _("recipe deleted successfully!"))
return redirect("recipe.index")
@cached_property
def recipe(self):
return get_object_or_404(
Recipe,
uuid=self.kwargs.get("uuid"),
)
def test_func(self):
return self.request.user.id == self.recipe.created_by.id
class DeleteStepView(LoginRequiredMixin, UserPassesTestMixin, TemplateView):
def get(self, request, *args, **kwargs):
if self.step.order != (self.step.recipe.steps.count() - 1):
messages.error(self.request, _("sorry! you can only delete the last step."))
return redirect(self.step.recipe.get_absolute_url())
self.step.delete()
messages.success(self.request, _("step deleted!"))
return redirect(self.step.recipe.get_absolute_url())
@cached_property
def step(self):
return get_object_or_404(
RecipeStep,
recipe__uuid=self.kwargs.get("uuid"),
uuid=self.kwargs.get("step"),
)
def test_func(self):
return self.request.user.id == self.step.recipe.created_by.id
class EditStepView(LoginRequiredMixin, UserPassesTestMixin, FormView):
template_name = "moku/recipe/edit_step.jinja"
form_class = RecipeStepForm
def form_valid(self, form):
form.save()
messages.success(self.request, _("step updated!"))
return redirect(self.step.recipe.get_absolute_url())
def get_form(self):
return self.form_class(instance=self.step, **self.get_form_kwargs())
@cached_property
def step(self):
return get_object_or_404(
RecipeStep,
recipe__uuid=self.kwargs.get("uuid"),
uuid=self.kwargs.get("step"),
)
def test_func(self):
return self.request.user.id == self.step.recipe.created_by.id
class IndexRecipeView(LoginRequiredMixin, TemplateView):
template_name = "moku/recipe/index.jinja"
def get_context_data(self, **kwargs):
return {
**super().get_context_data(**kwargs),
"recipes": Recipe.objects.filter(created_by=self.request.user).order_by("-created_by"),
}
class NewRecipeView(LoginRequiredMixin, FormView):
template_name = "moku/recipe/form.jinja"
form_class = RecipeForm
def form_valid(self, form):
form.instance.created_by = self.request.user
form.save()
messages.success(self.request, _("recipe made successfully! now you can start adding the steps."))
return redirect(form.instance.get_absolute_url())
class ShowRecipeView(FormView):
template_name = "moku/recipe/show.jinja"
form_class = RecipeStepForm
@cached_property
def recipe(self):
return Recipe.objects.get(uuid=self.kwargs.get("uuid"))
def form_valid(self, form):
if not self.request.user.is_authenticated or self.request.user.id != self.recipe.created_by.id:
messages.error(self.request, _("that's not yours!"))
return redirect(form.instance.get_absolute_url())
order = self.recipe.steps.count()
if order >= 16:
messages.error(self.request, _("sorry! you can't add any more steps to this recipe."))
return redirect(self.recipe.get_absolute_url())
form.instance.recipe = self.recipe
form.instance.order = order
form.save()
messages.success(self.request, _("step added successfully!"))
return redirect(self.recipe.get_absolute_url())
def get_context_data(self, **kwargs):
return {
**super().get_context_data(**kwargs),
"recipe": self.recipe,
}