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",
"crispy_forms",
"crispy_bootstrap5",
"django_extensions",
]
MIDDLEWARE = [

View file

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

View file

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

View file

@ -1,4 +1,5 @@
from django.db import models
from django.db.models import Count, F, Sum
from django.urls import reverse
from PIL import Image, ImageOps
@ -11,53 +12,53 @@ class Model(models.Model):
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):
name = models.CharField(max_length=50, unique=True)
objects = PaymentMethodQuerySet.as_manager()
def __str__(self):
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():
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):
name = models.CharField(max_length=250, unique=True)
image = models.ImageField(null=True, blank=True)
unit_price_cents = models.PositiveIntegerField()
display_order = models.PositiveIntegerField(default=default_product_display_order)
objects = ProductQuerySet.as_manager()
class Meta:
ordering = ["display_order", "name"]
def __str__(self):
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):
super().save()
with Image.open(self.image.path) as img:
@ -92,6 +93,13 @@ class Product(Model):
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):
payment_method = models.ForeignKey(
to=PaymentMethod,
@ -101,22 +109,20 @@ class Basket(Model):
blank=True,
)
objects = BasketQuerySet.as_manager()
def __str__(self):
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):
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):
product = models.ForeignKey(
to=Product, on_delete=models.PROTECT, related_name="basket_items"
@ -126,11 +132,4 @@ class BasketItem(Model):
)
quantity = models.PositiveIntegerField()
@property
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}"
objects = BasketItemQuerySet.as_manager()

View file

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

View file

@ -1,4 +1,5 @@
{% extends "common/base.html" %}
{% load purchase %}
{% block content %}
<h1>Baskets</h1>
<div class="row row-cols-auto g-4">
@ -9,7 +10,7 @@
<h5 class="card-title">Basket #{{ basket.id }}</h5>
<p class="card-text">
{{ basket.items.count }} items<br>
{{ basket.price_display }}<br>
{{ basket.price|currency }}<br>
{{ basket.payment_method }}
</p>
{% 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,
UpdateBasketView,
)
from purchase.views.reports import ReportsView
app_name = "purchase"
urlpatterns = [
@ -13,4 +14,5 @@ urlpatterns = [
path("new/", NewBasketView.as_view(), name="new"),
path("<int:pk>/update/", UpdateBasketView.as_view(), name="update"),
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.urls import reverse
from django.views.generic import CreateView, DeleteView, ListView, UpdateView
from purchase.forms import BasketForm
from purchase.models import Basket
class ProtectedViewsMixin(PermissionRequiredMixin, LoginRequiredMixin):
pass
from purchase.views.utils import ProtectedViewsMixin
class NewBasketView(ProtectedViewsMixin, SuccessMessageMixin, CreateView):
@ -17,6 +13,8 @@ class NewBasketView(ProtectedViewsMixin, SuccessMessageMixin, CreateView):
form_class = BasketForm
success_message = "Successfully created basket."
queryset = Basket.objects.priced()
def get_success_url(self):
if self.request.user.has_perm("purchase.change_basket"):
return super().get_success_url()
@ -29,6 +27,7 @@ class UpdateBasketView(ProtectedViewsMixin, SuccessMessageMixin, UpdateView):
model = Basket
form_class = BasketForm
success_message = "Successfully updated basket."
queryset = Basket.objects.priced()
class ListBasketsView(ProtectedViewsMixin, ListView):
@ -36,12 +35,14 @@ class ListBasketsView(ProtectedViewsMixin, ListView):
model = Basket
context_object_name = "baskets"
ordering = "-id"
queryset = Basket.objects.priced()
class DeleteBasketView(ProtectedViewsMixin, SuccessMessageMixin, DeleteView):
permission_required = ["purchase.delete_basket"]
model = Basket
success_message = "Basket successfully deleted."
queryset = Basket.objects.priced()
def get_success_url(self):
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