mirror of
https://github.com/almet/copanier.git
synced 2025-04-28 19:42:37 +02:00
Merge branch 'master' into crac
This commit is contained in:
commit
d54324c2c1
12 changed files with 131 additions and 61 deletions
|
@ -181,15 +181,19 @@ async def sesame(request, response):
|
|||
async def send_sesame(request, response):
|
||||
email = request.form.get("email")
|
||||
token = utils.create_token(email)
|
||||
emails.send_from_template(
|
||||
env,
|
||||
"access_granted",
|
||||
email,
|
||||
f"Sésame {config.SITE_NAME}",
|
||||
hostname=request.host,
|
||||
token=token.decode(),
|
||||
)
|
||||
response.message(f"Un sésame vous a été envoyé à l'adresse '{email}'")
|
||||
try:
|
||||
emails.send_from_template(
|
||||
env,
|
||||
"access_granted",
|
||||
email,
|
||||
f"Sésame {config.SITE_NAME}",
|
||||
hostname=request.host,
|
||||
token=token.decode(),
|
||||
)
|
||||
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 = "/"
|
||||
|
||||
|
||||
|
@ -533,6 +537,12 @@ async def export_products(request, response, id):
|
|||
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"])
|
||||
@staff_only
|
||||
async def edit_delivery(request, response, id):
|
||||
|
@ -617,7 +627,6 @@ async def place_order(request, response, id):
|
|||
delivery.persist()
|
||||
|
||||
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.
|
||||
groups = request["groups"].groups
|
||||
if orderer.group_id in groups.keys():
|
||||
|
@ -643,9 +652,13 @@ async def place_order(request, response, id):
|
|||
response.redirect = f"/livraison/{delivery.id}"
|
||||
else:
|
||||
order = delivery.orders.get(orderer.id) or Order()
|
||||
force_adjustment = "adjust" in request.query and user and user.is_staff
|
||||
response.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:
|
||||
response.message(f"Aucune commande pour «{email}»", status="warning")
|
||||
else:
|
||||
emails.send_order(
|
||||
request, env, person=Person(email=email), delivery=delivery, order=order
|
||||
)
|
||||
response.message(f"Résumé de commande envoyé à «{email}»")
|
||||
try:
|
||||
emails.send_order(
|
||||
request, env, person=Person(email=email), delivery=delivery, order=order
|
||||
)
|
||||
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}"
|
||||
|
||||
|
||||
|
|
|
@ -245,6 +245,7 @@ class Order(Base):
|
|||
return round(
|
||||
sum(p.quantity * _get_price(ref) for ref, p in self.products.items()), 2
|
||||
)
|
||||
return round(total, 2)
|
||||
|
||||
@property
|
||||
def has_adjustments(self):
|
||||
|
@ -352,7 +353,9 @@ class Delivery(PersistedBase):
|
|||
|
||||
@classmethod
|
||||
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
|
||||
def former(cls):
|
||||
|
@ -425,3 +428,8 @@ class Delivery(PersistedBase):
|
|||
|
||||
def get_referents(self):
|
||||
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)
|
||||
|
|
|
@ -1,23 +1,14 @@
|
|||
:root {
|
||||
--primary-color: #0062b7;
|
||||
--primary-color-light: #e6f0fa;
|
||||
--secondary-color: #e10055;
|
||||
--link-color: #00d1b2;
|
||||
--text-color: #414664;
|
||||
--border-color: #e6e6eb;
|
||||
--primary-background-color: #fff;
|
||||
--secondary-background-color: #fafafb;
|
||||
--disease: #7846af;
|
||||
--disease-light: #f5ebfa;
|
||||
--country: #0f8796;
|
||||
--country-light: #e6f5f5;
|
||||
--group: #d03800;
|
||||
--group-light: #fff5eb;
|
||||
--keyword: #8c5a2d;
|
||||
--keyword-light: #f5f0eb;
|
||||
--kind: #cd0073;
|
||||
--kind-light: #faf0f5;
|
||||
--ern: #32009b;
|
||||
--ern-light: #ebebf5;
|
||||
--light-text-color: #fafafb;
|
||||
--background-color: #fafafb;
|
||||
--flag-color: #A87CA0;
|
||||
--warning-color: #FFA631;
|
||||
--danger-color: #d9534f;
|
||||
--neutral-color: #4D8FAC;
|
||||
--success-color: #0f8796;
|
||||
}
|
||||
|
||||
|
||||
|
@ -103,7 +94,7 @@ body {
|
|||
font-size: .8rem;
|
||||
font-family: 'Work Sans', sans-serif;
|
||||
text-rendering: optimizeLegibility;
|
||||
background-color: var(--secondary-background-color);
|
||||
background-color: var(--background-color);
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
@ -113,7 +104,6 @@ h3,
|
|||
h4,
|
||||
h5,
|
||||
legend {
|
||||
/*margin: 0;*/
|
||||
color: #444;
|
||||
line-height: 1;
|
||||
font-weight: 300;
|
||||
|
@ -125,7 +115,7 @@ h3 { font-size: 1.4rem }
|
|||
h4 { font-size: 1.1rem }
|
||||
|
||||
a {
|
||||
color: #00d1b2;
|
||||
color: var(--link-color);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
-webkit-transition: none 86ms ease-out;
|
||||
|
@ -165,6 +155,7 @@ main {
|
|||
padding: 1rem;
|
||||
}
|
||||
|
||||
.flag,
|
||||
button,
|
||||
a.button,
|
||||
input[type=submit] {
|
||||
|
@ -190,13 +181,14 @@ input[type=submit] {
|
|||
cursor: pointer;
|
||||
}
|
||||
input[type=submit],
|
||||
input[type=submit] + a.button {
|
||||
input[type=submit] + a.button,
|
||||
a.button + a.button {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
input[type=submit]:hover,
|
||||
.button:hover {
|
||||
color: #fff;
|
||||
color: var(--light-text-color);
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
|
||||
|
@ -204,29 +196,40 @@ input[type=submit]:hover,
|
|||
button.primary,
|
||||
a.button.primary,
|
||||
input[type=submit].primary {
|
||||
color: #fff;
|
||||
color: var(--background-color);
|
||||
background: var(--primary-color);
|
||||
}
|
||||
|
||||
button.primary:hover,
|
||||
a.button.primary:hover,
|
||||
input[type=submit].primary:hover {
|
||||
background-color: #fff;
|
||||
background-color: var(--background-color);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
button.danger,
|
||||
a.button.danger,
|
||||
input[type=submit].danger {
|
||||
color: #d9534f;
|
||||
border-color: #d9534f;
|
||||
color: var(--danger-color);
|
||||
border-color: var(--danger-color);
|
||||
}
|
||||
|
||||
button.danger:hover,
|
||||
a.button.danger:hover,
|
||||
input[type=submit].danger:hover {
|
||||
background-color: #d9534f;
|
||||
color: #fff;
|
||||
background-color: var(--danger-color);
|
||||
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;
|
||||
height: 2rem;
|
||||
padding: .4rem .8rem;
|
||||
color: #50596c;
|
||||
color: var(--text-color);
|
||||
font-size: .8rem;
|
||||
line-height: 1rem;
|
||||
background-color: #fff;
|
||||
background-color: var(--background-color);
|
||||
border: .05rem solid #bbc;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
@ -351,10 +354,10 @@ tr:nth-child(even) {
|
|||
background-color: #ddd;
|
||||
}
|
||||
thead tr {
|
||||
background-color: #3498db;
|
||||
background-color: var(--neutral-color);
|
||||
}
|
||||
thead tr * {
|
||||
color: #f1f1f1;
|
||||
color: var(--light-text-color);
|
||||
}
|
||||
thead th + th {
|
||||
border-left: 1px solid white;
|
||||
|
@ -398,11 +401,11 @@ article.delivery th.person {
|
|||
}
|
||||
td.missing,
|
||||
th.missing {
|
||||
background-color: #db7734;
|
||||
background-color: var(--warning-color);
|
||||
}
|
||||
.missing a {
|
||||
color: #f1f1f1;
|
||||
border-color: #f1f1f1;
|
||||
color: var(--light-text-color);
|
||||
border-color: var(--light-text-color);
|
||||
height: 1rem;
|
||||
}
|
||||
hr {
|
||||
|
@ -436,13 +439,13 @@ hr {
|
|||
vertical-align: middle;
|
||||
}
|
||||
.notification.success {
|
||||
background-color: #0f8796;
|
||||
background-color: var(--success-color);
|
||||
}
|
||||
.notification.error {
|
||||
background-color: #e10055;
|
||||
background-color: var(--danger-color);
|
||||
}
|
||||
.notification.warning {
|
||||
background-color: #f9b42d;
|
||||
background-color: var(--warning-color);
|
||||
}
|
||||
.notification.info {
|
||||
background-color: #aed1b175;
|
||||
|
@ -453,14 +456,14 @@ hr {
|
|||
font-size: 2rem;
|
||||
}
|
||||
.not-paid {
|
||||
background-color: #db7734;
|
||||
background-color: var(--warning-color);
|
||||
}
|
||||
.toggle {
|
||||
display: none;
|
||||
}
|
||||
.toggle-label {
|
||||
cursor: pointer;
|
||||
color: #00d1b2;
|
||||
color: var(--link-color);
|
||||
}
|
||||
.toggle-container {
|
||||
display: none;
|
||||
|
|
|
@ -42,6 +42,9 @@
|
|||
</li>
|
||||
{% endif %}
|
||||
{% if request.user and request.user.is_staff %}
|
||||
<li>
|
||||
<a href="/livraison/{{ delivery.id }}/désarchiver" class="button danger"><i class="icon-hazardous"></i> Désarchiver</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/livraison/{{ delivery.id }}/exporter"><i class="icon-layers"></i> Liste des produits</a>
|
||||
</li>
|
||||
|
|
|
@ -48,7 +48,7 @@
|
|||
<ul class="toolbox">
|
||||
{% if delivery.status == delivery.CLOSED %}
|
||||
<li>
|
||||
<a href="/livraison/{{ delivery.id }}/archiver" class="button danger"><i class="icon-hazardous"></i> Archiver</a>
|
||||
<a href="/livraison/{{ delivery.id }}/archiver" class="button danger"><i class="icon-layers"></i> Archiver</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li>
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
<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>
|
||||
|
|
|
@ -7,3 +7,9 @@
|
|||
{% endif %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if request.user.email in delivery.orders %}
|
||||
<span class="flag" title="Mon solde"><i class="icon-wallet"></i> {{ 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> Commande à solder</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
{% for product in delivery.products %}
|
||||
{% if order[product].quantity %}
|
||||
<tr>
|
||||
<td>{{ product.ref }}</td>
|
||||
<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>
|
||||
</tr>
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
<th class="packing">Conditionnement</th>
|
||||
{% endif %}
|
||||
<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>
|
||||
{% endif %}
|
||||
</tr>
|
||||
|
@ -36,8 +36,8 @@
|
|||
{% 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>
|
||||
{% endif %}
|
||||
{% if delivery.status == delivery.ADJUSTMENT or order.has_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>
|
||||
{% 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 }}" min="{{ order[product].wanted * -1 }}" {% if not (delivery.product_missing(product) or force_adjustment) %}readonly{% endif %}></td>
|
||||
{% 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>
|
||||
</tr>
|
||||
|
@ -54,6 +54,9 @@
|
|||
<input type="submit" value="Enregistrer la commande" class="primary">
|
||||
{% endif %}
|
||||
<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>
|
||||
</article>
|
||||
{% endblock body %}
|
||||
|
|
|
@ -184,6 +184,7 @@ def upload_env():
|
|||
"COPANIER_SEND_EMAILS": "1",
|
||||
"COPANIER_SMTP_PASSWORD": None,
|
||||
"COPANIER_SMTP_LOGIN": None,
|
||||
"COPANIER_SMTP_HOST": None,
|
||||
"COPANIER_STAFF": None,
|
||||
}
|
||||
content = ""
|
||||
|
|
|
@ -105,6 +105,16 @@ def test_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):
|
||||
with pytest.raises(AssertionError):
|
||||
delivery.path
|
||||
|
|
|
@ -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="adjustment:123"]')
|
||||
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="adjustment:456"]')
|
||||
# Already adjusted.
|
||||
|
@ -154,6 +155,21 @@ async def test_get_place_order_with_closed_delivery_but_adjustments(client, deli
|
|||
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):
|
||||
monkeypatch.setattr("copanier.config.STAFF", ["someone@else.org"])
|
||||
delivery.order_before = datetime.now() - timedelta(days=1)
|
||||
|
|
Loading…
Reference in a new issue