From 425222a0a2cb36cbe22bc201af47bda12eb1f7c0 Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Sat, 13 Apr 2019 23:48:37 +0200 Subject: [PATCH] Allow to define ProductOrder.ajustment when product needs adjustment --- copanier/__init__.py | 20 +++++-- copanier/models.py | 36 ++++++++--- copanier/reports.py | 23 +++++-- copanier/static/app.css | 5 +- copanier/templates/delivery.html | 14 ++--- copanier/templates/edit_delivery.html | 12 ++-- copanier/templates/emails/order_summary.txt | 4 +- .../templates/includes/delivery_head.html | 2 +- .../templates/includes/delivery_list.html | 2 +- copanier/templates/includes/order_button.html | 9 +++ .../templates/includes/order_summary.html | 4 +- copanier/templates/place_order.html | 19 +++++- setup.cfg | 14 ++++- tests/test_models.py | 17 ++++++ tests/test_reports.py | 26 ++++++++ tests/test_views.py | 60 +++++++++++++++++-- 16 files changed, 219 insertions(+), 48 deletions(-) create mode 100644 copanier/templates/includes/order_button.html create mode 100644 tests/test_reports.py diff --git a/copanier/__init__.py b/copanier/__init__.py index 733d353..141d501 100644 --- a/copanier/__init__.py +++ b/copanier/__init__.py @@ -80,7 +80,7 @@ async def auth_required(request, response): # Should be handler Roll side? # In dev mode, we serve the static, but we don't have yet a way to mark static # route as unprotected. - if request.path.startswith('/static/'): + if request.path.startswith("/static/"): return if request.route.payload and not request.route.payload.get("unprotected"): token = request.cookies.get("token") @@ -198,7 +198,7 @@ async def import_products(request, response, id): response.message(err, status="error") response.redirect = path return - elif data.filename.endswith('.xlsx'): + elif data.filename.endswith(".xlsx"): try: imports.products_from_xlsx(delivery, data) except ValueError as err: @@ -258,15 +258,25 @@ async def place_order(request, response, id): response.redirect = delivery_url return if request.method == "POST": + if not (user and user.is_staff) and delivery.status == delivery.CLOSED: + response.message("La livraison est fermée", "error") + response.redirect = delivery_url + return form = request.form order = Order(paid=form.bool("paid", False)) for product in delivery.products: try: - quantity = form.int(product.ref, 0) + wanted = form.int(f"wanted:{product.ref}", 0) except HttpError: continue - if quantity: - order.products[product.ref] = ProductOrder(wanted=quantity) + try: + adjustment = form.int(f"adjustment:{product.ref}", 0) + except HttpError: + adjustment = 0 + if wanted or adjustment: + order.products[product.ref] = ProductOrder( + wanted=wanted, adjustment=adjustment + ) if not delivery.orders: delivery.orders = {} if not order.products: diff --git a/copanier/models.py b/copanier/models.py index ceef0f1..f0d87f3 100644 --- a/copanier/models.py +++ b/copanier/models.py @@ -33,7 +33,6 @@ def price_field(value): @dataclass class Base: - @classmethod def create(cls, data=None, **kwargs): if isinstance(data, Base): @@ -109,7 +108,11 @@ class Product(Base): @dataclass class ProductOrder(Base): wanted: int - ordered: int = 0 + adjustment: int = 0 + + @property + def quantity(self): + return self.wanted + self.adjustment @dataclass @@ -117,14 +120,15 @@ class Order(Base): products: Dict[str, ProductOrder] = field(default_factory=dict) paid: bool = False - def get_quantity(self, product): - choice = self.products.get(product.ref) - return choice.wanted if choice else 0 + def __getitem__(self, ref): + if isinstance(ref, Product): + ref = ref.ref + return self.products.get(ref, ProductOrder(wanted=0)) def total(self, products): products = {p.ref: p for p in products} return round( - sum(p.wanted * products[ref].price for ref, p in self.products.items()), 2 + sum(p.quantity * products[ref].price for ref, p in self.products.items()), 2 ) @@ -133,6 +137,9 @@ class Delivery(Base): __root__ = "delivery" __lock__ = threading.Lock() + CLOSED = 0 + OPEN = 1 + ADJUSTMENT = 2 producer: str from_date: datetime_field @@ -145,6 +152,14 @@ class Delivery(Base): orders: Dict[str, Order] = field(default_factory=dict) id: str = field(default_factory=lambda *a, **k: uuid.uuid4().hex) + @property + def status(self): + if self.is_open: + return self.OPEN + if self.needs_adjustment: + return self.ADJUSTMENT + return self.CLOSED + @property def total(self): return round(sum(o.total(self.products) for o in self.orders.values()), 2) @@ -165,6 +180,10 @@ class Delivery(Base): def has_packing(self): return any(p.packing for p in self.products) + @property + def needs_adjustment(self): + return self.has_packing and any(self.product_missing(p) for p in self.products) + @classmethod def init_fs(cls): cls.get_root().mkdir(parents=True, exist_ok=True) @@ -202,7 +221,7 @@ class Delivery(Base): total = 0 for order in self.orders.values(): if product.ref in order.products: - total += order.products[product.ref].wanted + total += order.products[product.ref].quantity return total def product_missing(self, product): @@ -211,3 +230,6 @@ class Delivery(Base): wanted = self.product_wanted(product) orphan = wanted % product.packing return product.packing - orphan if orphan else 0 + + def has_order(self, person): + return person.email in self.orders diff --git a/copanier/reports.py b/copanier/reports.py index ec25008..dde7e2a 100644 --- a/copanier/reports.py +++ b/copanier/reports.py @@ -10,7 +10,15 @@ def summary(delivery): wb = Workbook() ws = wb.active ws.title = f"{delivery.producer} {delivery.from_date.date()}" - ws.append(["ref", "produit", "prix", "unités", "total"]) + headers = [ + "ref", + "produit", + "prix unitaire", + "quantité commandée", + "unité", + "total", + ] + ws.append(headers) for product in delivery.products: wanted = delivery.product_wanted(product) ws.append( @@ -19,10 +27,11 @@ def summary(delivery): product.label, product.price, wanted, + product.unit, round(product.price * wanted, 2), ] ) - ws.append(["", "", "", "Total", delivery.total]) + ws.append(["", "", "", "", "Total", delivery.total]) return save_virtual_workbook(wb) @@ -36,12 +45,14 @@ def full(delivery): row = [product.ref, product.label, product.price] for order in delivery.orders.values(): wanted = order.products.get(product.ref) - row.append(wanted.wanted if wanted else 0) + row.append(wanted.quantity if wanted else 0) row.append(delivery.product_wanted(product)) ws.append(row) - footer = ["Total", "", ""] + [ - o.total(delivery.products) for o in delivery.orders.values() - ] + [delivery.total] + footer = ( + ["Total", "", ""] + + [o.total(delivery.products) for o in delivery.orders.values()] + + [delivery.total] + ) ws.append(footer) return save_virtual_workbook(wb) diff --git a/copanier/static/app.css b/copanier/static/app.css index 087f87d..4e1ceac 100644 --- a/copanier/static/app.css +++ b/copanier/static/app.css @@ -236,7 +236,6 @@ textarea { line-height: 1rem; background-color: #fff; border: .05rem solid #bbc; - border-radius: .1rem; box-sizing: border-box; } @@ -276,6 +275,9 @@ input:focus, select:focus { box-shadow: 0 0 .1rem var(--primary-color); } +input[readonly] { + background-color: #eee; +} table { border-collapse: collapse; @@ -355,6 +357,7 @@ td.with-input { } td.with-input input { width: 100%; + text-align: center; } article.delivery { width: 100%; diff --git a/copanier/templates/delivery.html b/copanier/templates/delivery.html index 0133af1..8ac5824 100644 --- a/copanier/templates/delivery.html +++ b/copanier/templates/delivery.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% block body %} -

