mirror of
https://github.com/Crocmagnon/checkout.git
synced 2024-11-22 16:18:03 +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",
|
"purchase",
|
||||||
"crispy_forms",
|
"crispy_forms",
|
||||||
"crispy_bootstrap5",
|
"crispy_bootstrap5",
|
||||||
|
"django_extensions",
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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}€"
|
|
||||||
|
|
|
@ -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 %}
|
||||||
|
|
|
@ -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 %}
|
||||||
|
|
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,
|
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"),
|
||||||
]
|
]
|
||||||
|
|
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.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")
|
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