From 6f3ed061b4e466e4b4b69a83ce8b1993d128a987 Mon Sep 17 00:00:00 2001 From: m5ka Date: Mon, 25 Mar 2024 21:46:47 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=F0=9F=93=98=20add=20recipes=20to=20pos?= =?UTF-8?q?ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- moku/config/urls.py | 14 ++ moku/forms/post.py | 3 +- moku/forms/recipe.py | 19 +++ .../0006_recipe_post_recipe_recipestep.py | 42 ++++++ moku/models/__init__.py | 6 +- moku/models/post.py | 21 +++ moku/models/recipe.py | 76 +++++++++++ moku/static/css/moku.css | 26 +++- moku/templates/moku/base.jinja | 3 +- moku/templates/moku/feed.jinja | 5 + moku/templates/moku/recipe/edit_step.jinja | 16 +++ moku/templates/moku/recipe/form.jinja | 16 +++ moku/templates/moku/recipe/index.jinja | 16 +++ moku/templates/moku/recipe/show.jinja | 43 +++++++ moku/templates/moku/snippets/post.jinja | 11 +- moku/views/post.py | 2 +- moku/views/recipe.py | 121 ++++++++++++++++++ 17 files changed, 433 insertions(+), 7 deletions(-) create mode 100644 moku/forms/recipe.py create mode 100644 moku/migrations/0006_recipe_post_recipe_recipestep.py create mode 100644 moku/models/recipe.py create mode 100644 moku/templates/moku/recipe/edit_step.jinja create mode 100644 moku/templates/moku/recipe/form.jinja create mode 100644 moku/templates/moku/recipe/index.jinja create mode 100644 moku/templates/moku/recipe/show.jinja create mode 100644 moku/views/recipe.py diff --git a/moku/config/urls.py b/moku/config/urls.py index eff90d4..eb9db44 100644 --- a/moku/config/urls.py +++ b/moku/config/urls.py @@ -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/", ProfileView.as_view(), name="profile"), + path("recipes", IndexRecipeView.as_view(), name="recipe.index"), + path("recipes/new", NewRecipeView.as_view(), name="recipe.new"), + path("recipes/", ShowRecipeView.as_view(), name="recipe.show"), + path("recipes//delete", DeleteRecipeView.as_view(), name="recipe.delete"), + path("recipes//", EditStepView.as_view(), name="step.edit"), + path("recipes///delete", DeleteStepView.as_view(), name="step.delete"), ] if settings.DEBUG_TOOLBAR: diff --git a/moku/forms/post.py b/moku/forms/post.py index 9006a54..3efafa2 100644 --- a/moku/forms/post.py +++ b/moku/forms/post.py @@ -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"), } diff --git a/moku/forms/recipe.py b/moku/forms/recipe.py new file mode 100644 index 0000000..c90e56d --- /dev/null +++ b/moku/forms/recipe.py @@ -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",) diff --git a/moku/migrations/0006_recipe_post_recipe_recipestep.py b/moku/migrations/0006_recipe_post_recipe_recipestep.py new file mode 100644 index 0000000..8a8a406 --- /dev/null +++ b/moku/migrations/0006_recipe_post_recipe_recipestep.py @@ -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')), + ], + ), + ] diff --git a/moku/models/__init__.py b/moku/models/__init__.py index 4fa1ead..f94d6c2 100644 --- a/moku/models/__init__.py +++ b/moku/models/__init__.py @@ -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", ] diff --git a/moku/models/post.py b/moku/models/post.py index 43b62ec..88771e9 100644 --- a/moku/models/post.py +++ b/moku/models/post.py @@ -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}" diff --git a/moku/models/recipe.py b/moku/models/recipe.py new file mode 100644 index 0000000..00ef78f --- /dev/null +++ b/moku/models/recipe.py @@ -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}" diff --git a/moku/static/css/moku.css b/moku/static/css/moku.css index 5f3beac..e0402b0 100644 --- a/moku/static/css/moku.css +++ b/moku/static/css/moku.css @@ -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; } diff --git a/moku/templates/moku/base.jinja b/moku/templates/moku/base.jinja index 4549a8f..a9ccd3b 100644 --- a/moku/templates/moku/base.jinja +++ b/moku/templates/moku/base.jinja @@ -14,7 +14,8 @@
    {% if request.user.is_authenticated %}
  • {% trans %}feed{% endtrans %}
  • -
  • {% trans %}my profile{% endtrans %}
  • +
  • {% trans %}recipes{% endtrans %}
  • +
  • {% trans %}profile{% endtrans %}
  • {% trans %}settings{% endtrans %}
  • diff --git a/moku/templates/moku/feed.jinja b/moku/templates/moku/feed.jinja index daba1a2..b5c6d38 100644 --- a/moku/templates/moku/feed.jinja +++ b/moku/templates/moku/feed.jinja @@ -38,6 +38,11 @@ {{ form.verb.help_text }} +
    + + {{ form.recipe }} + {{ form.recipe.help_text }} +
    {{ form.image }} diff --git a/moku/templates/moku/recipe/edit_step.jinja b/moku/templates/moku/recipe/edit_step.jinja new file mode 100644 index 0000000..3ba9124 --- /dev/null +++ b/moku/templates/moku/recipe/edit_step.jinja @@ -0,0 +1,16 @@ +{% extends "moku/base.jinja" %} + +{% block content %} +
    + + {% include "moku/snippets/form_errors.jinja" %} + {% csrf_token %} +
    + + {{ form.instructions }} + {{ form.instructions.help_text }} +
    + + +
    +{% endblock content %} \ No newline at end of file diff --git a/moku/templates/moku/recipe/form.jinja b/moku/templates/moku/recipe/form.jinja new file mode 100644 index 0000000..2cfe0f5 --- /dev/null +++ b/moku/templates/moku/recipe/form.jinja @@ -0,0 +1,16 @@ +{% extends "moku/base.jinja" %} + +{% block content %} +
    +
    + {% include "moku/snippets/form_errors.jinja" %} + {% csrf_token %} +
    + + {{ form.title }} + {{ form.title.help_text }} +
    + +
    +
    +{% endblock content %} \ No newline at end of file diff --git a/moku/templates/moku/recipe/index.jinja b/moku/templates/moku/recipe/index.jinja new file mode 100644 index 0000000..5013d17 --- /dev/null +++ b/moku/templates/moku/recipe/index.jinja @@ -0,0 +1,16 @@ +{% extends "moku/base.jinja" %} + +{% block content %} +
    +

    👏 new recipe

    +

    your recipes

    +
      + {% if not recipes %} +
    • {% trans %}no recipes yet... 😴{% endtrans %}
    • + {% endif %} + {% for recipe in recipes %} +
    • {{ recipe.title }}
    • + {% endfor %} +
    +
    +{% endblock content %} \ No newline at end of file diff --git a/moku/templates/moku/recipe/show.jinja b/moku/templates/moku/recipe/show.jinja new file mode 100644 index 0000000..c095b50 --- /dev/null +++ b/moku/templates/moku/recipe/show.jinja @@ -0,0 +1,43 @@ +{% extends "moku/base.jinja" %} + +{% block content %} +
    + {% if request.user.id == recipe.created_by.id %} +

    🗑️ {% trans %}delete recipe{% endtrans %}

    + {% endif %} +

    {{ recipe.title }}

    +
      + {% set count = recipe.steps.count() %} + {% for step in recipe.steps.all() %} +
    1. + {{ step.instructions }} + {% if request.user.is_authenticated and request.user.id == recipe.created_by.id %} + + [{% trans %}edit{% endtrans %}] + {% if loop.index0 == (count-1) %} + [{% trans %}delete{% endtrans %}] + {% endif %} + + {% endif %} +
    2. + {% endfor %} + {% if request.user.is_authenticated and request.user.id == recipe.created_by.id %} + {% if count < 16 %} +
    3. +
      + {% csrf_token %} +
      + +
      + {{ form.instructions }} + +
      + {{ form.instructions.help_text }} +
      +
      +
    4. + {% endif %} + {% endif %} +
    +
    +{% endblock content %} \ No newline at end of file diff --git a/moku/templates/moku/snippets/post.jinja b/moku/templates/moku/snippets/post.jinja index 1261773..8428c43 100644 --- a/moku/templates/moku/snippets/post.jinja +++ b/moku/templates/moku/snippets/post.jinja @@ -10,6 +10,15 @@

    {{ post.text|safe }}

    - + {% if post.recipe %} +
    + recipe +
      + {% for step in post.recipe.steps.all() %} +
    1. {{ step.instructions }}
    2. + {% endfor %} +
    +
    + {% endif %}
    \ No newline at end of file diff --git a/moku/views/post.py b/moku/views/post.py index 0e135b7..7633dff 100644 --- a/moku/views/post.py +++ b/moku/views/post.py @@ -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) diff --git a/moku/views/recipe.py b/moku/views/recipe.py new file mode 100644 index 0000000..06c7588 --- /dev/null +++ b/moku/views/recipe.py @@ -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, + }