mirror of
https://github.com/Crocmagnon/checkout.git
synced 2024-11-22 08:08:04 +01:00
Refactor price with database queries and start reports
This commit is contained in:
parent
53620a279a
commit
150632e9c5
17 changed files with 152 additions and 57 deletions
|
@ -86,6 +86,7 @@ INSTALLED_APPS = [
|
|||
"purchase",
|
||||
"crispy_forms",
|
||||
"crispy_bootstrap5",
|
||||
"django_extensions",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
19
src/purchase/templates/purchase/reports.html
Normal file
19
src/purchase/templates/purchase/reports.html
Normal 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 %}
|
|
@ -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>
|
0
src/purchase/templatetags/__init__.py
Normal file
0
src/purchase/templatetags/__init__.py
Normal file
10
src/purchase/templatetags/purchase.py
Normal file
10
src/purchase/templatetags/purchase.py
Normal 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
|
|
@ -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"),
|
||||
]
|
||||
|
|
3
src/purchase/views/__init__.py
Normal file
3
src/purchase/views/__init__.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from .basket import DeleteBasketView, ListBasketsView, NewBasketView, UpdateBasketView
|
||||
|
||||
__all__ = ["NewBasketView", "UpdateBasketView", "DeleteBasketView", "ListBasketsView"]
|
|
@ -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")
|
20
src/purchase/views/reports.py
Normal file
20
src/purchase/views/reports.py
Normal 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
|
5
src/purchase/views/utils.py
Normal file
5
src/purchase/views/utils.py
Normal file
|
@ -0,0 +1,5 @@
|
|||
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
|
||||
|
||||
|
||||
class ProtectedViewsMixin(PermissionRequiredMixin, LoginRequiredMixin):
|
||||
pass
|
Loading…
Reference in a new issue