feat: added recipes project

main
Jonathan Peacher 2023-06-20 14:52:13 -05:00
rodzic b123426ca0
commit 2f7eba43d4
Nie znaleziono w bazie danych klucza dla tego podpisu
25 zmienionych plików z 453 dodań i 7 usunięć

Wyświetl plik

@ -46,6 +46,8 @@ INSTALLED_APPS = [
'allauth',
'allauth.account',
'allauth.socialaccount',
'recipes',
'neapolitan',
]
MIDDLEWARE = [

Wyświetl plik

@ -22,5 +22,6 @@ from django.views.generic.base import TemplateView
urlpatterns = [
path('', TemplateView.as_view(template_name="projects.html")),
path('accounts/', include('allauth.urls')),
path('recipes/', include('recipes.views')),
path(settings.ADMIN_URL, admin.site.urls),
]

Wyświetl plik

50
recipes/admin.py 100644
Wyświetl plik

@ -0,0 +1,50 @@
from django.contrib import admin
from django.db.models import Sum
from django.http import HttpResponse
from .views import Aisle, Unit, Recipe, Ingredient
@admin.register(Unit)
class UnitAdmin(admin.ModelAdmin):
list_display = ('name', 'user')
@admin.register(Aisle)
class AisleAdmin(admin.ModelAdmin):
list_display = ('name', 'user')
class IngredientInline(admin.TabularInline):
model = Ingredient
extra = 0
@admin.action(description="Generate shopping list")
def generate_shopping_list(modeladmin, request, queryset):
ingredients = Ingredient.objects \
.filter(recipe__in=queryset) \
.values('name', 'unit__name', 'aisle__name') \
.annotate(total_quantity=Sum('quantity')) \
.order_by('aisle__name', 'name')
response = ""
for i in ingredients:
if i['aisle__name']:
response += f"({i['aisle__name']}) "
response += f"{i['total_quantity']} {i['unit__name']} {i['name']}\n"
return HttpResponse(response, content_type='text/plain')
@admin.register(Recipe)
class RecipeAdmin(admin.ModelAdmin):
inlines = [IngredientInline]
actions = [generate_shopping_list]
list_display = ('name', 'ingredient_count')
def ingredient_count(self, obj):
return obj.ingredient_set.all().count()
def get_queryset(self, request):
queryset = super().get_queryset(request)
return queryset.prefetch_related('ingredient_set')

6
recipes/apps.py 100644
Wyświetl plik

@ -0,0 +1,6 @@
from django.apps import AppConfig
class RecipesConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'recipes'

Wyświetl plik

@ -0,0 +1,51 @@
# Generated by Django 4.2.2 on 2023-06-19 18:57
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Aisle',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=128)),
],
),
migrations.CreateModel(
name='Unit',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=16)),
],
),
migrations.CreateModel(
name='Recipe',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=128)),
('note', models.TextField(blank=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='Ingredient',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.DecimalField(decimal_places=2, default=0, max_digits=5)),
('name', models.CharField(max_length=128)),
('aisle', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='recipes.aisle')),
('recipe', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='recipes.recipe')),
('unit', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='recipes.unit')),
],
),
]

Wyświetl plik

@ -0,0 +1,38 @@
# Generated by Django 4.2.2 on 2023-06-19 20:07
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('recipes', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='aisle',
name='user',
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
preserve_default=False,
),
migrations.AddField(
model_name='unit',
name='user',
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
preserve_default=False,
),
migrations.AlterField(
model_name='ingredient',
name='quantity',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True),
),
migrations.AlterField(
model_name='ingredient',
name='unit',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='recipes.unit'),
),
]

44
recipes/models.py 100644
Wyświetl plik

