diff --git a/kaba/__init__.py b/kaba/__init__.py index 1be51ff..7768083 100644 --- a/kaba/__init__.py +++ b/kaba/__init__.py @@ -1,7 +1,9 @@ import csv +from datetime import timedelta from pathlib import Path from time import perf_counter +import jwt import ujson as json import hupper import minicli @@ -9,7 +11,7 @@ from jinja2 import Environment, PackageLoader, select_autoescape from roll import Roll, Response from roll.extensions import cors, options, traceback, simple_server, static -from . import config, reports +from . import config, reports, session, utils, emails from .models import Delivery, Order, Person, Product, ProductOrder @@ -59,6 +61,42 @@ cors(app, methods="*", headers="*") options(app) +def auth_required(view): + async def redirect(request, response, *a, **k): + # FIXME do not return a view when Roll allows it. + response.redirect = f"/sésame?next={request.path}" + + def wrapper(request, response, *args, **kwargs): + token = request.cookies.get("token") + email = None + if token: + decoded = read_token(token) + email = decoded.get("sub") + if not email: + return redirect(request, response, *args, **kwargs) + user = Person(email=email) + request["user"] = user + session.user.set(user) + return view(request, response, *args, **kwargs) + + return wrapper + + +def create_token(email): + return jwt.encode( + {"sub": str(email), "exp": utils.utcnow() + timedelta(days=7)}, + config.SECRET, + config.JWT_ALGORITHM, + ) + + +def read_token(token): + try: + return jwt.decode(token, config.SECRET, algorithms=[config.JWT_ALGORITHM]) + except (jwt.DecodeError, jwt.ExpiredSignatureError): + return {} + + @app.listen("request") async def attach_request(request, response): response.request = request @@ -69,17 +107,49 @@ async def on_startup(): configure() +@app.route("/sésame", methods=["GET"]) +async def sesame(request, response): + response.html("sesame.html") + + +@app.route("/sésame", methods=["POST"]) +async def send_sesame(request, response): + email = request.form.get("email") + token = create_token(email) + emails.send( + email, + "Sésame Panio", + emails.ACCESS_GRANTED.format(hostname=request.host, token=token.decode()), + ) + response.message(f"Un sésame vous a été envoyé à l'adresse '{email}'") + response.redirect = "/" + + +@app.route("/sésame/{token}", methods=["GET"]) +async def set_sesame(request, response, token): + decoded = read_token(token) + if not decoded: + response.message("Sésame invalide :(", status="error") + else: + response.message("Yay! Le sésame a fonctionné. Bienvenue à bord! :)") + response.cookies.set(name="token", value=token) + response.redirect = "/" + + @app.route("/", methods=["GET"]) +@auth_required async def home(request, response): response.html("home.html", deliveries=Delivery.all()) @app.route("/livraison/new", methods=["GET"]) +@auth_required async def new_delivery(request, response): response.html("edit_delivery.html", delivery={}) @app.route("/livraison/new", methods=["POST"]) +@auth_required async def create_delivery(request, response): form = request.form data = {} @@ -93,6 +163,7 @@ async def create_delivery(request, response): @app.route("/livraison/{id}/importer/produits", methods=["POST"]) +@auth_required async def import_products(request, response, id): delivery = Delivery.load(id) delivery.products = [] @@ -107,12 +178,14 @@ async def import_products(request, response, id): @app.route("/livraison/{id}/edit", methods=["GET"]) +@auth_required async def edit_delivery(request, response, id): delivery = Delivery.load(id) response.html("edit_delivery.html", {"delivery": delivery}) @app.route("/livraison/{id}/edit", methods=["POST"]) +@auth_required async def post_delivery(request, response, id): delivery = Delivery.load(id) form = request.form @@ -125,30 +198,40 @@ async def post_delivery(request, response, id): @app.route("/livraison/{id}", methods=["GET"]) +@auth_required async def view_delivery(request, response, id): delivery = Delivery.load(id) response.html("delivery.html", {"delivery": delivery}) @app.route("/livraison/{id}/commander", methods=["GET"]) +@auth_required async def order_form(request, response, id): delivery = Delivery.load(id) - email = request.query.get("email") - order = delivery.orders.get(email) or Order() - response.html( - "place_order.html", {"delivery": delivery, "person": email, "order": order} - ) + email = request.query.get("email", None) + if not email: + user = session.user.get(None) + if user: + email = user.email + if email: + order = delivery.orders.get(email) or Order() + response.html( + "place_order.html", {"delivery": delivery, "person": email, "order": order} + ) + else: + response.message("Impossible de comprendre pour qui passer commande…", "error") + response.redirect = request.path @app.route("/livraison/{id}/émargement", methods=["GET"]) -async def signing_list(request, response, id): +@auth_required +async def signing_sheet(request, response, id): delivery = Delivery.load(id) - response.html( - "signing_list.html", {"delivery": delivery} - ) + response.html("signing_sheet.html", {"delivery": delivery}) @app.route("/livraison/{id}/commander", methods=["POST"]) +@auth_required async def place_order(request, response, id): delivery = Delivery.load(id) email = request.query.get("email") @@ -167,6 +250,7 @@ async def place_order(request, response, id): @app.route("/livraison/{id}/importer/commande", methods=["POST"]) +@auth_required async def import_commande(request, response, id): email = request.form.get("email") order = Order() @@ -185,20 +269,22 @@ async def import_commande(request, response, id): @app.route("/livraison/{id}/rapport.xlsx", methods=["GET"]) +@auth_required async def xls_report(request, response, id): delivery = Delivery.load(id) response.body = reports.summary(delivery) mimetype = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" - response.headers["Content-Disposition"] = f'attachment; filename="export.xlsx"' + response.headers["Content-Disposition"] = f'attachment; filename="epinamap.xlsx"' response.headers["Content-Type"] = f"{mimetype}; charset=utf-8" @app.route("/livraison/{id}/rapport-complet.xlsx", methods=["GET"]) +@auth_required async def xls_full_report(request, response, id): delivery = Delivery.load(id) response.body = reports.full(delivery) mimetype = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" - response.headers["Content-Disposition"] = f'attachment; filename="export.xlsx"' + response.headers["Content-Disposition"] = f'attachment; filename="epinamap.xlsx"' response.headers["Content-Type"] = f"{mimetype}; charset=utf-8" diff --git a/kaba/config.py b/kaba/config.py index e6336f2..beca4ba 100644 --- a/kaba/config.py +++ b/kaba/config.py @@ -5,10 +5,11 @@ getenv = os.environ.get DATA_ROOT = Path(__file__).parent.parent / "db" SECRET = "sikretfordevonly" -# JWT_ALGORITHM = "HS256" +JWT_ALGORITHM = "HS256" SEND_EMAILS = False -SMTP_HOST = "smtp.gmail.com" +SMTP_HOST = "mail.gandi.net" SMTP_PASSWORD = "" +SMTP_LOGIN = "" FROM_EMAIL = "contact@epinamap.org" diff --git a/kaba/emails.py b/kaba/emails.py index 95eb4e0..a69c03d 100644 --- a/kaba/emails.py +++ b/kaba/emails.py @@ -10,7 +10,7 @@ Voici le sésame: https://{hostname}/sésame/{token} -Les gentils gens d'Épinamap +Les gentils copains d'Épinamap """ @@ -24,7 +24,7 @@ def send(to, subject, body): return print("Sending email", str(msg)) try: server = smtplib.SMTP_SSL(config.SMTP_HOST) - server.login(config.FROM_EMAIL, config.SMTP_PASSWORD) + server.login(config.SMTP_LOGIN, config.SMTP_PASSWORD) server.send_message(msg) except smtplib.SMTPException: raise RuntimeError diff --git a/kaba/models.py b/kaba/models.py index 06ad69f..4dc5ee6 100644 --- a/kaba/models.py +++ b/kaba/models.py @@ -8,7 +8,7 @@ from typing import List, Dict import yaml -from . import config, utils +from . import config class DoesNotExist(ValueError): diff --git a/kaba/reports.py b/kaba/reports.py index fa37fba..aaea0ca 100644 --- a/kaba/reports.py +++ b/kaba/reports.py @@ -5,7 +5,7 @@ from openpyxl.writer.excel import save_virtual_workbook def summary(delivery): wb = Workbook() ws = wb.active - ws.title = f"Commande Epinamap - {delivery.producer} - {delivery.when.date()}" + ws.title = f"{delivery.producer} {delivery.when.date()}" ws.append(["ref", "produit", "prix", "unités", "total"]) for product in delivery.products: wanted = delivery.product_wanted(product) @@ -25,7 +25,7 @@ def summary(delivery): def full(delivery): wb = Workbook() ws = wb.active - ws.title = f"Epinamap - {delivery.producer} - {delivery.when.date()}" + ws.title = f"{delivery.producer} {delivery.when.date()}" headers = ["ref", "produit", "prix"] + [e for e in delivery.orders] + ["total"] ws.append(headers) for product in delivery.products: diff --git a/kaba/session.py b/kaba/session.py new file mode 100644 index 0000000..2941cf2 --- /dev/null +++ b/kaba/session.py @@ -0,0 +1,3 @@ +import contextvars + +user = contextvars.ContextVar("user") diff --git a/kaba/static/app.css b/kaba/static/app.css index 9042c70..5a8a052 100644 --- a/kaba/static/app.css +++ b/kaba/static/app.css @@ -157,6 +157,10 @@ nav { display: flex; align-items: center; } +.logged-in { + text-decoration: underline; + font-variant: small-caps; +} main { padding: 1rem; } @@ -164,7 +168,7 @@ main { button, a.button, input[type=submit] { - display: flex; + display: inline-flex; align-items: center; justify-content: center; width: auto; @@ -377,6 +381,9 @@ hr { .notification.success { background-color: #0f8796; } +.notification.error { + background-color: #e10055; +} .notification i { font-size: 2rem; } diff --git a/kaba/templates/base.html b/kaba/templates/base.html index 50ef126..146f65a 100644 --- a/kaba/templates/base.html +++ b/kaba/templates/base.html @@ -20,6 +20,9 @@

