Merge branch 'packing' into 'master'

Product.packing management

See merge request ybon/copanier!1
This commit is contained in:
Yohan 2019-04-14 16:27:09 +02:00
commit 35e68bd2ac
17 changed files with 371 additions and 57 deletions

View file

@ -80,7 +80,7 @@ async def auth_required(request, response):
# Should be handler Roll side? # Should be handler Roll side?
# In dev mode, we serve the static, but we don't have yet a way to mark static # In dev mode, we serve the static, but we don't have yet a way to mark static
# route as unprotected. # route as unprotected.
if request.path.startswith('/static/'): if request.path.startswith("/static/"):
return return
if request.route.payload and not request.route.payload.get("unprotected"): if request.route.payload and not request.route.payload.get("unprotected"):
token = request.cookies.get("token") token = request.cookies.get("token")
@ -198,7 +198,7 @@ async def import_products(request, response, id):
response.message(err, status="error") response.message(err, status="error")
response.redirect = path response.redirect = path
return return
elif data.filename.endswith('.xlsx'): elif data.filename.endswith(".xlsx"):
try: try:
imports.products_from_xlsx(delivery, data) imports.products_from_xlsx(delivery, data)
except ValueError as err: except ValueError as err:
@ -258,15 +258,25 @@ async def place_order(request, response, id):
response.redirect = delivery_url response.redirect = delivery_url
return return
if request.method == "POST": 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 form = request.form
order = Order(paid=form.bool("paid", False)) order = Order(paid=form.bool("paid", False))
for product in delivery.products: for product in delivery.products:
try: try:
quantity = form.int(product.ref, 0) wanted = form.int(f"wanted:{product.ref}", 0)
except HttpError: except HttpError:
continue continue
if quantity: try:
order.products[product.ref] = ProductOrder(wanted=quantity) 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: if not delivery.orders:
delivery.orders = {} delivery.orders = {}
if not order.products: if not order.products:
@ -344,6 +354,35 @@ async def xls_full_report(request, response, id):
response.xlsx(reports.full(delivery)) response.xlsx(reports.full(delivery))
@app.route("/livraison/{id}/ajuster/{ref}", methods=["GET", "POST"])
async def adjust_product(request, response, id, ref):
delivery = Delivery.load(id)
delivery_url = f"/livraison/{delivery.id}"
user = session.user.get(None)
if not user or not user.is_staff:
response.message("Désolé, c'est dangereux par ici", "warning")
response.redirect = delivery_url
return
for product in delivery.products:
if product.ref == ref:
break
else:
response.message(f"Référence inconnue: {ref}")
response.redirect = delivery_url
return
if request.method == "POST":
form = request.form
for email, order in delivery.orders.items():
choice = order[product]
choice.adjustment = form.int(email, 0)
order[product] = choice
delivery.persist()
response.message(f"Le produit «{product.ref}» a bien été ajusté!")
response.redirect = delivery_url
else:
response.html("adjust_product.html", {"delivery": delivery, "product": product})
def configure(): def configure():
config.init() config.init()

View file

