Allow to archive deliveries

- Delivery.id is not persisted anymore: the file name and relaltive
  directory *is* the id, and we don't want to denormalize this info
- Delivery.load will filter out non fields keys from the storage: this
  allows more resilient upgrade of the data model, and is usefull when
  dealing with multiple branches with different data models
- Delivery.archive/unarchive will move the delivery storage from
  root to archive/
- Delivery.all now accepts "is_archived" to retrieve archived instead
  of active deliveries
- import products is now moved to a modal
- archived deliveries are now listed in a separate page
This commit is contained in:
Yohan Boniface 2019-04-23 20:38:48 +02:00
parent 95c5f26f8e
commit 6db17fa56e
10 changed files with 189 additions and 64 deletions

View file

@ -177,6 +177,35 @@ async def home(request, response):
response.html("home.html", incoming=Delivery.incoming(), former=Delivery.former()) response.html("home.html", incoming=Delivery.incoming(), former=Delivery.former())
@app.route("/archives", methods=["GET"])
async def view_archives(request, response):
response.html("archive.html", {"deliveries": Delivery.all(is_archived=True)})
@app.route("/livraison/archive/{id}", methods=["GET"])
async def view_archive(request, response, id):
delivery = Delivery.load(f"archive/{id}")
response.html("delivery.html", {"delivery": delivery})
@app.route("/livraison/{id}/archiver", methods=["GET"])
@staff_only
async def archive_delivery(request, response, id):
delivery = Delivery.load(id)
delivery.archive()
response.message("La livraison a été archivée")
response.redirect = f"/livraison/{delivery.id}"
@app.route("/livraison/archive/{id}/désarchiver", methods=["GET"])
@staff_only
async def unarchive_delivery(request, response, id):
delivery = Delivery.load(f"archive/{id}")
delivery.unarchive()
response.message("La livraison a été désarchivée")
response.redirect = f"/livraison/{delivery.id}"
@app.route("/livraison", methods=["GET"]) @app.route("/livraison", methods=["GET"])
async def new_delivery(request, response): async def new_delivery(request, response):
response.html("edit_delivery.html", delivery={}) response.html("edit_delivery.html", delivery={})

View file

@ -151,6 +151,7 @@ class Delivery(Base):
CLOSED = 0 CLOSED = 0
OPEN = 1 OPEN = 1
ADJUSTMENT = 2 ADJUSTMENT = 2
ARCHIVED = 3
producer: str producer: str
from_date: datetime_field from_date: datetime_field
@ -162,11 +163,16 @@ class Delivery(Base):
where: str = "Marché de la Briche" where: str = "Marché de la Briche"
products: List[Product] = field(default_factory=list) products: List[Product] = field(default_factory=list)
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)
infos_url: str = "" infos_url: str = ""
def __post_init__(self):
self.id = None # Not a field because we don't want to persist it.
super().__post_init__()
@property @property
def status(self): def status(self):
if self.is_archived:
return self.ARCHIVED
if self.is_open: if self.is_open:
return self.OPEN return self.OPEN
if self.needs_adjustment: if self.needs_adjustment:
@ -197,9 +203,14 @@ class Delivery(Base):
def needs_adjustment(self): def needs_adjustment(self):
return self.has_packing and any(self.product_missing(p) for p in self.products) return self.has_packing and any(self.product_missing(p) for p in self.products)
@property
def is_archived(self):
return self.id and self.id.startswith("archive/")
@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)
cls.get_root().joinpath("archive").mkdir(exist_ok=True)
@classmethod @classmethod
def get_root(cls): def get_root(cls):
@ -210,12 +221,21 @@ class Delivery(Base):
path = cls.get_root() / f"{id}.yml" path = cls.get_root() / f"{id}.yml"
if not path.exists(): if not path.exists():
raise DoesNotExist raise DoesNotExist
return cls(**yaml.safe_load(path.read_text())) data = yaml.safe_load(path.read_text())
# Tolerate extra fields (but we'll lose them if instance is persisted)
data = {k: v for k, v in data.items() if k in cls.__dataclass_fields__}
delivery = cls(**data)
delivery.id = id
return delivery
@classmethod @classmethod
def all(cls): def all(cls, is_archived=False):
for path in cls.get_root().glob("*.yml"): root = cls.get_root()
yield Delivery.load(path.stem) if is_archived:
root = root / "archive"
for path in root.glob("*.yml"):
id = str(path.relative_to(cls.get_root()))[:-4]
yield Delivery.load(id)
@classmethod @classmethod
def incoming(cls): def incoming(cls):
@ -225,10 +245,30 @@ class Delivery(Base):
def former(cls): def former(cls):
return [d for d in cls.all() if not d.is_foreseen] return [d for d in cls.all() if not d.is_foreseen]
@property
def path(self):
assert self.id, "Cannot operate on unsaved deliveries"
return self.get_root() / f"{self.id}.yml"
def persist(self): def persist(self):
with self.__lock__: with self.__lock__:
path = self.get_root() / f"{self.id}.yml" if not self.id:
path.write_text(self.dump()) self.id = uuid.uuid4().hex
self.path.write_text(self.dump())
def archive(self):
if self.is_archived:
raise ValueError("La livraison est déjà archivée")
current = self.path
self.id = f"archive/{self.id}"
current.rename(self.path)
def unarchive(self):
if not self.is_archived:
raise ValueError("La livraison n'est pas archivée")
current = self.path
self.id = self.path.stem
current.rename(self.path)
def product_wanted(self, product): def product_wanted(self, product):
total = 0 total = 0