{{ delivery.producer }} {% if delivery.is_open %}Gérer ma commande{% endif %}

+

{{ delivery.producer }} {% include "includes/order_button.html" %}

{% include "includes/delivery_head.html" %}
@@ -11,7 +11,7 @@ {% if delivery.has_packing %} - + {% endif %} {% for email, order in delivery.orders.items() %} @@ -32,13 +32,9 @@ {% if delivery.has_packing %} {% endif %} - {{ delivery.product_wanted(product) }}{% if delivery.product_missing(product) %} ({{ delivery.product_missing(product) }}){% endif %} + {{ delivery.product_wanted(product) }}{% if delivery.status == delivery.ADJUSTMENT and delivery.product_missing(product) %} (−{{ delivery.product_missing(product) }}){% endif %} {% for email, order in delivery.orders.items() %} - {% if product.ref in order.products %} - - {% else %} - - {% endif %} + {% endfor %} {% endfor %} @@ -57,7 +53,7 @@
diff --git a/copanier/templates/includes/delivery_list.html b/copanier/templates/includes/delivery_list.html index bb0c27b..1687bd6 100644 --- a/copanier/templates/includes/delivery_list.html +++ b/copanier/templates/includes/delivery_list.html @@ -2,7 +2,7 @@
Produit PrixLotConditionnementTotal{{ product.packing or '—'}}{{ order.products[product.ref].wanted }}{{ order[product.ref].quantity or "—" }}
{% for product in delivery.products %} - {% if order.get_quantity(product) %} + {% if order[product].quantity %} - + {% endif %} {% endfor %} diff --git a/copanier/templates/place_order.html b/copanier/templates/place_order.html index 468445c..475f1e8 100644 --- a/copanier/templates/place_order.html +++ b/copanier/templates/place_order.html @@ -6,7 +6,14 @@ {% include "includes/delivery_head.html" %}
ProduitPrix unitaireQuantité
{{ product.label }}{{ product.price }} €{{ order.get_quantity(product) }}{{ product.price }} €{{ order[product].quantity }}
- + + + + {% if delivery.has_packing %} + + {% endif %} + + {% if delivery.status == delivery.ADJUSTMENT %}{% endif %} {% for product in delivery.products %} - {{ product.packing or "—" }}{% if delivery.product_missing(product) %} (manque {{ delivery.product_missing(product) }}){% endif %} - + {% if delivery.has_packing %} + {{ product.packing or "—" }}{% if delivery.status == delivery.ADJUSTMENT and delivery.product_missing(product) %} (−{{ delivery.product_missing(product) }}){% endif %} + {% endif %} + + {% if delivery.status == delivery.ADJUSTMENT %} + + {% endif %} + {% endfor %}
ProduitPrixLotQuantité
ProduitPrixConditionnementQuantitéAjustement
{{ product.label }} @@ -17,8 +24,14 @@ {% endif %}