@ -0,0 +1,44 @@
from django.db import models
from django.conf import settings
# HACK: There must be a better way to do this, but this returns __str__ output
# from the foreign key object instead of the raw id.
class CustomForeignKey(models.ForeignKey):
def value_to_string(self, obj):
return str(getattr(obj, obj._meta.get_field(self.attname).name))
class Unit(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
name = models.CharField(max_length=16)
def __str__(self):
return self.name
class Aisle(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
name = models.CharField(max_length=128)
def __str__(self):
return self.name
class Recipe(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
name = models.CharField(max_length=128)
note = models.TextField(blank=True)
def __str__(self):
return self.name
class Ingredient(models.Model):
name = models.CharField(max_length=128)
quantity = models.DecimalField(max_digits=5, decimal_places=2, blank=True, null=True)
unit = CustomForeignKey(Unit, on_delete=models.SET_NULL, blank=True, null=True)
aisle = CustomForeignKey(Aisle, on_delete=models.SET_NULL, blank=True, null=True)
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
def __str__(self):
ingredient = ""
if self.aisle:
ingredient += f"({self.aisle}) "
ingredient += f"{self.quantity} {self.unit} {self.name}"
return ingredient

Wyświetl plik

@ -0,0 +1,5 @@
{% extends 'index.html'%}
{% block index_main %}
{% block content %}{% endblock %}
{% endblock %}

Wyświetl plik

@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block content %}
<h1>Confirm Delete?</h1>
<p>{{ object }}</p>
<form method="POST" action="{{ delete_view_url }}">
{% csrf_token %}
{{ form }}
<button type="submit">Delete</button>
<a class="button" href="{% url object_verbose_name|add:'-list' %}">Cancel</a>
</form>
{% endblock %}

Wyświetl plik

@ -0,0 +1,8 @@
{% extends "base.html" %}
{% load neapolitan %}
{% block content %}
<a href="{% url object_verbose_name|add:'-list' %}">← View all {{ object_verbose_name_plural }}</a>
<h1>{{ object }}</h1>
{% object_detail object view.fields %}
{% endblock %}

Wyświetl plik

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block content %}
<h1>{% if object %}Edit {{ object_verbose_name }}{% else %}Create {{ object_verbose_name }}{% endif %}</h1>
<form method="POST" {% if form.is_multipart %}enctype="multipart/form-data" {% endif %}
action="{% if object %}{{ update_view_url }}{% else %}{{ create_view_url }}{% endif %}" class="dl-form">
{% csrf_token %}
{{ form }}
<br>
<button type="submit">Save</button>
<a class="button" href="{% url object_verbose_name|add:'-list' %}">Cancel</a>
</form>
{% endblock %}

Wyświetl plik

@ -0,0 +1,26 @@
{% extends "base.html" %}
{% load neapolitan %}
{% block index_header %}
<header>
{% block title %}
<h1>{{ object_verbose_name_plural|capfirst }}</h1>
{% endblock %}
<div>
{% block actions %}
<a href="{{ create_view_url }}">Add a new {{ object_verbose_name }}</a>
{% block extra_actions %}{% endblock %}
{% endblock %}
</div>
</header>
{% endblock %}
{% block content %}
{% block extra_top_content %}{% endblock %}
{% if object_list %}
{% object_list object_list view.fields %}
{% else %}
<p>There are no {{ object_verbose_name_plural }}. Create one now?</p>
{% endif %}
{% block extra_bottom_content %}{% endblock %}
{% endblock %}

Wyświetl plik

@ -0,0 +1,22 @@
<table x-data="{ all: false }">
<thead>
<tr>
<th><input type="checkbox" x-on:click="all=!all"></th>
{% for header in headers %}
<th scope="col">{{ header|capfirst }}</th>
{% endfor %}
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
{% for object in object_list %}
<tr>
<td><input type="checkbox" name="id" value="{{ object.object.id }}" x-bind:checked="all"></td>
{% for field in object.fields %}
<td>{{ field }}</td>
{% endfor %}
<td>{{ object.actions }}</td>
</tr>
{% endfor %}
</tbody>
</table>

Wyświetl plik

@ -0,0 +1,5 @@
{% extends 'neapolitan/object_list.html' %}
{% block extra_actions %}
<br><a href="{% url 'recipe-list' %}">← Back to recipes</a>
{% endblock %}

Wyświetl plik

@ -0,0 +1,13 @@
{% extends 'index.html' %}
{% block index_header %}
<header>
<h1>Recipes</h1>
<p>TK TK TK</p>
</header>
{% endblock %}
{% block index_main %}
<p>TK TK TK</p>
<a href="{% url 'recipe-list' %}" class="button">Get Started →</a>
{% endblock %}

Wyświetl plik

@ -0,0 +1,13 @@
{% extends 'neapolitan/object_list.html' %}
{% block title %}
<h1>{{ recipe.name }}</h1>
{% endblock %}
{% block extra_actions %}
<br><a href="{% url 'recipe-list' %}">← View all recipes</a>
{% endblock %}
{% block extra_top_content %}
<p class="notice">{{ recipe.note }}</p>
{% endblock %}

Wyświetl plik

@ -0,0 +1,11 @@
{% extends 'neapolitan/object_list.html' %}
{% block extra_actions %}
<br><a href="" hx-get="{% url 'shopping-list' %}" hx-include="[name='id']" hx-target="#shopping-list">Generate shopping list</a>
<br><a href="{% url 'unit-list' %}">Manage units</a>
<br><a href="{% url 'aisle-list' %}">Manage aisles</a>
{% endblock %}
{% block extra_top_content %}
<div id="shopping-list"></div>
{% endblock %}

Wyświetl plik

@ -0,0 +1,9 @@
{% if ingredients %}
<code>
<pre>{% for i in ingredients %}{% if i.aisle__name %}({{ i.aisle__name }}) {% endif %}{% if i.total_quantity %}{{ i.total_quantity }} {% endif %}{% if i.unit__name %}{{ i.unit__name }} {% endif %}{{ i.name }}<br>{% endfor %}</pre>
</code>
{% elif request.GET %}
<p>There are no ingredients from the selected recipes.</p>
{% else %}
<p>Select one or more recipes to generate shopping list.</p>
{% endif %}

Wyświetl plik

@ -0,0 +1,5 @@
{% extends 'neapolitan/object_list.html' %}
{% block extra_actions %}
<br><a href="{% url 'recipe-list' %}">← Back to recipes</a>
{% endblock %}

108
recipes/views.py 100644
Wyświetl plik

@ -0,0 +1,108 @@
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import Sum
from django.shortcuts import render, redirect
from django.urls import path
from django.views.generic import TemplateView
from neapolitan.views import CRUDView
from .models import Aisle, Unit, Recipe, Ingredient
class UnitView(LoginRequiredMixin, CRUDView):
model = Unit
fields = ["name"]
def get_queryset(self):
return Unit.objects.filter(user=self.request.user)
def form_valid(self, form):
self.object = form.save(commit=False)
self.object.user = self.request.user
self.object.save()
return redirect("unit-list")
class AisleView(LoginRequiredMixin, CRUDView):
model = Aisle
fields = ["name"]
def get_queryset(self):
return Aisle.objects.filter(user=self.request.user)
def form_valid(self, form):
self.object = form.save(commit=False)
self.object.user = self.request.user
self.object.save()
return redirect("aisle-list")
class RecipeView(LoginRequiredMixin, CRUDView):
model = Recipe
fields = ["name", "note"]
def get_queryset(self):
return Recipe.objects.filter(user=self.request.user)
def list(self, request, *args, **kwargs):
self.fields = ["name"]
return super().list(request, *args, **kwargs)
def detail(self, request, *args, **kwargs):
self.object = self.get_object()
self.request.session['recipe'] = self.object.id
return redirect("ingredient-list")
def form_valid(self, form):
self.object = form.save(commit=False)
self.object.user = self.request.user
self.object.save()
return redirect("recipe-list")
class IngredientView(LoginRequiredMixin, CRUDView):
model = Ingredient
fields = ["name", "quantity", "unit", "aisle"]
def get_queryset(self):
recipe, user = self.request.session['recipe'], self.request.user
return Ingredient.objects.filter(recipe=recipe, recipe__user=user)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['recipe'] = Recipe.objects.get(id=self.request.session['recipe'])
return context
def get_form(self, *args, **kwargs):
form = super().get_form(*args, **kwargs)
form.fields['unit'].queryset = Unit.objects.filter(user=self.request.user)
form.fields['aisle'].queryset = Aisle.objects.filter(user=self.request.user)
return form
def form_valid(self, form):
self.object = form.save(commit=False)
self.object.recipe_id = self.request.session['recipe']
self.object.save()
return redirect("ingredient-list")
@login_required
def shopping_list(request):
# https://docs.djangoproject.com/en/4.2/topics/db/aggregation/#values
return render(request, "recipes/shopping_list.html", {
'ingredients': Ingredient.objects \
.filter(recipe__user=request.user) \
.filter(recipe__in=request.GET.getlist("id")) \
.values('name', 'unit__name', 'aisle__name') \
.annotate(total_quantity=Sum('quantity')) \
.order_by('aisle__name', 'name')}
)
urlpatterns = [
path('', TemplateView.as_view(template_name="recipes/index.html")),
path('shopping-list/', shopping_list, name="shopping-list"),
*RecipeView.get_urls(),
*IngredientView.get_urls(),
*AisleView.get_urls(),
*UnitView.get_urls(),
]

Wyświetl plik

@ -2,4 +2,6 @@ Django==4.2.2
django-allauth==0.54.0
dj-database-url==2.0.0
psycopg2-binary==2.9.6
gunicorn==20.1.0
gunicorn==20.1.0
django-filter==23.2
neapolitan==23.11

Wyświetl plik

@ -40,11 +40,11 @@
</head>
<body>
{% block header %}{% endblock %}
{% block index_header %}{% endblock %}
<main>
{% block main %}{% endblock %}
{% block index_main %}{% endblock %}
</main>
{% block footer %}{% endblock %}
{% block index_footer %}{% endblock %}
</body>
</html>

Wyświetl plik

@ -1,6 +1,6 @@
{% extends 'index.html' %}
{% block header %}
{% block index_header %}
<header>
<nav></nav>
<h1>Jonathan Peacher</h1>
@ -13,7 +13,9 @@
</header>
{% endblock %}
{% block main %}
{% block index_main %}
<h2>Projects</h1>
<p>TK TK TK</p>
<ul>
<li><a href="/recipes/">Recipes</a></li>
</ul>
{% endblock %}