@ -33,7 +33,6 @@ def price_field(value):
@dataclass @dataclass
class Base: class Base:
@classmethod @classmethod
def create(cls, data=None, **kwargs): def create(cls, data=None, **kwargs):
if isinstance(data, Base): if isinstance(data, Base):
@ -44,7 +43,8 @@ class Base:
for name, field_ in self.__dataclass_fields__.items(): for name, field_ in self.__dataclass_fields__.items():
value = getattr(self, name) value = getattr(self, name)
type_ = field_.type type_ = field_.type
if not isinstance(value, Base): # Do not recast our classes. # Do not recast our classes.
if not isinstance(value, Base) and value is not None:
try: try:
setattr(self, name, self.cast(type_, value)) setattr(self, name, self.cast(type_, value))
except (TypeError, ValueError): except (TypeError, ValueError):
@ -95,9 +95,9 @@ class Product(Base):
description: str = "" description: str = ""
url: str = "" url: str = ""
img: str = "" img: str = ""
packing: int = None
@property def __str__(self):
def label(self):
out = self.name out = self.name
if self.unit: if self.unit:
out += f" ({self.unit})" out += f" ({self.unit})"
@ -107,7 +107,11 @@ class Product(Base):
@dataclass @dataclass
class ProductOrder(Base): class ProductOrder(Base):
wanted: int wanted: int
ordered: int = 0 adjustment: int = 0
@property
def quantity(self):
return self.wanted + self.adjustment
@dataclass @dataclass
@ -115,14 +119,20 @@ class Order(Base):
products: Dict[str, ProductOrder] = field(default_factory=dict) products: Dict[str, ProductOrder] = field(default_factory=dict)
paid: bool = False paid: bool = False
def get_quantity(self, product): def __getitem__(self, ref):
choice = self.products.get(product.ref) if isinstance(ref, Product):
return choice.wanted if choice else 0 ref = ref.ref
return self.products.get(ref, ProductOrder(wanted=0))
def __setitem__(self, ref, value):
if isinstance(ref, Product):
ref = ref.ref
self.products[ref] = value
def total(self, products): def total(self, products):
products = {p.ref: p for p in products} products = {p.ref: p for p in products}
return round( 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
) )
@ -131,6 +141,9 @@ class Delivery(Base):
__root__ = "delivery" __root__ = "delivery"
__lock__ = threading.Lock() __lock__ = threading.Lock()
CLOSED = 0
OPEN = 1
ADJUSTMENT = 2
producer: str producer: str
from_date: datetime_field from_date: datetime_field
@ -143,6 +156,14 @@ class Delivery(Base):
orders: Dict[str, Order] = field(default_factory=dict) orders: Dict[str, Order] = field(default_factory=dict)
id: str = field(default_factory=lambda *a, **k: uuid.uuid4().hex) 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 @property
def total(self): def total(self):
return round(sum(o.total(self.products) for o in self.orders.values()), 2) return round(sum(o.total(self.products) for o in self.orders.values()), 2)
@ -159,6 +180,14 @@ class Delivery(Base):
def is_passed(self): def is_passed(self):
return not self.is_foreseen return not self.is_foreseen
@property
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 @classmethod
def init_fs(cls): def init_fs(cls):
cls.get_root().mkdir(parents=True, exist_ok=True) cls.get_root().mkdir(parents=True, exist_ok=True)
@ -196,5 +225,15 @@ class Delivery(Base):
total = 0 total = 0
for order in self.orders.values(): for order in self.orders.values():
if product.ref in order.products: if product.ref in order.products:
total += order.products[product.ref].wanted total += order.products[product.ref].quantity
return total return total
def product_missing(self, product):
if not product.packing:
return 0
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

View file

@ -10,19 +10,28 @@ def summary(delivery):
wb = Workbook() wb = Workbook()
ws = wb.active ws = wb.active
ws.title = f"{delivery.producer} {delivery.from_date.date()}" 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: for product in delivery.products:
wanted = delivery.product_wanted(product) wanted = delivery.product_wanted(product)
ws.append( ws.append(
[ [
product.ref, product.ref,
product.label, str(product),
product.price, product.price,
wanted, wanted,
product.unit,
round(product.price * wanted, 2), round(product.price * wanted, 2),
] ]
) )
ws.append(["", "", "", "Total", delivery.total]) ws.append(["", "", "", "", "Total", delivery.total])
return save_virtual_workbook(wb) return save_virtual_workbook(wb)
@ -33,15 +42,17 @@ def full(delivery):
headers = ["ref", "produit", "prix"] + [e for e in delivery.orders] + ["total"] headers = ["ref", "produit", "prix"] + [e for e in delivery.orders] + ["total"]
ws.append(headers) ws.append(headers)
for product in delivery.products: for product in delivery.products:
row = [product.ref, product.label, product.price] row = [product.ref, str(product), product.price]
for order in delivery.orders.values(): for order in delivery.orders.values():
wanted = order.products.get(product.ref) 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)) row.append(delivery.product_wanted(product))
ws.append(row) ws.append(row)
footer = ["Total", "", ""] + [ footer = (
o.total(delivery.products) for o in delivery.orders.values() ["Total", "", ""]
] + [delivery.total] + [o.total(delivery.products) for o in delivery.orders.values()]
+ [delivery.total]
)
ws.append(footer) ws.append(footer)
return save_virtual_workbook(wb) return save_virtual_workbook(wb)

View file

