kopia lustrzana https://github.com/djpeacher/django-projects
feat: added recipes project
rodzic
b123426ca0
commit
2f7eba43d4
|
@ -46,6 +46,8 @@ INSTALLED_APPS = [
|
|||
'allauth',
|
||||
'allauth.account',
|
||||
'allauth.socialaccount',
|
||||
'recipes',
|
||||
'neapolitan',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
|
|
|
@ -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),
|
||||
]
|
||||
|
|
|
@ -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')
|
|
@ -0,0 +1,6 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class RecipesConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'recipes'
|
|
@ -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')),
|
||||
],
|
||||
),
|
||||
]
|
|
@ -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'),
|
||||
),
|
||||
]
|
|
@ -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
|
|
@ -0,0 +1,5 @@
|
|||
{% extends 'index.html'%}
|
||||
|
||||
{% block index_main %}
|
||||
{% block content %}{% endblock %}
|
||||
{% endblock %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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>
|
|
@ -0,0 +1,5 @@
|
|||
{% extends 'neapolitan/object_list.html' %}
|
||||
|
||||
{% block extra_actions %}
|
||||
<br><a href="{% url 'recipe-list' %}">← Back to recipes</a>
|
||||
{% endblock %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -0,0 +1,5 @@
|
|||
{% extends 'neapolitan/object_list.html' %}
|
||||
|
||||
{% block extra_actions %}
|
||||
<br><a href="{% url 'recipe-list' %}">← Back to recipes</a>
|
||||
{% endblock %}
|
|
@ -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(),
|
||||
]
|
|
@ -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
|
|
@ -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>
|
|
@ -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 %}
|
Ładowanie…
Reference in New Issue