Merge branch 'master' into crac

This commit is contained in:
Alexis M 2019-10-05 21:12:16 +02:00
commit d54324c2c1
12 changed files with 131 additions and 61 deletions

View file

@ -181,15 +181,19 @@ async def sesame(request, response):
async def send_sesame(request, response): async def send_sesame(request, response):
email = request.form.get("email") email = request.form.get("email")
token = utils.create_token(email) token = utils.create_token(email)
emails.send_from_template( try:
env, emails.send_from_template(
"access_granted", env,
email, "access_granted",
f"Sésame {config.SITE_NAME}", email,
hostname=request.host, f"Sésame {config.SITE_NAME}",
token=token.decode(), hostname=request.host,
) token=token.decode(),
response.message(f"Un sésame vous a été envoyé à l'adresse '{email}'") )
except RuntimeError:
response.message("Oops, impossible d'envoyer le courriel…", status="error")
else:
response.message(f"Un sésame vous a été envoyé à l'adresse '{email}'")
response.redirect = "/" response.redirect = "/"
@ -533,6 +537,12 @@ async def export_products(request, response, id):
response.xlsx(reports.products(delivery)) response.xlsx(reports.products(delivery))
@app.route("/livraison/archive/{id}/exporter/produits", methods=["GET"])
async def export_archived_products(request, response, id):
delivery = Delivery.load(f"archive/{id}")
response.xlsx(reports.products(delivery))
@app.route("/livraison/{id}/edit", methods=["GET"]) @app.route("/livraison/{id}/edit", methods=["GET"])
@staff_only @staff_only
async def edit_delivery(request, response, id): async def edit_delivery(request, response, id):
@ -617,7 +627,6 @@ async def place_order(request, response, id):
delivery.persist() delivery.persist()
if user and orderer.id == user.id: if user and orderer.id == user.id:
# Only send email if order has been placed by the user itself.
# Send the emails to everyone in the group. # Send the emails to everyone in the group.
groups = request["groups"].groups groups = request["groups"].groups
if orderer.group_id in groups.keys(): if orderer.group_id in groups.keys():
@ -643,9 +652,13 @@ async def place_order(request, response, id):
response.redirect = f"/livraison/{delivery.id}" response.redirect = f"/livraison/{delivery.id}"
else: else:
order = delivery.orders.get(orderer.id) or Order() order = delivery.orders.get(orderer.id) or Order()
force_adjustment = "adjust" in request.query and user and user.is_staff
response.html( response.html(
"place_order.html", "place_order.html",
{"delivery": delivery, "person": orderer, "order": order}, delivery=delivery,
person=orderer,
order=order,
force_adjustment=force_adjustment,
) )
@ -657,10 +670,14 @@ async def send_order(request, response, id):
if not order: if not order:
response.message(f"Aucune commande pour «{email}»", status="warning") response.message(f"Aucune commande pour «{email}»", status="warning")
else: else:
emails.send_order( try:
request, env, person=Person(email=email), delivery=delivery, order=order emails.send_order(
) request, env, person=Person(email=email), delivery=delivery, order=order
response.message(f"Résumé de commande envoyé à «{email}»") )
except RuntimeError:
response.message("Oops, impossible d'envoyer le courriel…", status="error")
else:
response.message(f"Résumé de commande envoyé à «{email}»")
response.redirect = f"/livraison/{delivery.id}" response.redirect = f"/livraison/{delivery.id}"

View file

@ -245,6 +245,7 @@ class Order(Base):
return round( return round(
sum(p.quantity * _get_price(ref) for ref, p in self.products.items()), 2 sum(p.quantity * _get_price(ref) for ref, p in self.products.items()), 2
) )
return round(total, 2)
@property @property
def has_adjustments(self): def has_adjustments(self):
@ -352,7 +353,9 @@ class Delivery(PersistedBase):
@classmethod @classmethod
def incoming(cls): def incoming(cls):
return [d for d in cls.all() if d.is_foreseen] return sorted(
[d for d in cls.all() if d.is_foreseen], key=lambda d: d.order_before
)
@classmethod @classmethod
def former(cls): def former(cls):
@ -425,3 +428,8 @@ class Delivery(PersistedBase):
def get_referents(self): def get_referents(self):
return [producer.referent for producer in self.producers.values()] return [producer.referent for producer in self.producers.values()]
def total_for(self, person):
if person.email not in self.orders:
return 0
return self.orders[person.email].total(self.products)