@ -236,7 +236,6 @@ textarea {
line-height: 1rem; line-height: 1rem;
background-color: #fff; background-color: #fff;
border: .05rem solid #bbc; border: .05rem solid #bbc;
border-radius: .1rem;
box-sizing: border-box; box-sizing: border-box;
} }
@ -276,6 +275,9 @@ input:focus,
select:focus { select:focus {
box-shadow: 0 0 .1rem var(--primary-color); box-shadow: 0 0 .1rem var(--primary-color);
} }
input[readonly] {
background-color: #eee;
}
table { table {
border-collapse: collapse; border-collapse: collapse;
@ -355,6 +357,7 @@ td.with-input {
} }
td.with-input input { td.with-input input {
width: 100%; width: 100%;
text-align: center;
} }
article.delivery { article.delivery {
width: 100%; width: 100%;
@ -363,6 +366,13 @@ article.delivery {
article.delivery th.person { article.delivery th.person {
max-width: 7rem; max-width: 7rem;
} }
td.missing,
th.missing {
background-color: #db7734;
}
.missing a {
color: #ddd;
}
hr { hr {
background-color: #dbdbdb; background-color: #dbdbdb;
border: none; border: none;

View file

@ -0,0 +1,23 @@
{% extends "base.html" %}
{% block body %}
<article>
<h3><a href="/livraison/{{ delivery.id }}">{{ delivery.producer }}</a> — Ajuster le produit «{{ product.name }}»</h3>
<p><strong>Conditionnement</strong> {{ product.packing }} x {{ product.unit }}</p>
<p><strong>Total commandé</strong> {{ delivery.product_wanted(product) }}</p>
<p><strong>Manquant</strong> {{ delivery.product_missing(product) }}</p>
<form method="post">
<table>
<tr><th>Personne</th><th class="amount">Commande</th><th class="amount">Ajustement</th></tr>
{% for email, order in delivery.orders.items() %}
<tr>
<td>{{ email }}</td>
<td>{{ order[product].wanted }}</td>
<td class="with-input"><input type="number" min=0 name="{{ email }}" value="{{ order[product].adjustment }}"></td>
</tr>
{% endfor %}
</table>
<input type="submit" value="Enregistrer">
</form>
</article>
{% endblock body %}

View file

@ -1,7 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block body %} {% block body %}
<h3>{{ delivery.producer }} {% if delivery.is_open %}<a class="button" href="/livraison/{{ delivery.id }}/commander">Gérer ma commande</a>{% endif %}</h3> <h3>{{ delivery.producer }} {% include "includes/order_button.html" %}</h3>
{% include "includes/delivery_head.html" %} {% include "includes/delivery_head.html" %}
<article class="delivery"> <article class="delivery">
<table class="delivery"> <table class="delivery">
@ -10,6 +10,9 @@
<th class="ref">Référence</th> <th class="ref">Référence</th>
<th class="product">Produit</th> <th class="product">Produit</th>
<th class="price">Prix</th> <th class="price">Prix</th>
{% if delivery.has_packing %}
<th class="packing">Conditionnement</th>
{% endif %}
<th class="amount">Total</th> <th class="amount">Total</th>
{% for email, order in delivery.orders.items() %} {% for email, order in delivery.orders.items() %}
<th class="person{% if delivery.is_passed and not order.paid %} not-paid{% endif %}"> <th class="person{% if delivery.is_passed and not order.paid %} not-paid{% endif %}">
@ -24,20 +27,28 @@
{% for product in delivery.products %} {% for product in delivery.products %}
<tr> <tr>
<td class="ref">{{ product.ref }}</td> <td class="ref">{{ product.ref }}</td>
<th class="product">{{ product.label }}</th> <th class="product">{{ product }}</th>
<td>{{ product.price }} €</td> <td>{{ product.price }} €</td>
<th>{{ delivery.product_wanted(product) }}</th> {% if delivery.has_packing %}
{% for email, order in delivery.orders.items() %} <td class="packing">{{ product.packing or '—'}}</td>
{% if product.ref in order.products %}
<td>{{ order.products[product.ref].wanted }}</td>
{% else %}
<td></td>
{% endif %} {% endif %}
<th{% if delivery.status == delivery.ADJUSTMENT and delivery.product_missing(product) %} class="missing" title="Les commandes individuelles ne correspondent pas aux conditionnements"{% endif %}>
{{ delivery.product_wanted(product) }}
{% if request.user.is_staff %}<a href="/livraison/{{ delivery.id }}/ajuster/{{ product.ref }}">{% endif %}
{% if delivery.status == delivery.ADJUSTMENT and delivery.product_missing(product) %} ({{ delivery.product_missing(product) }})
{% if request.user.is_staff %}</a>{% endif %}
{% endif %}
</th>
{% for email, order in delivery.orders.items() %}
<td>{{ order[product.ref].quantity or "—" }}</td>
{% endfor %} {% endfor %}
</tr> </tr>
{% endfor %} {% endfor %}
<tr><th class="total"><i class="icon-pricetags"></i> Total</th><td></td><td></td> <tr><th class="total"><i class="icon-pricetags"></i> Total</th><td></td><td></td>
<td class="total">{{ delivery.total }} €</td> {% if delivery.has_packing %}
<td></td>
{% endif %}
<th class="total">{{ delivery.total }} €</th>
{% for email, order in delivery.orders.items() %} {% for email, order in delivery.orders.items() %}
<td>{{ order.total(delivery.products) }} €</td> <td>{{ order.total(delivery.products) }} €</td>
{% endfor %} {% endfor %}
@ -48,7 +59,7 @@
<hr> <hr>
<ul class="toolbox"> <ul class="toolbox">
<li> <li>
<a href="/livraison/{{ delivery.id }}/rapport.xlsx"><i class="icon-magnifying-glass"></i> Rapport résumé</a> <a href="/livraison/{{ delivery.id }}/rapport.xlsx"><i class="icon-ribbon"></i> Bon de commande</a>
</li> </li>
<li> <li>
<a href="/livraison/{{ delivery.id }}/rapport-complet.xlsx"><i class="icon-grid"></i> Rapport complet</a> <a href="/livraison/{{ delivery.id }}/rapport-complet.xlsx"><i class="icon-grid"></i> Rapport complet</a>

View file

@ -8,27 +8,27 @@
{% endif %} {% endif %}
<form method="post"> <form method="post">
<label> <label>
<h5>Producteur</h5> <p>Producteur</p>
<input type="text" name="producer" value="{{ delivery.producer or '' }}" required> <input type="text" name="producer" value="{{ delivery.producer or '' }}" required>
</label> </label>
<label> <label>
<h5>Description des produits</h5> <p>Description des produits</p>
<input type="text" name="description" value="{{ delivery.description or '' }}" required> <input type="text" name="description" value="{{ delivery.description or '' }}" required>
</label> </label>
<label> <label>
<h5>Lieu</h5> <p>Lieu</p>
<input type="text" name="where" value="{{ delivery.where or '' }}" required> <input type="text" name="where" value="{{ delivery.where or '' }}" required>
</label> </label>
<label> <label>
<h5>Date de livraison</h5> <p>Date de livraison</p>
<input type="date" name="date" value="{{ delivery.from_date.date() if delivery.from_date else '' }}" required> de <input type="time" name="from_time" value="{{ delivery.from_date.time() if delivery.from_date else '' }}" required> à <input type="time" name="to_time" value="{{ delivery.to_date.time() if delivery.to_date else '' }}" required> <input type="date" name="date" value="{{ delivery.from_date.date() if delivery.from_date else '' }}" required> de <input type="time" name="from_time" value="{{ delivery.from_date.time() if delivery.from_date else '' }}" required> à <input type="time" name="to_time" value="{{ delivery.to_date.time() if delivery.to_date else '' }}" required>
</label> </label>
<label> <label>
<h5>Date de limite de commande</h5> <p>Date de limite de commande</p>
<input type="date" name="order_before" value="{{ delivery.order_before.date() if delivery.order_before else '' }}" required> <input type="date" name="order_before" value="{{ delivery.order_before.date() if delivery.order_before else '' }}" required>
</label> </label>
<label> <label>
<h5>Instructions particulières</h5> <p>Instructions particulières</p>
<input type="text" name="instructions" value="{{ delivery.instructions or '' }}"> <input type="text" name="instructions" value="{{ delivery.instructions or '' }}">
</label> </label>
<div> <div>
@ -52,6 +52,8 @@
<dd>Conditionnement d'une unité: 1kg, 33cl…</dd> <dd>Conditionnement d'une unité: 1kg, 33cl…</dd>
<dt>description</dt> <dt>description</dt>
<dd>Plus de détails sur le produit.</dd> <dd>Plus de détails sur le produit.</dd>
<dt>packing</dt>
<dd>Contionnement final pour grouper les commandes, le cas échéant, en nombre d'unités.</dd>
<dt>url</dt> <dt>url</dt>
<dd>Une URL éventuelle pointant sur une page présentant le produit.</dd> <dd>Une URL éventuelle pointant sur une page présentant le produit.</dd>
<dt>img</dt> <dt>img</dt>

View file

@ -5,8 +5,8 @@ Voici le résumé de votre commande «{{ delivery.producer }}»
Produit | Prix unitaire | Quantité Produit | Prix unitaire | Quantité
{% for product in delivery.products %} {% for product in delivery.products %}
{% if order.get_quantity(product) %} {% if order[product].quantity %}
{{ product.name }} | {{ product.price }} € | {{ order.get_quantity(product) }} {{ product.name }} | {{ product.price }} € | {{ order[product].quantity }}
{% endif %} {% endif %}
{% endfor %} {% endfor %}

View file

@ -2,6 +2,6 @@
<li><i class="icon-basket"></i> <strong>Produits</strong> {{ delivery.description }}</li> <li><i class="icon-basket"></i> <strong>Produits</strong> {{ delivery.description }}</li>
<li><i class="icon-streetsign"></i> <strong>Lieu</strong> {{ delivery.where }}</li> <li><i class="icon-streetsign"></i> <strong>Lieu</strong> {{ delivery.where }}</li>
<li><i class="icon-clock"></i> <strong>Date de livraison</strong> <time datetime="{{ delivery.from_date }}">{{ delivery.from_date|date }} de {{ delivery.from_date|time }} à {{ delivery.to_date|time }}</time></li> <li><i class="icon-clock"></i> <strong>Date de livraison</strong> <time datetime="{{ delivery.from_date }}">{{ delivery.from_date|date }} de {{ delivery.from_date|time }} à {{ delivery.to_date|time }}</time></li>
<li><i class="icon-hourglass"></i> {% if delivery.is_open %}<strong>Date limite de commande</strong> <time datetime="{{ delivery.order_before.date() }}">{{ delivery.order_before|date }}</time>{% else %}<strong>Fermée</strong>{% endif %}</li> <li><i class="icon-hourglass"></i> {% if delivery.status == delivery.OPEN %}<strong>Date limite de commande</strong> <time datetime="{{ delivery.order_before.date() }}">{{ delivery.order_before|date }}</time>{% elif delivery.status == delivery.ADJUSTMENT %}<strong>Ajustement en cours</strong>{% else %}<strong>Fermée</strong>{% endif %}</li>
{% if delivery.instructions %}<li><i class="icon-lightbulb"></i> <strong>À savoir</strong> {{ delivery.instructions }}</li>{% endif %} {% if delivery.instructions %}<li><i class="icon-lightbulb"></i> <strong>À savoir</strong> {{ delivery.instructions }}</li>{% endif %}
</ul> </ul>

View file

@ -2,7 +2,7 @@
<ul class="delivery"> <ul class="delivery">
{% for delivery in deliveries %} {% for delivery in deliveries %}
<li> <li>
<h3><a href="/livraison/{{ delivery.id }}"><i class="icon-hotairballoon"></i> {{ delivery.producer }}</a> {% if delivery.is_open %}<a class="button" href="/livraison/{{ delivery.id }}/commander">{% if request.user.email in delivery.orders %}Gérer ma commande{% else %}Commander{% endif %}</a>{% endif %}</h3> <h3><a href="/livraison/{{ delivery.id }}"><i class="icon-hotairballoon"></i> {{ delivery.producer }}</a> {% include "includes/order_button.html" %}</h3>
{% include "includes/delivery_head.html" %} {% include "includes/delivery_head.html" %}
</li> </li>
<hr> <hr>

View file

@ -0,0 +1,9 @@
{% if delivery.status != delivery.CLOSED %}
<a class="button" href="/livraison/{{ delivery.id }}/commander">
{% if delivery.status == delivery.ADJUSTMENT %}
Ajuster ma commande
{% elif delivery.status == delivery.OPEN %}
{% if request.user.email in delivery.orders %}Gérer ma commande{% else %}Commander{% endif %}
{% endif %}
</a>
{% endif %}

View file

@ -1,10 +1,10 @@
<table class="order"> <table class="order">
<tr><th class="product">Produit</th><th class="price">Prix unitaire</th><th class="amount">Quantité</th></tr> <tr><th class="product">Produit</th><th class="price">Prix unitaire</th><th class="amount">Quantité</th></tr>
{% for product in delivery.products %} {% for product in delivery.products %}
{% if order.get_quantity(product) %} {% if order[product].quantity %}
<tr> <tr>
<th class="product" style="text-align: left;">{{ product.label }}</th> <th class="product" style="text-align: left;">{{ product }}</th>
<td>{{ product.price }} €</td><td>{{ order.get_quantity(product) }}</td> <td>{{ product.price }} €</td><td>{{ order[product].quantity }}</td>
</tr> </tr>
{% endif %} {% endif %}
{% endfor %} {% endfor %}

View file

@ -6,17 +6,32 @@
{% include "includes/delivery_head.html" %} {% include "includes/delivery_head.html" %}
<form method="post"> <form method="post">
<table class="order"> <table class="order">
<tr><th class="product">Produit</th><th class="price">Prix</th><th class="amount">Quantité</th></tr> <tr>
<th class="product">Produit</th>
<th class="price">Prix</th>
{% if delivery.has_packing %}
<th class="packing">Conditionnement</th>
{% endif %}
<th class="amount">Quantité</th>
{% if delivery.status == delivery.ADJUSTMENT %}<th class="amount">Ajustement</th>{% endif %}</tr>
{% for product in delivery.products %} {% for product in delivery.products %}
<tr> <tr>
<th class="product">{{ product.label }} <th class="product">{{ product }}
{% if product.description or product.img %} {% if product.description or product.img %}
{% with unique_id=loop.index %} {% with unique_id=loop.index %}
{% include "includes/modal_product.html" %} {% include "includes/modal_product.html" %}
{% endwith %} {% endwith %}
{% endif %}</p> {% endif %}</p>
</th> </th>
<td>{{ product.price }} €</td><td class="with-input"><input type="number" name="{{ product.ref }}" value="{{ order.get_quantity(product) }}"></td></tr> <td>{{ product.price }} €</td>
{% if delivery.has_packing %}
<td{% if delivery.status == delivery.ADJUSTMENT and delivery.product_missing(product) %} class="missing" title="Les commandes individuelles ne correspondent pas aux conditionnements"{% endif %}>{{ product.packing or "—" }}{% if delivery.status == delivery.ADJUSTMENT and delivery.product_missing(product) %} ({{ delivery.product_missing(product) }}){% endif %}</td>
{% endif %}
<td class="with-input"><input {% if delivery.status != delivery.OPEN %}type="text" readonly{% else%}type="number"{% endif%} min=0 name="wanted:{{ product.ref }}" value="{{ order[product].wanted }}"></td>
{% if delivery.status == delivery.ADJUSTMENT %}
<td class="with-input"><input type="number" name="adjustment:{{ product.ref }}" value="{{ order[product].adjustment }}" {% if not delivery.product_missing(product) %}readonly{% endif %}></td>
{% endif %}
</tr>
{% endfor %} {% endfor %}
</table> </table>
<p>Total: {{ order.total(delivery.products) if order else 0 }} €</p> <p>Total: {{ order.total(delivery.products) if order else 0 }} €</p>

View file

@ -7,13 +7,25 @@ packages = find:
include_package_data = True include_package_data = True
install_requires = install_requires =
Jinja2==2.10 Jinja2==2.10
minicli==0.4.4
openpyxl==2.6.1 openpyxl==2.6.1
PyJWT==1.7.1 PyJWT==1.7.1
PyYAML==5.1 PyYAML==5.1
roll==0.10.1 roll==0.10.1
ujson==1.35 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] [options.entry_points]
console_scripts = console_scripts =

View file

@ -36,6 +36,13 @@ def test_delivery_is_open_when_order_before_is_in_the_future(delivery):
assert delivery.is_open 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(): def test_can_create_product():
product = Product(name="Lait 1.5L", ref="123", price=1.5) product = Product(name="Lait 1.5L", ref="123", price=1.5)
assert product.ref == "123" assert product.ref == "123"
@ -103,3 +110,13 @@ def test_person_is_staff_if_no_staff_in_config(monkeypatch):
monkeypatch.setattr(config, 'STAFF', []) monkeypatch.setattr(config, 'STAFF', [])
person = Person(email="foo@bar.fr") person = Person(email="foo@bar.fr")
assert person.is_staff 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

26
tests/test_reports.py Normal file
View file

@ -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),
]

View file

@ -1,9 +1,11 @@
from datetime import datetime, timedelta
from io import BytesIO from io import BytesIO
import pytest import pytest
from openpyxl import load_workbook 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 pytestmark = pytest.mark.asyncio
@ -52,7 +54,7 @@ async def test_create_delivery(client):
async def test_place_order_with_session(client, delivery): async def test_place_order_with_session(client, delivery):
delivery.persist() delivery.persist()
body = {"123": "3"} body = {"wanted:123": "3"}
resp = await client.post(f"/livraison/{delivery.id}/commander", body=body) resp = await client.post(f"/livraison/{delivery.id}/commander", body=body)
assert resp.status == 302 assert resp.status == 302
delivery = Delivery.load(id=delivery.id) 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): async def test_place_order_with_empty_string(client, delivery):
delivery.persist() 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) resp = await client.post(f"/livraison/{delivery.id}/commander", body=body)
assert resp.status == 302 assert resp.status == 302
delivery = Delivery.load(id=delivery.id) 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): async def test_change_paid_status_when_placing_order(client, delivery):
delivery.persist() delivery.persist()
body = {"123": "3", "paid": 1} body = {"wanted:123": "3", "paid": 1}
resp = await client.post(f"/livraison/{delivery.id}/commander", body=body) resp = await client.post(f"/livraison/{delivery.id}/commander", body=body)
assert resp.status == 302 assert resp.status == 302
delivery = Delivery.load(id=delivery.id) delivery = Delivery.load(id=delivery.id)
@ -96,11 +98,109 @@ async def test_change_paid_status_when_placing_order(client, delivery):
assert delivery.orders["foo@bar.org"].paid is True 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_get_adjust_product(client, delivery):
delivery.order_before = datetime.now() - timedelta(days=1)
delivery.products[0].packing = 6
delivery.orders["foo@bar.org"] = Order(
products={"123": ProductOrder(wanted=2, adjustment=1)}
)
delivery.persist()
assert delivery.status == delivery.ADJUSTMENT
resp = await client.get(f"/livraison/{delivery.id}/ajuster/123")
doc = pq(resp.body)
assert doc('[name="foo@bar.org"]')
assert doc('[name="foo@bar.org"]').attr("value") == '1'
async def test_post_adjust_product(client, delivery):
delivery.order_before = datetime.now() - timedelta(days=1)
delivery.products[0].packing = 6
delivery.orders["foo@bar.org"] = Order(
products={"123": ProductOrder(wanted=2)}
)
delivery.persist()
assert delivery.status == delivery.ADJUSTMENT
body = {"foo@bar.org": "1"}
resp = await client.post(f"/livraison/{delivery.id}/ajuster/123", body=body)
assert resp.status == 302
delivery = Delivery.load(id=delivery.id)
assert delivery.orders["foo@bar.org"].products["123"].wanted == 2
assert delivery.orders["foo@bar.org"].products["123"].adjustment == 1
async def test_only_staff_can_adjust_product(client, delivery, monkeypatch):
delivery.order_before = datetime.now() - timedelta(days=1)
delivery.products[0].packing = 6
delivery.orders["foo@bar.org"] = Order(
products={"123": ProductOrder(wanted=2)}
)
delivery.persist()
monkeypatch.setattr("copanier.config.STAFF", ["someone@else.org"])
resp = await client.get(f"/livraison/{delivery.id}/ajuster/123")
assert resp.status == 302
body = {"foo@bar.org": "1"}
resp = await client.post(f"/livraison/{delivery.id}/ajuster/123", body=body)
assert resp.status == 302
delivery = Delivery.load(id=delivery.id)
assert delivery.orders["foo@bar.org"].products["123"].wanted == 2
assert delivery.orders["foo@bar.org"].products["123"].adjustment == 0
async def test_export_products(client, delivery): async def test_export_products(client, delivery):
delivery.persist() delivery.persist()
resp = await client.get(f"/livraison/{delivery.id}/exporter/produits") resp = await client.get(f"/livraison/{delivery.id}/exporter/produits")
wb = load_workbook(filename=BytesIO(resp.body)) wb = load_workbook(filename=BytesIO(resp.body))
assert list(wb.active.values) == [ assert list(wb.active.values) == [
("name", "ref", "price", "unit", "description", "url", "img"), ("name", "ref", "price", "unit", "description", "url", "img", "packing"),
("Lait", "123", 1.5, None, None, None, None), ("Lait", "123", 1.5, None, None, None, None, None),
] ]