Refactor price with database queries and start reports

This commit is contained in:
Gabriel Augendre 2022-04-25 18:37:26 +02:00
parent 53620a279a
commit 150632e9c5
17 changed files with 152 additions and 57 deletions

View file

@ -86,6 +86,7 @@ INSTALLED_APPS = [
"purchase", "purchase",
"crispy_forms", "crispy_forms",
"crispy_bootstrap5", "crispy_bootstrap5",
"django_extensions",
] ]
MIDDLEWARE = [ MIDDLEWARE = [

View file

@ -15,6 +15,9 @@
<li class="nav-item"> <li class="nav-item">
<a href="{% url "purchase:list" %}" class="nav-link">Baskets</a> <a href="{% url "purchase:list" %}" class="nav-link">Baskets</a>
</li> </li>
<li class="nav-item">
<a href="{% url "purchase:reports" %}" class="nav-link">Reports</a>
</li>
{% endif %} {% endif %}
{% if user.is_staff %} {% if user.is_staff %}
<li class="nav-item"> <li class="nav-item">

View file

@ -2,6 +2,7 @@ from django.contrib import admin
from django.contrib.admin import register from django.contrib.admin import register
from purchase.models import Basket, BasketItem, PaymentMethod, Product from purchase.models import Basket, BasketItem, PaymentMethod, Product
from purchase.templatetags.purchase import currency
@register(Product) @register(Product)
@ -10,14 +11,17 @@ class ProductAdmin(admin.ModelAdmin):
list_editable = ["display_order"] list_editable = ["display_order"]
search_fields = ["name"] search_fields = ["name"]
def get_queryset(self, request):
return super().get_queryset(request).with_sold().with_turnover()
def unit_price(self, instance: Product): def unit_price(self, instance: Product):
return instance.unit_price_display return currency(instance.unit_price_cents)
def sold(self, instance: Product): def sold(self, instance: Product):
return instance.sold return instance.sold
def turnover(self, instance: Product): def turnover(self, instance: Product):
return instance.turnover_display return currency(instance.turnover)
@register(PaymentMethod) @register(PaymentMethod)
@ -25,8 +29,11 @@ class PaymentMethodAdmin(admin.ModelAdmin):
list_display = ["name", "turnover"] list_display = ["name", "turnover"]
search_fields = ["name"] search_fields = ["name"]
def get_queryset(self, request):
return super().get_queryset(request).with_turnover()
def turnover(self, instance: Product): def turnover(self, instance: Product):
return instance.turnover_display return currency(instance.turnover)
class BasketItemInline(admin.TabularInline): class BasketItemInline(admin.TabularInline):
@ -35,18 +42,24 @@ class BasketItemInline(admin.TabularInline):
extra = 0 extra = 0
readonly_fields = ["price"] readonly_fields = ["price"]
def get_queryset(self, request):
return super().get_queryset(request).priced()
def price(self, instance) -> str: def price(self, instance) -> str:
return instance.price_display return currency(instance.price)
@register(Basket) @register(Basket)
class BasketAdmin(admin.ModelAdmin): class BasketAdmin(admin.ModelAdmin):
list_display = ["id", "payment_method", "created_at", "price"] list_display = ["id", "payment_method", "created_at", "price"]
fields = ["created_at", "payment_method"] fields = ["created_at", "payment_method", "price"]
list_filter = ["payment_method"] list_filter = ["payment_method"]
date_hierarchy = "created_at" date_hierarchy = "created_at"
readonly_fields = ["created_at"] readonly_fields = ["created_at", "price"]
inlines = [BasketItemInline] inlines = [BasketItemInline]
def get_queryset(self, request):
return super().get_queryset(request).priced()
def price(self, instance) -> str: def price(self, instance) -> str:
return instance.price_display return currency(instance.price)

View file

@ -2,7 +2,7 @@ from crispy_forms.layout import Field
class BasketItemField(Field): class BasketItemField(Field):
template = "purchase/basket_item.html" template = "purchase/snippets/basket_item.html"
def __init__(self, *args, product, **kwargs): def __init__(self, *args, product, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)

View file

@ -1,4 +1,5 @@
from django.db import models from django.db import models
from django.db.models import Count, F, Sum
from django.urls import reverse from django.urls import reverse
from PIL import Image, ImageOps from PIL import Image, ImageOps
@ -11,53 +12,53 @@ class Model(models.Model):
abstract = True abstract = True
class PaymentMethodQuerySet(models.QuerySet):
def with_turnover(self):
return self.annotate(
turnover=Sum(
F("baskets__items__quantity")
* F("baskets__items__product__unit_price_cents")
)
)
class PaymentMethod(Model): class PaymentMethod(Model):
name = models.CharField(max_length=50, unique=True) name = models.CharField(max_length=50, unique=True)
objects = PaymentMethodQuerySet.as_manager()
def __str__(self): def __str__(self):
return self.name return self.name
@property
def turnover(self) -> int:
return sum(basket.price for basket in self.baskets.all())
@property
def turnover_display(self) -> str:
return f"{self.turnover / 100}"
def default_product_display_order(): def default_product_display_order():
return Product.objects.last().display_order + 1 return Product.objects.last().display_order + 1
class ProductQuerySet(models.QuerySet):
def with_turnover(self):
return self.annotate(
turnover=Sum(F("basket_items__quantity") * F("unit_price_cents"))
)
def with_sold(self):
return self.annotate(sold=Sum("basket_items__quantity"))
class Product(Model): class Product(Model):
name = models.CharField(max_length=250, unique=True) name = models.CharField(max_length=250, unique=True)
image = models.ImageField(null=True, blank=True) image = models.ImageField(null=True, blank=True)
unit_price_cents = models.PositiveIntegerField() unit_price_cents = models.PositiveIntegerField()
display_order = models.PositiveIntegerField(default=default_product_display_order) display_order = models.PositiveIntegerField(default=default_product_display_order)
objects = ProductQuerySet.as_manager()
class Meta: class Meta:
ordering = ["display_order", "name"] ordering = ["display_order", "name"]
def __str__(self): def __str__(self):
return self.name return self.name
@property
def unit_price_display(self) -> str:
return f"{self.unit_price_cents / 100}"
@property
def turnover(self) -> int:
return sum(items.price for items in self.basket_items.all())
@property
def turnover_display(self) -> str:
return f"{self.turnover / 100}"
@property
def sold(self):
return sum(items.quantity for items in self.basket_items.all())
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
super().save() super().save()
with Image.open(self.image.path) as img: with Image.open(self.image.path) as img:
@ -92,6 +93,13 @@ class Product(Model):
img.save(self.image.path) img.save(self.image.path)
class BasketQuerySet(models.QuerySet):
def priced(self):
return self.annotate(
price=Sum(F("items__quantity") * F("items__product__unit_price_cents"))
)
class Basket(Model): class Basket(Model):
payment_method = models.ForeignKey( payment_method = models.ForeignKey(
to=PaymentMethod, to=PaymentMethod,
@ -101,22 +109,20 @@ class Basket(Model):
blank=True, blank=True,
) )
objects = BasketQuerySet.as_manager()
def __str__(self): def __str__(self):
return f"Panier #{self.id}" return f"Panier #{self.id}"
@property
def price(self) -> int:
return sum(item.price for item in self.items.all())
@property
def price_display(self) -> str:
price = self.price / 100
return f"{price}"
def get_absolute_url(self): def get_absolute_url(self):
return reverse("purchase:update", args=(self.pk,)) return reverse("purchase:update", args=(self.pk,))
class BasketItemQuerySet(models.QuerySet):
def priced(self):
return self.annotate(price=F("quantity") * F("product__unit_price_cents"))
class BasketItem(Model): class BasketItem(Model):
product = models.ForeignKey( product = models.ForeignKey(
to=Product, on_delete=models.PROTECT, related_name="basket_items" to=Product, on_delete=models.PROTECT, related_name="basket_items"
@ -126,11 +132,4 @@ class BasketItem(Model):
) )
quantity = models.PositiveIntegerField() quantity = models.PositiveIntegerField()
@property objects = BasketItemQuerySet.as_manager()
def price(self) -> int:
return self.product.unit_price_cents * self.quantity
@property
def price_display(self) -> str:
price = self.price / 100
return f"{price}"

View file

@ -1,5 +1,5 @@
{% extends "common/base.html" %} {% extends "common/base.html" %}
{% load crispy_forms_tags %} {% load crispy_forms_tags purchase %}
{% block content %} {% block content %}
{% if object %} {% if object %}
<h1>{{ object }}</h1> <h1>{{ object }}</h1>
@ -9,7 +9,7 @@
{% crispy form %} {% crispy form %}
{% if object %} {% if object %}
<div> <div>
Price: {{ object.price_display }} Price: {{ object.price|currency }}
</div> </div>
<a href="{% url "purchase:new" %}" class="btn btn-secondary">New</a> <a href="{% url "purchase:new" %}" class="btn btn-secondary">New</a>
{% endif %} {% endif %}

View file

@ -1,4 +1,5 @@
{% extends "common/base.html" %} {% extends "common/base.html" %}
{% load purchase %}
{% block content %} {% block content %}
<h1>Baskets</h1> <h1>Baskets</h1>
<div class="row row-cols-auto g-4"> <div class="row row-cols-auto g-4">
@ -9,7 +10,7 @@
<h5 class="card-title">Basket #{{ basket.id }}</h5> <h5 class="card-title">Basket #{{ basket.id }}</h5>
<p class="card-text"> <p class="card-text">
{{ basket.items.count }} items<br> {{ basket.items.count }} items<br>
{{ basket.price_display }}<br> {{ basket.price|currency }}<br>
{{ basket.payment_method }} {{ basket.payment_method }}
</p> </p>
{% if perms.purchase.change_basket %} {% if perms.purchase.change_basket %}

View file

@ -0,0 +1,19 @@
{% extends "common/base.html" %}
{% load purchase %}
{% block content %}
<h1>Reports</h1>
<h2>Total turnover</h2>
<ul>
<li>
{{ total|currency }}
</li>
</ul>
<h2>Turnover by day</h2>
<h2>Products</h2>
{% include "purchase/snippets/report_products.html" %}
<h2>Turnover by product by day</h2>
<h2>Turnover by payment method</h2>
<h2>Turnover by payment method by day</h2>
{% endblock %}

View file

@ -0,0 +1,18 @@
{% load purchase %}
<table class="table table-hover table-sm">
<thead><tr>
<th scope="col">Product</th>
<th scope="col">Sold</th>
<th scope="col">Turnover</th>
</tr></thead>
<tbody>
{% for product in products %}
<tr>
<th scope="row">{{ product }}</th>
<td>{{ product.sold }}</td>
<td>{{ product.turnover|currency }}</td>
</tr>
{% endfor %}
</tbody>
</table>

View file

View file

@ -0,0 +1,10 @@
from django import template
register = template.Library()
@register.filter
def currency(value):
if isinstance(value, int) or isinstance(value, float):
return f"{value/100:.2f}"
return value

View file

@ -6,6 +6,7 @@ from purchase.views import (
NewBasketView, NewBasketView,
UpdateBasketView, UpdateBasketView,
) )
from purchase.views.reports import ReportsView
app_name = "purchase" app_name = "purchase"
urlpatterns = [ urlpatterns = [
@ -13,4 +14,5 @@ urlpatterns = [
path("new/", NewBasketView.as_view(), name="new"), path("new/", NewBasketView.as_view(), name="new"),
path("<int:pk>/update/", UpdateBasketView.as_view(), name="update"), path("<int:pk>/update/", UpdateBasketView.as_view(), name="update"),
path("<int:pk>/delete/", DeleteBasketView.as_view(), name="delete"), path("<int:pk>/delete/", DeleteBasketView.as_view(), name="delete"),
path("reports/", ReportsView.as_view(), name="reports"),
] ]

View file

@ -0,0 +1,3 @@
from .basket import DeleteBasketView, ListBasketsView, NewBasketView, UpdateBasketView
__all__ = ["NewBasketView", "UpdateBasketView", "DeleteBasketView", "ListBasketsView"]

View file

@ -1,14 +1,10 @@
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.contrib.messages.views import SuccessMessageMixin from django.contrib.messages.views import SuccessMessageMixin
from django.urls import reverse from django.urls import reverse
from django.views.generic import CreateView, DeleteView, ListView, UpdateView from django.views.generic import CreateView, DeleteView, ListView, UpdateView
from purchase.forms import BasketForm from purchase.forms import BasketForm
from purchase.models import Basket from purchase.models import Basket
from purchase.views.utils import ProtectedViewsMixin
class ProtectedViewsMixin(PermissionRequiredMixin, LoginRequiredMixin):
pass
class NewBasketView(ProtectedViewsMixin, SuccessMessageMixin, CreateView): class NewBasketView(ProtectedViewsMixin, SuccessMessageMixin, CreateView):
@ -17,6 +13,8 @@ class NewBasketView(ProtectedViewsMixin, SuccessMessageMixin, CreateView):
form_class = BasketForm form_class = BasketForm
success_message = "Successfully created basket." success_message = "Successfully created basket."
queryset = Basket.objects.priced()
def get_success_url(self): def get_success_url(self):
if self.request.user.has_perm("purchase.change_basket"): if self.request.user.has_perm("purchase.change_basket"):
return super().get_success_url() return super().get_success_url()
@ -29,6 +27,7 @@ class UpdateBasketView(ProtectedViewsMixin, SuccessMessageMixin, UpdateView):
model = Basket model = Basket
form_class = BasketForm form_class = BasketForm
success_message = "Successfully updated basket." success_message = "Successfully updated basket."
queryset = Basket.objects.priced()
class ListBasketsView(ProtectedViewsMixin, ListView): class ListBasketsView(ProtectedViewsMixin, ListView):
@ -36,12 +35,14 @@ class ListBasketsView(ProtectedViewsMixin, ListView):
model = Basket model = Basket
context_object_name = "baskets" context_object_name = "baskets"
ordering = "-id" ordering = "-id"
queryset = Basket.objects.priced()
class DeleteBasketView(ProtectedViewsMixin, SuccessMessageMixin, DeleteView): class DeleteBasketView(ProtectedViewsMixin, SuccessMessageMixin, DeleteView):
permission_required = ["purchase.delete_basket"] permission_required = ["purchase.delete_basket"]
model = Basket model = Basket
success_message = "Basket successfully deleted." success_message = "Basket successfully deleted."
queryset = Basket.objects.priced()
def get_success_url(self): def get_success_url(self):
return reverse("purchase:list") return reverse("purchase:list")

View file

@ -0,0 +1,20 @@
from django.db.models import Sum
from django.views.generic import TemplateView
from purchase.models import Basket, PaymentMethod, Product
class ReportsView(TemplateView):
template_name = "purchase/reports.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context.update(
{
"total": Basket.objects.priced().aggregate(total=Sum("price"))["total"],
"by_day": {},
"products": Product.objects.with_turnover().with_sold(),
"payment_methods": PaymentMethod.objects.with_turnover(),
}
)
return context

View file

@ -0,0 +1,5 @@
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
class ProtectedViewsMixin(PermissionRequiredMixin, LoginRequiredMixin):
pass