View file

@ -0,0 +1,5 @@
{% extends "base.html" %}
{% block body %}
<h2>Livraisons archivées</h2>
{% include "includes/delivery_list.html" %}
{% endblock body %}

View file

@ -58,6 +58,7 @@
</article> </article>
<hr> <hr>
<ul class="toolbox"> <ul class="toolbox">
{% if not delivery.is_archived %}
<li> <li>
<a href="/livraison/{{ delivery.id }}/bon-de-commande.xlsx"><i class="icon-ribbon"></i> Bon de commande</a> <a href="/livraison/{{ delivery.id }}/bon-de-commande.xlsx"><i class="icon-ribbon"></i> Bon de commande</a>
</li> </li>
@ -95,5 +96,12 @@
</li> </li>
{% endif %} {% endif %}
{% endif %} {% endif %}
{% else %}
{% if request.user and request.user.is_staff %}
<li>
<a href="/livraison/{{ delivery.id }}/désarchiver"><i class="icon-hazardous"></i> Désarchiver</a>
</li>
{% endif %}
{% endif %}
</ul> </ul>
{% endblock body %} {% endblock body %}

View file

@ -45,32 +45,17 @@
</form> </form>
<hr> <hr>
{% if delivery %} {% if delivery %}
<h3>Importer des produits</h3> <ul class="toolbox">
<p>Formats pris en charge: xlsx, csv</p> {% if delivery.status == delivery.CLOSED %}
<details> <li>
<summary>Détails des colonnes</summary> <a href="/livraison/{{ delivery.id }}/archiver"><i class="icon-hazardous"></i> Archiver</a>
<dl> </li>
<dt class="mandatory">ref</dt> {% endif %}
<dd>Référence unique du produit (permet de mettre à jour les produits en cours de commande ou d'importer des commandes individuelles).</dd> <li>
<dt class="mandatory">name</dt> {% with unique_id="import-products" %}
<dd>Nom du produit: mettre juste assez d'info pour distinguer les produits les uns des autres.</dd> {% include "includes/modal_import_products.html" %}
<dt class="mandatory">price</dt> {% endwith %}
<dd>Prix d'une unité, en euros.</dd> </li>
<dt>unit</dt> </ul>
<dd>Conditionnement d'une unité: 1kg, 33cl…</dd>
<dt>description</dt>
<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>
<dd>Une URL éventuelle pointant sur une page présentant le produit.</dd>
<dt>img</dt>
<dd>Une URL éventuelle pointant sur une image du produit (attention, utiliser seulement des liens https).</dd>
</dl>
</details>
<form action="/livraison/{{ delivery.id }}/importer/produits" method="post" enctype="multipart/form-data">
<input type="file" name="data" required>
<input type="submit" value="Mettre à jour les produits">
</form>
{% endif %} {% endif %}
{% endblock body %} {% endblock body %}

View file

@ -8,4 +8,5 @@
{% with deliveries=former %} {% with deliveries=former %}
{% include "includes/delivery_list.html" %} {% include "includes/delivery_list.html" %}
{% endwith %} {% endwith %}
<a href="/archives">Voir les livraisons archivées</a>
{% endblock body %} {% endblock body %}

View file

@ -3,7 +3,7 @@
<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-strategy"></i> <strong>Référent</strong> <a href="mailto:{{ delivery.contact }}">{{ delivery.contact }}</a></li> <li><i class="icon-strategy"></i> <strong>Référent</strong> <a href="mailto:{{ delivery.contact }}">{{ delivery.contact }}</a></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.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> <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>{% elif delivery.status == delivery.CLOSED %}<strong>Fermée</strong>{% else %}<strong>Archivé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 %}
{% if delivery.infos_url %}<li><i class="icon-global"></i><strong>Plus d'infos</strong> <a href="{{ delivery.infos_url }}" title="{{ delivery.infos_url }}">{{ delivery.infos_url|truncate(20)}}</a></li>{% endif %} {% if delivery.infos_url %}<li><i class="icon-global"></i><strong>Plus d'infos</strong> <a href="{{ delivery.infos_url }}" title="{{ delivery.infos_url }}">{{ delivery.infos_url|truncate(20)}}</a></li>{% endif %}
</ul> </ul>

View file

@ -0,0 +1,32 @@
{% extends "includes/modal.html" %}
{% block modal_label %}<i class="icon-upload"></i> Importer les produits{% endblock modal_label %}
{% block modal_body %}
<h3>Importer des produits</h3>
<p>Formats pris en charge: xlsx, csv</p>
<details>
<summary>Détails des colonnes</summary>
<dl>
<dt class="mandatory">ref</dt>
<dd>Référence unique du produit (permet de mettre à jour les produits en cours de commande ou d'importer des commandes individuelles).</dd>
<dt class="mandatory">name</dt>
<dd>Nom du produit: mettre juste assez d'info pour distinguer les produits les uns des autres.</dd>
<dt class="mandatory">price</dt>
<dd>Prix d'une unité, en euros.</dd>
<dt>unit</dt>
<dd>Conditionnement d'une unité: 1kg, 33cl…</dd>
<dt>description</dt>
<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>
<dd>Une URL éventuelle pointant sur une page présentant le produit.</dd>
<dt>img</dt>
<dd>Une URL éventuelle pointant sur une image du produit (attention, utiliser seulement des liens https).</dd>
</dl>
</details>
<form action="/livraison/{{ delivery.id }}/importer/produits" method="post" enctype="multipart/form-data">
<input type="file" name="data" required>
<input type="submit" value="Mettre à jour les produits">
</form>
{% endblock modal_body %}

View file

@ -1,4 +1,4 @@
{% if delivery.status != delivery.CLOSED %} {% if delivery.status == delivery.OPEN or delivery.status == delivery.ADJUSTMENT %}
<a class="button" href="/livraison/{{ delivery.id }}/commander"> <a class="button" href="/livraison/{{ delivery.id }}/commander">
{% if delivery.status == delivery.ADJUSTMENT %} {% if delivery.status == delivery.ADJUSTMENT %}
Ajuster ma commande Ajuster ma commande

View file

@ -20,7 +20,7 @@ def test_can_create_delivery():
assert delivery.producer == "Andines" assert delivery.producer == "Andines"
assert delivery.where == "Marché de la Briche" assert delivery.where == "Marché de la Briche"
assert delivery.from_date.year == now().year assert delivery.from_date.year == now().year
assert delivery.id assert not delivery.id
def test_wrong_datetime_raise_valueerror(): def test_wrong_datetime_raise_valueerror():
@ -106,7 +106,12 @@ def test_order_has_adjustments():
def test_can_persist_delivery(delivery): def test_can_persist_delivery(delivery):
with pytest.raises(AssertionError):
delivery.path
assert not delivery.id
delivery.persist() delivery.persist()
assert delivery.id
assert delivery.path.exists()
def test_can_load_delivery(delivery): def test_can_load_delivery(delivery):
@ -138,3 +143,23 @@ def test_productorder_quantity():
assert choice.quantity == 5 assert choice.quantity == 5
choice = ProductOrder(wanted=3, adjustment=-1) choice = ProductOrder(wanted=3, adjustment=-1)
assert choice.quantity == 2 assert choice.quantity == 2
def test_archive_delivery(delivery):
delivery.persist()
old_id = delivery.id
old_path = delivery.path
assert str(old_path).endswith(f"delivery/{delivery.id}.yml")
assert old_path.exists()
delivery.archive()
assert delivery.is_archived
assert delivery.id.startswith("archive/")
new_path = delivery.path
assert str(new_path).endswith(f"delivery/archive/{old_id}.yml")
assert not old_path.exists()
assert new_path.exists()
delivery.unarchive()
assert not delivery.id.startswith("archive/")
assert old_path.exists()
assert not new_path.exists()
assert not delivery.is_archived