{{ product.price }} €

Total: {{ order.total(delivery.products) if order else 0 }} €

diff --git a/setup.cfg b/setup.cfg index 7162f61..82e7e1b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -7,13 +7,25 @@ packages = find: include_package_data = True install_requires = Jinja2==2.10 - minicli==0.4.4 openpyxl==2.6.1 PyJWT==1.7.1 PyYAML==5.1 roll==0.10.1 ujson==1.35 +[options.extras_require] +dev = + hupper==1.6.1 + minicli==0.4.4 + usine==0.2.2 +test = + pyquery==1.4.0 + pytest==4.3.1 + pytest-asyncio==0.10.0 +prod = + gunicorn==19.9.0 + uvloop==0.12.2 + [options.entry_points] console_scripts = diff --git a/tests/test_models.py b/tests/test_models.py index f11051c..d34a5e1 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -36,6 +36,13 @@ def test_delivery_is_open_when_order_before_is_in_the_future(delivery): assert delivery.is_open +def test_delivery_status(delivery): + delivery.order_before = now() + timedelta(hours=1) + assert delivery.status == delivery.OPEN + delivery.order_before = now() - timedelta(days=1) + assert delivery.status == delivery.CLOSED + + def test_can_create_product(): product = Product(name="Lait 1.5L", ref="123", price=1.5) assert product.ref == "123" @@ -103,3 +110,13 @@ def test_person_is_staff_if_no_staff_in_config(monkeypatch): monkeypatch.setattr(config, 'STAFF', []) person = Person(email="foo@bar.fr") assert person.is_staff + + +def test_productorder_quantity(): + choice = ProductOrder(wanted=3) + assert choice.wanted == 3 + assert choice.quantity == 3 + choice = ProductOrder(wanted=3, adjustment=2) + assert choice.quantity == 5 + choice = ProductOrder(wanted=3, adjustment=-1) + assert choice.quantity == 2 diff --git a/tests/test_reports.py b/tests/test_reports.py new file mode 100644 index 0000000..833ff7d --- /dev/null +++ b/tests/test_reports.py @@ -0,0 +1,26 @@ +from io import BytesIO + +from openpyxl import load_workbook + +from copanier import reports +from copanier.models import Order, Product, ProductOrder + + +def test_export_products(client, delivery): + delivery.products[0].packing = 6 + delivery.products.append( + Product(ref="456", name="yaourt", price="3.5", packing=4, unit="pot 125ml") + ) + delivery.products.append(Product(ref="789", name="fromage", price="9.2")) + delivery.orders["foo@bar.org"] = Order( + products={"123": ProductOrder(wanted=1), "456": ProductOrder(wanted=4)} + ) + delivery.persist() + wb = load_workbook(filename=BytesIO(reports.summary(delivery))) + assert list(wb.active.values) == [ + ("ref", "produit", "prix unitaire", "quantité commandée", "unité", "total"), + ("123", "Lait", 1.5, 1, None, 1.5), + ("456", "yaourt (pot 125ml)", 3.5, 4, "pot 125ml", 14), + ("789", "fromage", 9.2, 0, None, 0), + (None, None, None, None, "Total", 15.5), + ] diff --git a/tests/test_views.py b/tests/test_views.py index 24a3ff2..b7007e6 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -1,9 +1,11 @@ +from datetime import datetime, timedelta from io import BytesIO import pytest from openpyxl import load_workbook +from pyquery import PyQuery as pq -from copanier.models import Delivery, Order, ProductOrder +from copanier.models import Delivery, Order, ProductOrder, Product pytestmark = pytest.mark.asyncio @@ -52,7 +54,7 @@ async def test_create_delivery(client): async def test_place_order_with_session(client, delivery): delivery.persist() - body = {"123": "3"} + body = {"wanted:123": "3"} resp = await client.post(f"/livraison/{delivery.id}/commander", body=body) assert resp.status == 302 delivery = Delivery.load(id=delivery.id) @@ -79,7 +81,7 @@ async def test_place_empty_order_should_delete_previous(client, delivery): async def test_place_order_with_empty_string(client, delivery): delivery.persist() - body = {"123": ""} # User deleted the field value. + body = {"wanted:123": ""} # User deleted the field value. resp = await client.post(f"/livraison/{delivery.id}/commander", body=body) assert resp.status == 302 delivery = Delivery.load(id=delivery.id) @@ -88,7 +90,7 @@ async def test_place_order_with_empty_string(client, delivery): async def test_change_paid_status_when_placing_order(client, delivery): delivery.persist() - body = {"123": "3", "paid": 1} + body = {"wanted:123": "3", "paid": 1} resp = await client.post(f"/livraison/{delivery.id}/commander", body=body) assert resp.status == 302 delivery = Delivery.load(id=delivery.id) @@ -96,6 +98,56 @@ async def test_change_paid_status_when_placing_order(client, delivery): assert delivery.orders["foo@bar.org"].paid is True +async def test_get_place_order_with_closed_subscription(client, delivery): + delivery.order_before = datetime.now() - timedelta(days=1) + delivery.orders["foo@bar.org"] = Order(products={"123": ProductOrder(wanted=1)}) + delivery.persist() + assert delivery.status == delivery.CLOSED + resp = await client.get(f"/livraison/{delivery.id}/commander") + doc = pq(resp.body) + assert doc('[name="wanted:123"]').attr("readonly") + assert not doc('[name="adjustment:123"]') + + +async def test_get_place_order_with_adjustment_status(client, delivery): + resp = await client.get(f"/livraison/{delivery.id}/commander") + doc = pq(resp.body) + assert not doc('[name="wanted:123"]').attr("readonly") + assert not doc('[name="adjustment:123"]') + delivery.order_before = datetime.now() - timedelta(days=1) + delivery.products[0].packing = 6 + delivery.products.append(Product(ref="456", name="yaourt", price="3.5", packing=4)) + delivery.products.append(Product(ref="789", name="fromage", price="9.2")) + delivery.orders["foo@bar.org"] = Order( + products={"123": ProductOrder(wanted=1), "456": ProductOrder(wanted=4)} + ) + delivery.persist() + assert delivery.status == delivery.ADJUSTMENT + resp = await client.get(f"/livraison/{delivery.id}/commander") + doc = pq(resp.body) + assert doc('[name="wanted:123"]').attr("readonly") + assert doc('[name="adjustment:123"]') + assert not doc('[name="adjustment:123"]').attr("readonly") + assert doc('[name="wanted:456"]').attr("readonly") + assert doc('[name="adjustment:456"]') + # Already adjusted. + assert doc('[name="adjustment:456"]').attr("readonly") + assert doc('[name="adjustment:789"]') + # Needs no adjustment. + assert doc('[name="adjustment:789"]').attr("readonly") + + +async def test_cannot_place_order_on_closed_delivery(client, delivery, monkeypatch): + monkeypatch.setattr("copanier.config.STAFF", ["someone@else.org"]) + delivery.order_before = datetime.now() - timedelta(days=1) + delivery.persist() + body = {"wanted:123": "3"} + resp = await client.post(f"/livraison/{delivery.id}/commander", body=body) + assert resp.status == 302 + delivery = Delivery.load(id=delivery.id) + assert not delivery.orders + + async def test_export_products(client, delivery): delivery.persist() resp = await client.get(f"/livraison/{delivery.id}/exporter/produits")