View file

@ -1,23 +1,14 @@
:root { :root {
--primary-color: #0062b7; --primary-color: #0062b7;
--primary-color-light: #e6f0fa; --link-color: #00d1b2;
--secondary-color: #e10055;
--text-color: #414664; --text-color: #414664;
--border-color: #e6e6eb; --light-text-color: #fafafb;
--primary-background-color: #fff; --background-color: #fafafb;
--secondary-background-color: #fafafb; --flag-color: #A87CA0;
--disease: #7846af; --warning-color: #FFA631;
--disease-light: #f5ebfa; --danger-color: #d9534f;
--country: #0f8796; --neutral-color: #4D8FAC;
--country-light: #e6f5f5; --success-color: #0f8796;
--group: #d03800;
--group-light: #fff5eb;
--keyword: #8c5a2d;
--keyword-light: #f5f0eb;
--kind: #cd0073;
--kind-light: #faf0f5;
--ern: #32009b;
--ern-light: #ebebf5;
} }
@ -103,7 +94,7 @@ body {
font-size: .8rem; font-size: .8rem;
font-family: 'Work Sans', sans-serif; font-family: 'Work Sans', sans-serif;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
background-color: var(--secondary-background-color); background-color: var(--background-color);
padding: 0; padding: 0;
margin: 0; margin: 0;
} }
@ -113,7 +104,6 @@ h3,
h4, h4,
h5, h5,
legend { legend {
/*margin: 0;*/
color: #444; color: #444;
line-height: 1; line-height: 1;
font-weight: 300; font-weight: 300;
@ -125,7 +115,7 @@ h3 { font-size: 1.4rem }
h4 { font-size: 1.1rem } h4 { font-size: 1.1rem }
a { a {
color: #00d1b2; color: var(--link-color);
cursor: pointer; cursor: pointer;
text-decoration: none; text-decoration: none;
-webkit-transition: none 86ms ease-out; -webkit-transition: none 86ms ease-out;
@ -165,6 +155,7 @@ main {
padding: 1rem; padding: 1rem;
} }
.flag,
button, button,
a.button, a.button,
input[type=submit] { input[type=submit] {
@ -190,13 +181,14 @@ input[type=submit] {
cursor: pointer; cursor: pointer;
} }
input[type=submit], input[type=submit],
input[type=submit] + a.button { input[type=submit] + a.button,
a.button + a.button {
margin-top: 5px; margin-top: 5px;
} }
input[type=submit]:hover, input[type=submit]:hover,
.button:hover { .button:hover {
color: #fff; color: var(--light-text-color);
background-color: var(--primary-color); background-color: var(--primary-color);
} }
@ -204,29 +196,40 @@ input[type=submit]:hover,
button.primary, button.primary,
a.button.primary, a.button.primary,
input[type=submit].primary { input[type=submit].primary {
color: #fff; color: var(--background-color);
background: var(--primary-color); background: var(--primary-color);
} }
button.primary:hover, button.primary:hover,
a.button.primary:hover, a.button.primary:hover,
input[type=submit].primary:hover { input[type=submit].primary:hover {
background-color: #fff; background-color: var(--background-color);
color: var(--primary-color); color: var(--primary-color);
} }
button.danger, button.danger,
a.button.danger, a.button.danger,
input[type=submit].danger { input[type=submit].danger {
color: #d9534f; color: var(--danger-color);
border-color: #d9534f; border-color: var(--danger-color);
} }
button.danger:hover, button.danger:hover,
a.button.danger:hover, a.button.danger:hover,
input[type=submit].danger:hover { input[type=submit].danger:hover {
background-color: #d9534f; background-color: var(--danger-color);
color: #fff; color: var(--light-text-color);
}
.flag {
border-color: var(--flag-color);
color: var(--light-text-color);
background-color: var(--flag-color);
cursor: help;
}
.flag.warning {
background-color: var(--warning-color);
border-color: var(--warning-color);
font-weight: 600;
} }
@ -247,10 +250,10 @@ textarea {
position: relative; position: relative;
height: 2rem; height: 2rem;
padding: .4rem .8rem; padding: .4rem .8rem;
color: #50596c; color: var(--text-color);
font-size: .8rem; font-size: .8rem;
line-height: 1rem; line-height: 1rem;
background-color: #fff; background-color: var(--background-color);
border: .05rem solid #bbc; border: .05rem solid #bbc;
box-sizing: border-box; box-sizing: border-box;
} }
@ -351,10 +354,10 @@ tr:nth-child(even) {
background-color: #ddd; background-color: #ddd;
} }
thead tr { thead tr {
background-color: #3498db; background-color: var(--neutral-color);
} }
thead tr * { thead tr * {
color: #f1f1f1; color: var(--light-text-color);
} }
thead th + th { thead th + th {
border-left: 1px solid white; border-left: 1px solid white;
@ -398,11 +401,11 @@ article.delivery th.person {
} }
td.missing, td.missing,
th.missing { th.missing {
background-color: #db7734; background-color: var(--warning-color);
} }
.missing a { .missing a {
color: #f1f1f1; color: var(--light-text-color);
border-color: #f1f1f1; border-color: var(--light-text-color);
height: 1rem; height: 1rem;
} }
hr { hr {
@ -436,13 +439,13 @@ hr {
vertical-align: middle; vertical-align: middle;
} }
.notification.success { .notification.success {
background-color: #0f8796; background-color: var(--success-color);
} }
.notification.error { .notification.error {
background-color: #e10055; background-color: var(--danger-color);
} }
.notification.warning { .notification.warning {
background-color: #f9b42d; background-color: var(--warning-color);
} }
.notification.info { .notification.info {
background-color: #aed1b175; background-color: #aed1b175;
@ -453,14 +456,14 @@ hr {
font-size: 2rem; font-size: 2rem;
} }
.not-paid { .not-paid {
background-color: #db7734; background-color: var(--warning-color);
} }
.toggle { .toggle {
display: none; display: none;
} }
.toggle-label { .toggle-label {
cursor: pointer; cursor: pointer;
color: #00d1b2; color: var(--link-color);
} }
.toggle-container { .toggle-container {
display: none; display: none;

View file

@ -42,6 +42,9 @@
</li> </li>
{% endif %} {% endif %}
{% if request.user and request.user.is_staff %} {% if request.user and request.user.is_staff %}
<li>
<a href="/livraison/{{ delivery.id }}/désarchiver" class="button danger"><i class="icon-hazardous"></i>&nbsp;Désarchiver</a>
</li>
<li> <li>
<a href="/livraison/{{ delivery.id }}/exporter"><i class="icon-layers"></i> Liste des produits</a> <a href="/livraison/{{ delivery.id }}/exporter"><i class="icon-layers"></i> Liste des produits</a>
</li> </li>

View file

@ -48,7 +48,7 @@
<ul class="toolbox"> <ul class="toolbox">
{% if delivery.status == delivery.CLOSED %} {% if delivery.status == delivery.CLOSED %}
<li> <li>
<a href="/livraison/{{ delivery.id }}/archiver" class="button danger"><i class="icon-hazardous"></i>&nbsp;Archiver</a> <a href="/livraison/{{ delivery.id }}/archiver" class="button danger"><i class="icon-layers"></i>&nbsp;Archiver</a>
</li> </li>
{% endif %} {% endif %}
<li> <li>

View file

@ -1,5 +1,7 @@
<p>Hey ho!</p> <p>Hey ho!</p>
<p>Voici le sésame, <a href="https://{{ hostname }}/sésame/{{ token }}">clique ici</a> pour t'authentifier.</p> <p>Voici le sésame, clique dessus pour t'authentifier, ou copie-colle-le dans ton navigateur:</p>
<a href="https://{{ hostname }}/sésame/{{ token }}">https://{{ hostname }}/sésame/{{ token }}</a>
<p>{{ config.EMAIL_SIGNATURE }}</p> <p>{{ config.EMAIL_SIGNATURE }}</p>

View file

@ -7,3 +7,9 @@
{% endif %} {% endif %}
</a> </a>
{% endif %} {% endif %}
{% if request.user.email in delivery.orders %}
<span class="flag" title="Mon solde"><i class="icon-wallet"></i>&nbsp;{{ delivery.total_for(request.user) }} €</span>
{% if delivery.status == delivery.CLOSED and delivery.is_passed and not delivery.orders[request.user.email].paid %}
<span class="flag warning" title="Ma commande n'est pas marquée comme soldée"><i class="icon-caution"></i>&nbsp;Commande à solder</span>
{% endif %}
{% endif %}

View file

@ -3,6 +3,7 @@
{% for product in delivery.products %} {% for product in delivery.products %}
{% if order[product].quantity %} {% if order[product].quantity %}
<tr> <tr>
<td>{{ product.ref }}</td>
<th class="product" style="text-align: left;">{{ product }}</th> <th class="product" style="text-align: left;">{{ product }}</th>
{% if display_prices %}<td>{{ product.price | round(2) }} €</td>{% endif %}<td>{{ order[product].quantity }} x {{ product.unit }}</td> {% if display_prices %}<td>{{ product.price | round(2) }} €</td>{% endif %}<td>{{ order[product].quantity }} x {{ product.unit }}</td>
</tr> </tr>

View file

@ -17,7 +17,7 @@
<th class="packing">Conditionnement</th> <th class="packing">Conditionnement</th>
{% endif %} {% endif %}
<th class="amount">Commande</th> <th class="amount">Commande</th>
{% if delivery.status == delivery.ADJUSTMENT or order.has_adjustments %} {% if delivery.status == delivery.ADJUSTMENT or order.has_adjustments or force_adjustment %}
<th class="amount">Ajustement +/</th> <th class="amount">Ajustement +/</th>
{% endif %} {% endif %}
</tr> </tr>
@ -36,8 +36,8 @@
{% if delivery.has_packing %} {% 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) %} (manque {{ delivery.product_missing(product) }}){% endif %}</td> <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) %} (manque {{ delivery.product_missing(product) }}){% endif %}</td>
{% endif %} {% endif %}
{% if delivery.status == delivery.ADJUSTMENT or order.has_adjustments %} {% if delivery.status == delivery.ADJUSTMENT or order.has_adjustments or force_adjustments %}
<td class="with-input"><input type="number" name="adjustment:{{ product.ref }}" value="{{ order[product].adjustment }}" {% if not delivery.product_missing(product) %}readonly{% endif %}></td> <td class="with-input"><input type="number" name="adjustment:{{ product.ref }}" value="{{ order[product].adjustment }}" min="{{ order[product].wanted * -1 }}" {% if not (delivery.product_missing(product) or force_adjustment) %}readonly{% endif %}></td>
{% endif %} {% endif %}
<td class="with-input"><input {% if not request.user.is_referent(delivery) and delivery.status != delivery.OPEN or product.rupture %}type="text" readonly{% else%}type="number"{% endif%} min=0 name="wanted:{{ product.ref }}" value="{{ order[product].wanted }}"> x {{ product.unit }}</td> <td class="with-input"><input {% if not request.user.is_referent(delivery) and delivery.status != delivery.OPEN or product.rupture %}type="text" readonly{% else%}type="number"{% endif%} min=0 name="wanted:{{ product.ref }}" value="{{ order[product].wanted }}"> x {{ product.unit }}</td>
</tr> </tr>
@ -54,6 +54,9 @@
<input type="submit" value="Enregistrer la commande" class="primary"> <input type="submit" value="Enregistrer la commande" class="primary">
{% endif %} {% endif %}
<a class="button" href="/livraison/{{ delivery.id }}/courriel?email={{ person.email }}">Envoyer par courriel</a> <a class="button" href="/livraison/{{ delivery.id }}/courriel?email={{ person.email }}">Envoyer par courriel</a>
{% if request.user.is_staff and delivery.status == delivery.CLOSED %}
<a class="button danger" href="/livraison/{{ delivery.id }}/commander?email={{ person.email }}&adjust">Ajuster</a>
{% endif %}
</form> </form>
</article> </article>
{% endblock body %} {% endblock body %}

View file

@ -184,6 +184,7 @@ def upload_env():
"COPANIER_SEND_EMAILS": "1", "COPANIER_SEND_EMAILS": "1",
"COPANIER_SMTP_PASSWORD": None, "COPANIER_SMTP_PASSWORD": None,
"COPANIER_SMTP_LOGIN": None, "COPANIER_SMTP_LOGIN": None,
"COPANIER_SMTP_HOST": None,
"COPANIER_STAFF": None, "COPANIER_STAFF": None,
} }
content = "" content = ""

View file

@ -105,6 +105,16 @@ def test_order_has_adjustments():
assert order.has_adjustments assert order.has_adjustments
def test_order_total(delivery):
delivery.products = [Product(name="Lait", ref="123", price=1.5)]
order = Order()
assert order.total(delivery.products) == 0
order.products["123"] = ProductOrder(wanted=2)
assert order.total(delivery.products) == 3
order.products["unknown"] = ProductOrder(wanted=2)
assert order.total(delivery.products) == 3
def test_can_persist_delivery(delivery): def test_can_persist_delivery(delivery):
with pytest.raises(AssertionError): with pytest.raises(AssertionError):
delivery.path delivery.path

View file

@ -131,6 +131,7 @@ async def test_get_place_order_with_adjustment_status(client, delivery):
assert doc('[name="wanted:123"]').attr("readonly") assert doc('[name="wanted:123"]').attr("readonly")
assert doc('[name="adjustment:123"]') assert doc('[name="adjustment:123"]')
assert not doc('[name="adjustment:123"]').attr("readonly") assert not doc('[name="adjustment:123"]').attr("readonly")
assert doc('[name="adjustment:123"]').attr("min") == "-1"
assert doc('[name="wanted:456"]').attr("readonly") assert doc('[name="wanted:456"]').attr("readonly")
assert doc('[name="adjustment:456"]') assert doc('[name="adjustment:456"]')
# Already adjusted. # Already adjusted.
@ -154,6 +155,21 @@ async def test_get_place_order_with_closed_delivery_but_adjustments(client, deli
assert doc('[name="adjustment:123"]') assert doc('[name="adjustment:123"]')
async def test_get_place_order_with_closed_delivery_but_force(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") is not None
assert not doc('[name="adjustment:123"]')
resp = await client.get(f"/livraison/{delivery.id}/commander?adjust")
doc = pq(resp.body)
assert doc('[name="wanted:123"]').attr("readonly") is not None
assert doc('[name="adjustment:123"]')
async def test_cannot_place_order_on_closed_delivery(client, delivery, monkeypatch): async def test_cannot_place_order_on_closed_delivery(client, delivery, monkeypatch):
monkeypatch.setattr("copanier.config.STAFF", ["someone@else.org"]) monkeypatch.setattr("copanier.config.STAFF", ["someone@else.org"])
delivery.order_before = datetime.now() - timedelta(days=1) delivery.order_before = datetime.now() - timedelta(days=1)