Panio Les paniers piano d'Épinamap

diff --git a/kaba/templates/delivery.html b/kaba/templates/delivery.html index 9874c33..94b7cd9 100644 --- a/kaba/templates/delivery.html +++ b/kaba/templates/delivery.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% block body %} -

{{ delivery.producer }}

+

{{ delivery.producer }} Ma commande

{% include "includes/delivery_head.html" %} @@ -52,17 +52,14 @@ Liste d'émargement
  • - - -
    - -

    Colonnes: ref*, wanted*

    -
    - - - - -
    + {% with unique_id="import-command" %} + {% include "includes/modal_import_command.html" %} + {% endwith %} +
  • +
  • + {% with unique_id="add-command" %} + {% include "includes/modal_add_command.html" %} + {% endwith %}
  • {% endblock body %} diff --git a/kaba/templates/edit_delivery.html b/kaba/templates/edit_delivery.html index 6647deb..38a1766 100644 --- a/kaba/templates/edit_delivery.html +++ b/kaba/templates/edit_delivery.html @@ -6,24 +6,24 @@
    diff --git a/kaba/templates/home.html b/kaba/templates/home.html index 5093fb7..ddaa2a8 100644 --- a/kaba/templates/home.html +++ b/kaba/templates/home.html @@ -6,11 +6,6 @@
  • {{ delivery.producer }}

    {% include "includes/delivery_head.html" %} -

  • {% endfor %} diff --git a/kaba/templates/includes/delivery_head.html b/kaba/templates/includes/delivery_head.html index 535b8ae..d64369c 100644 --- a/kaba/templates/includes/delivery_head.html +++ b/kaba/templates/includes/delivery_head.html @@ -1,5 +1,5 @@
    diff --git a/kaba/templates/sesame.html b/kaba/templates/sesame.html new file mode 100644 index 0000000..6bdd177 --- /dev/null +++ b/kaba/templates/sesame.html @@ -0,0 +1,7 @@ +{% extends "base.html" %} +{% block body %} + + + + +{% endblock body %} diff --git a/kaba/templates/signing_list.html b/kaba/templates/signing_sheet.html similarity index 100% rename from kaba/templates/signing_list.html rename to kaba/templates/signing_sheet.html diff --git a/tests/conftest.py b/tests/conftest.py index 4880efb..9a6b1e3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,9 +3,11 @@ from datetime import datetime, timedelta import pytest from roll.extensions import traceback +from roll.testing import Client as BaseClient from kaba import app as kaba_app from kaba import config as kconfig +from kaba import create_token from kaba.models import Delivery, Person @@ -20,6 +22,39 @@ def pytest_runtest_setup(item): path.unlink() +class Client(BaseClient): + headers = {} + + async def request( + self, path, method="GET", body=b"", headers=None, content_type=None + ): + # TODO move this to Roll upstream? + headers = headers or {} + for key, value in self.headers.items(): + headers.setdefault(key, value) + return await super().request(path, method, body, headers, content_type) + + def login(self, email="foo@bar.org"): + token = create_token(email) + self.headers["Cookie"] = f"token={token.decode()}" + + def logout(self): + try: + del self.headers["Cookie"] + except KeyError: + pass + + +@pytest.fixture +def client(app, event_loop): + app.loop = event_loop + app.loop.run_until_complete(app.startup()) + client = Client(app) + client.login() + yield client + app.loop.run_until_complete(app.shutdown()) + + @pytest.fixture def app(): # Requested by Roll testing utilities. traceback(kaba_app) diff --git a/tests/test_views.py b/tests/test_views.py index d610528..34cfe46 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -1,6 +1,5 @@ import pytest - pytestmark = pytest.mark.asyncio @@ -14,3 +13,10 @@ async def test_home_should_list_active_delivery(client, delivery): resp = await client.get('/') assert resp.status == 200 assert delivery.producer in resp.body.decode() + + +async def test_home_should_redirect_to_login_if_not_logged(client): + client.logout() + resp = await client.get('/') + assert resp.status == 302 + assert resp.headers["Location"] == "/sésame?next=/"
    {{ product.name }} {% if product.description or product.img %} - - - -
    -

    {{ product.description }}

    -

    {% if product.img %} - - {% endif %}

    - -
    + {% with unique_id=loop.index %} + {% include "includes/modal_product.html" %} + {% endwith %} {% endif %}

    {{ product.price }} €