diff --git a/TODO b/TODO index b9f2f99..642de39 100644 --- a/TODO +++ b/TODO @@ -1,21 +1,25 @@ -Repenser l'interface pour la rendre plus simple -Refaire une interface plus simple (moins de boutons partout, qu'on s'y retrouve plus) -Uniformiser le nom des choses. Livraison → Distribution +Éditer directement depuis la vue « distribution » +x Ajouter un menu pour revenir à l'élément parent facilement +x Repenser l'interface pour la rendre plus simple +x Refaire une interface plus simple (moins de boutons partout, qu'on s'y retrouve plus) +x Uniformiser le nom des choses. Livraison → Distribution +x Ajouter un numéro de téléphone de la personne référente de la commande +x Il semble que les prix trop précis ne sont pas acceptés +x Utiliser une version lowercase des emails +x Permettre l'ajout de produits +x Ajouter un moyen d'ajouter un⋅e producteurice +x Ajouter un moyen de changer le nom de la personne référente pour un producteur / productrice +x Faciliter la duplication de distribution +x Si un produit est en rupture de stock, alors il n'est pas compté dans les totaux +x Permettre la supression des produits + +Permettre la suppression de producteurs -Ajouter un numéro de téléphone de la personne référente de la commande -Permettre la supression des produits Gérer les frais de livraison -Bug par rapport aux ruptures + Rendre plus visible l'action de modifier une commande - Ajouter la trame (agenda d’une distribution) dans la boite à outil du coordinateur -Explorer la possibilité de faire des ajustements automatiques -Il semble que les prix trop précis ne sont pas acceptés -utiliser une version lowercase des emails -Permettre l'ajout de produits -Ajouter un moyen d'ajouter un⋅e producteurice -Ajouter un moyen de changer le nom de la personne référente pour un producteur / productrice +Explorer la possibilité de faire des ajustements automatiques Ajouter une info « prix mis à jour » pour les référent⋅e⋅s -Faciliter la duplication de distribution Ajouter une note explicative pour la répartition des chèques \ No newline at end of file diff --git a/copanier/__init__.py b/copanier/__init__.py index 5c1f95d..5baa65c 100644 --- a/copanier/__init__.py +++ b/copanier/__init__.py @@ -1,828 +1,9 @@ -import csv -from collections import defaultdict from pathlib import Path -from functools import partial - -import ujson as json import minicli -from jinja2 import Environment, PackageLoader, select_autoescape -from roll import Roll as BaseRoll, Response as RollResponse, HttpError -from roll.extensions import traceback, simple_server, static -from slugify import slugify +from roll.extensions import simple_server, static -from debts.solver import order_balance, check_balance, reduce_balance -from weasyprint import HTML - -from . import config, reports, session, utils, emails, loggers, imports -from .models import Delivery, Order, Person, Product, ProductOrder, Groups, Group - - -class Response(RollResponse): - def render_template(self, template_name, *args, **kwargs): - context = app.context() - context.update(kwargs) - context["request"] = self.request - context["config"] = config - context["request"] = self.request - if self.request.cookies.get("message"): - context["message"] = json.loads(self.request.cookies["message"]) - self.cookies.set("message", "") - return env.get_template(template_name).render(*args, **context) - - def html(self, template_name, *args, **kwargs): - self.headers["Content-Type"] = "text/html; charset=utf-8" - self.body = self.render_template(template_name, *args, **kwargs) - - def render_pdf(self, template_name, *args, **kwargs): - html = self.render_template(template_name, *args, **kwargs) - - static_folder = Path(__file__).parent / "static" - stylesheets = [ - static_folder / "app.css", - static_folder / "icomoon.css", - static_folder / "page.css", - ] - if "css" in kwargs: - stylesheets.append(static_folder / kwargs["css"]) - - return HTML(string=html).write_pdf(stylesheets=stylesheets) - - def pdf(self, template_name, *args, **kwargs): - self.body = self.render_pdf(template_name, *args, **kwargs) - mimetype = "application/pdf" - filename = kwargs.get("filename", "file.pdf") - self.headers["Content-Disposition"] = f'attachment; filename="{filename}"' - self.headers["Content-Type"] = f"{mimetype}; charset=utf-8" - - def xlsx(self, body, filename=f"{config.SITE_NAME}.xlsx"): - self.body = body - mimetype = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" - self.headers["Content-Disposition"] = f'attachment; filename="{filename}"' - self.headers["Content-Type"] = f"{mimetype}; charset=utf-8" - - def redirect(self, location): - self.status = 302 - self.headers["Location"] = location - - redirect = property(None, redirect) - - def message(self, text, status="success"): - self.cookies.set("message", json.dumps((text, status))) - - -class Roll(BaseRoll): - Response = Response - - _context_func = [] - - def context(self): - context = {} - for func in self._context_func: - context.update(func()) - return context - - def register_context(self, func): - self._context_func.append(func) - - -env = Environment( - loader=PackageLoader("copanier", "templates"), - autoescape=select_autoescape(["copanier"]), -) - - -def date_filter(value): - return value.strftime(r"%A %d %B") - - -def time_filter(value): - return value.strftime(r"%H:%M") - - -env.filters["date"] = date_filter -env.filters["time"] = time_filter - -app = Roll() -traceback(app) - - -def staff_only(view): - async def decorator(request, response, *args, **kwargs): - user = session.user.get(None) - if not user or not user.is_staff: - response.message("Désolé, c'est réservé au staff par ici", "warning") - response.redirect = request.headers.get("REFERRER", "/") - return - return await view(request, response, *args, **kwargs) - - return decorator - - -@app.listen("request") -async def auth_required(request, response): - # Should be handler Roll side? - # In dev mode, we serve the static, but we don't have yet a way to mark static - # route as unprotected. - if request.path.startswith("/static/"): - return - if request.route.payload and not request.route.payload.get("unprotected"): - token = request.cookies.get("token") - email = None - if token: - decoded = utils.read_token(token) - email = decoded.get("sub") - if not email: - response.redirect = f"/sésame?next={request.path}" - return response - - groups = Groups.load() - request["groups"] = groups - - group = groups.get_user_group(email) - user_info = {"email": email} - if group: - user_info.update(dict(group_id=group.id, group_name=group.name)) - user = Person(**user_info) - request["user"] = user - session.user.set(user) - - -@app.listen("request") -async def attach_request(request, response): - response.request = request - - -@app.listen("request") -async def log_request(request, response): - if request.method == "POST": - message = { - "date": utils.utcnow().isoformat(), - "data": request.form, - "user": request.get("user"), - } - loggers.request_logger.info( - json.dumps(message, sort_keys=True, ensure_ascii=False) - ) - - -@app.listen("startup") -async def on_startup(): - configure() - Delivery.init_fs() - Groups.init_fs() - - -@app.route("/sésame", methods=["GET"], unprotected=True) -async def sesame(request, response): - response.html("sesame.html") - - -@app.route("/sésame", methods=["POST"], unprotected=True) -async def send_sesame(request, response): - email = request.form.get("email") - token = utils.create_token(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 = "/" - - -@app.route("/sésame/{token}", methods=["GET"], unprotected=True) -async def set_sesame(request, response, token): - decoded = utils.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, httponly=True, max_age=60 * 60 * 24 * 7 - ) - response.redirect = "/" - - -@app.route("/déconnexion", methods=["GET"]) -async def logout(request, response): - response.cookies.set(name="token", value="", httponly=True) - response.redirect = "/" - - -@app.route("/", methods=["GET"]) -async def home(request, response): - if not request["user"].group_id: - response.redirect = "/groupes" - return - response.html( - "home.html", - incoming=Delivery.incoming(), - former=Delivery.former(), - archives=list(Delivery.all(is_archived=True)), - ) - - -@app.route("/groupes", methods=["GET"]) -async def handle_groups(request, response): - response.html("groups.html", {"groups": request["groups"]}) - - -@app.route("/groupes/{id}/rejoindre", method=["GET"]) -async def join_group(request, response, id): - user = session.user.get(None) - group = request["groups"].add_user(user.email, id) - request["groups"].persist() - redirect = "/" if not request["user"].group_id else "/groupes" - - response.message(f"Vous avez bien rejoint le groupe '{group.name}'") - response.redirect = redirect - - -@app.route("/groupes/créer", methods=["GET", "POST"]) -async def create_group(request, response): - group = None - if request.method == "POST": - form = request.form - members = [] - if form.get("members"): - members = [m.strip() for m in form.get("members").split(",")] - - if not request["user"].group_id and request["user"].email not in members: - members.append(request["user"].email) - - group = Group.create( - id=slugify(form.get("name")), name=form.get("name"), members=members - ) - request["groups"].add_group(group) - request["groups"].persist() - response.message(f"Le groupe {group.name} à bien été créé") - response.redirect = "/" - response.html("edit_group.html", group=group) - - -@app.route("/groupes/{id}/éditer", methods=["GET", "POST"]) -async def edit_group(request, response, id): - assert id in request["groups"].groups, "Impossible de trouver le groupe" - group = request["groups"].groups[id] - if request.method == "POST": - form = request.form - members = [] - if form.get("members"): - members = [m.strip() for m in form.get("members").split(",")] - group.members = members - group.name = form.get("name") - request["groups"].groups[id] = group - request["groups"].persist() - response.redirect = "/groupes" - response.html("edit_group.html", group=group) - - -@app.route("/groupes/{id}/supprimer", methods=["GET"]) -async def delete_group(request, response, id): - assert id in request["groups"].groups, "Impossible de trouver le groupe" - deleted = request["groups"].groups.pop(id) - request["groups"].persist() - response.message(f"Le groupe {deleted.name} à bien été supprimé") - response.redirect = "/groupes" - - -@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"]) -async def new_delivery(request, response): - response.html("edit_delivery.html", delivery={}) - - -@app.route("/livraison", methods=["POST"]) -@staff_only -async def create_delivery(request, response): - form = request.form - data = {} - data["from_date"] = f"{form.get('date')} {form.get('from_time')}" - data["to_date"] = f"{form.get('date')} {form.get('to_time')}" - for name in Delivery.__dataclass_fields__.keys(): - if name in form: - data[name] = form.get(name) - delivery = Delivery(**data) - delivery.persist() - response.message("La livraison a bien été créée!") - response.redirect = f"/livraison/{delivery.id}" - - -@app.route("/livraison/{id}/importer/produits", methods=["POST"]) -@staff_only -async def import_products(request, response, id): - delivery = Delivery.load(id) - delivery.products = [] - data = request.files.get("data") - error_path = f"/livraison/{delivery.id}/edit" - - if data.filename.endswith(".xlsx"): - try: - imports.products_and_producers_from_xlsx(delivery, data) - except ValueError as err: - message = f"Impossible d'importer le fichier. {err.args[0]}" - response.message(message, status="error") - response.redirect = error_path - return - else: - response.message("Format de fichier inconnu", status="error") - response.redirect = error_path - return - response.message("Les produits et producteur⋅ice⋅s ont bien été mis à jour!") - response.redirect = f"/livraison/{delivery.id}" - - -@app.route("/livraison/{id}/producteurices") -@app.route("/livraison/{id}/producteurices.pdf") -async def list_producers(request, response, id): - delivery = Delivery.load(id) - template_name = "list_products.html" - template_params = { - "edit_mode": True, - "list_only": True, - "delivery": delivery, - "referent": request.query.get("referent", None), - } - - if request.url.endswith(b".pdf"): - template_params["edit_mode"] = False - response.pdf( - template_name, - template_params, - filename=utils.prefix("producteurices.pdf", delivery), - ) - else: - response.html(template_name, template_params) - - -@app.route("/livraison/{id}/{producer}/bon-de-commande.pdf", methods=["GET"]) -async def pdf_for_producer(request, response, id, producer): - delivery = Delivery.load(id) - date = delivery.to_date.strftime("%Y-%m-%d") - response.pdf( - "list_products.html", - {"list_only": True, "delivery": delivery, "producers": [producer]}, - filename=utils.prefix(f"bon-de-commande-{producer}.pdf", delivery), - ) - - -@app.route("/livraison/{delivery_id}/{producer_id}/éditer", methods=["GET", "POST"]) -async def edit_producer(request, response, delivery_id, producer_id): - delivery = Delivery.load(delivery_id) - producer = delivery.producers.get(producer_id) - if request.method == "POST": - form = request.form - producer.referent = form.get("referent") - producer.referent_tel = form.get("referent_tel") - producer.referent_name = form.get("referent_name") - producer.description = form.get("description") - producer.contact = form.get("contact") - delivery.producers[producer_id] = producer - delivery.persist() - - response.html( - "edit_producer.html", - { - "delivery": delivery, - "producer": producer, - "products": delivery.get_products_by(producer.id), - }, - ) - - -@app.route( - "/livraison/{delivery_id}/{producer_id}/{product_ref}/éditer", - methods=["GET", "POST"], -) -async def edit_product(request, response, delivery_id, producer_id, product_ref): - delivery = Delivery.load(delivery_id) - product = delivery.get_product(product_ref) - - if request.method == "POST": - form = request.form - product.name = form.get("name") - product.price = form.float("price") - product.unit = form.get("unit") - product.description = form.get("description") - product.url = form.get("url") - if form.get("packing"): - product.packing = form.int("packing") - else: - product.packing = None - if "rupture" in form: - product.rupture = form.get("rupture") - else: - product.rupture = None - delivery.persist() - response.message("Le produit à bien été modifié") - response.redirect = f"/livraison/{delivery_id}/{producer_id}/éditer" - - response.html("edit_product.html", {"delivery": delivery, "product": product}) - - -@app.route( - "/livraison/{delivery_id}/{producer_id}/ajouter-produit", methods=["GET", "POST"] -) -async def create_product(request, response, delivery_id, producer_id): - delivery = Delivery.load(delivery_id) - product = Product(name="", ref="", price=0) - - if request.method == "POST": - product.producer = producer_id - form = request.form - product.update_from_form(form) - product.ref = slugify(f"{producer_id}-{product.name}") - - delivery.products.append(product) - delivery.persist() - response.message("Le produit à bien été créé") - response.redirect = ( - f"/livraison/{delivery_id}/producteurice/{producer_id}/éditer" - ) - - response.html( - "edit_product.html", - {"delivery": delivery, "producer_id": producer_id, "product": product}, - ) - - -@app.route("/livraison/{id}/gérer", methods=["GET"]) -async def delivery_toolbox(request, response, id): - delivery = Delivery.load(id) - response.html( - "delivery_toolbox.html", - { - "delivery": delivery, - "referents": [p.referent for p in delivery.producers.values()], - }, - ) - - -@app.route("/livraison/{id}/envoi-email-referentes", methods=["GET", "POST"]) -async def send_referent_emails(request, response, id): - delivery = Delivery.load(id) - if request.method == "POST": - email_body = request.form.get("email_body") - email_subject = request.form.get("email_subject") - sent_mails = 0 - for referent in delivery.get_referents(): - producers = delivery.get_producers_for_referent(referent) - attachments = [] - for producer in producers: - if delivery.producers[producer].has_active_products(delivery): - pdf_file = response.render_pdf( - "list_products.html", - { - "list_only": True, - "delivery": delivery, - "producers": [producer], - }, - ) - - attachments.append( - ( - utils.prefix(f"{producer}.pdf", delivery), - pdf_file, - "application/pdf", - ) - ) - - if attachments: - sent_mails = sent_mails + 1 - emails.send( - referent, - email_subject, - email_body, - copy=delivery.contact, - attachments=attachments, - ) - response.message(f"Un mail à été envoyé aux {sent_mails} référent⋅e⋅s") - response.redirect = f"/livraison/{id}/gérer" - - response.html("prepare_referent_email.html", {"delivery": delivery}) - - -@app.route("/livraison/{id}/exporter", methods=["GET"]) -async def export_products(request, response, id): - delivery = Delivery.load(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): - delivery = Delivery.load(id) - response.html("edit_delivery.html", {"delivery": delivery}) - - -@app.route("/livraison/{id}/edit", methods=["POST"]) -@staff_only -async def post_delivery(request, response, id): - delivery = Delivery.load(id) - form = request.form - delivery.from_date = f"{form.get('date')} {form.get('from_time')}" - delivery.to_date = f"{form.get('date')} {form.get('to_time')}" - for name in Delivery.__dataclass_fields__.keys(): - if name in form: - setattr(delivery, name, form.get(name)) - delivery.persist() - response.message("La livraison a bien été mise à jour!") - response.redirect = f"/livraison/{delivery.id}" - - -@app.route("/livraison/{id}", methods=["GET"]) -async def view_delivery(request, response, id): - delivery = Delivery.load(id) - response.html("delivery.html", {"delivery": delivery}) - - -@app.route("/livraison/{id}/commander", methods=["POST", "GET"]) -async def place_order(request, response, id): - delivery = Delivery.load(id) - # email = request.query.get("email", None) - user = session.user.get(None) - orderer = request.query.get("orderer", None) - if orderer: - orderer = Person(email=orderer, group_id=orderer) - - delivery_url = f"/livraison/{delivery.id}" - if not orderer and user: - orderer = user - - if not orderer: - response.message("Impossible de comprendre pour qui passer commande…", "error") - response.redirect = delivery_url - return - - if request.method == "POST": - # When the delivery is closed, only staff can access. - if delivery.status == delivery.CLOSED and not (user and user.is_staff): - response.message("La livraison est fermée", "error") - response.redirect = delivery_url - return - - form = request.form - order = Order(phone_number=form.get('phone_number', '')) - for product in delivery.products: - try: - wanted = form.int(f"wanted:{product.ref}", 0) - except HttpError: - continue - try: - 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: - delivery.orders = {} - - if not order.products: - if orderer.id in delivery.orders: - del delivery.orders[orderer.id] - delivery.persist() - response.message("La commande est vide.", status="warning") - response.redirect = delivery_url - return - delivery.orders[orderer.id] = order - delivery.persist() - - if user and orderer.id == user.id: - # Send the emails to everyone in the group. - groups = request["groups"].groups - if orderer.group_id in groups.keys(): - for email in groups[orderer.group_id].members: - emails.send_order( - request, - env, - person=Person(email=email), - delivery=delivery, - order=order, - ) - else: - emails.send_order( - request, - env, - person=Person(email=orderer.email), - delivery=delivery, - order=order, - ) - response.message( - f"La commande pour « {orderer.name} » a bien été prise en compte!" - ) - 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, - force_adjustment=force_adjustment, - ) - - -@app.route("/livraison/{id}/émargement", methods=["GET"]) -async def signing_sheet(request, response, id): - delivery = Delivery.load(id) - response.pdf( - "signing_sheet.html", - {"delivery": delivery}, - css="signing-sheet.css", - filename=utils.prefix("commandes-par-groupe.pdf", delivery), - ) - - -@app.route("/livraison/{id}/importer/commande", methods=["POST"]) -@staff_only -async def import_commande(request, response, id): - email = request.form.get("email") - order = Order() - reader = csv.DictReader( - request.files.get("data").read().decode().splitlines(), delimiter=";" - ) - for row in reader: - wanted = int(row["wanted"] or 0) - if wanted: - order.products[row["ref"]] = ProductOrder(wanted=wanted) - delivery = Delivery.load(id) - delivery.orders[email] = order - delivery.persist() - response.message(f"Yallah! La commande de {email} a bien été importée !") - response.redirect = f"/livraison/{delivery.id}" - - -@app.route("/livraison/{id}/importer/commandes", methods=["POST"]) -@staff_only -async def import_multiple_commands(request, response, id): - reader = csv.DictReader( - request.files.get("data").read().decode().splitlines(), delimiter=";" - ) - orders = defaultdict(Order) - - current_ref = None - for row in reader: - for label, value in row.items(): - if label == "ref": - current_ref = value - else: - wanted = int(value or 0) - if wanted: - orders[label].products[current_ref] = ProductOrder(wanted=wanted) - delivery = Delivery.load(id) - for email, order in orders.items(): - delivery.orders[email] = order - delivery.persist() - response.message(f"Yes ! Les commandes ont bien été importées !") - response.redirect = f"/livraison/{delivery.id}" - - -@app.route("/livraison/{id}/rapport-complet.xlsx", methods=["GET"]) -async def xls_full_report(request, response, id): - delivery = Delivery.load(id) - date = delivery.to_date.strftime("%Y-%m-%d") - response.xlsx( - reports.full(delivery), - filename=f"{config.SITE_NAME}-{date}-rapport-complet.xlsx", - ) - - -@app.route("/livraison/{id}/ajuster/{ref}", methods=["GET", "POST"]) -@staff_only -async def adjust_product(request, response, id, ref): - delivery = Delivery.load(id) - delivery_url = f"/livraison/{delivery.id}" - product = None - 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}) - - -@app.route("/livraison/{id}/solde", methods=["GET"]) -@app.route("/livraison/{id}/solde.pdf", methods=["GET"]) -@staff_only -async def delivery_balance(request, response, id): - delivery = Delivery.load(id) - groups = request["groups"] - - balance = [] - for group_id, order in delivery.orders.items(): - balance.append((group_id, order.total(delivery.products) * -1)) - - producer_groups = {} - - for producer in delivery.producers.values(): - group = groups.get_user_group(producer.referent) - # When a group contains multiple producer contacts, - # the first one is elected to receive the money, - # and all the other ones are separated in the table. - group_id = None - if hasattr(group, "id"): - if ( - group.id not in producer_groups - or producer_groups[group.id] == producer.referent_name - ): - producer_groups[group.id] = producer.referent_name - group_id = group.id - if not group_id: - group_id = producer.referent_name - - amount = delivery.total_for_producer(producer.id) - if amount: - balance.append((group_id, amount)) - - debiters, crediters = order_balance(balance) - check_balance(debiters, crediters) - results = reduce_balance(debiters[:], crediters[:]) - - results_dict = defaultdict(partial(defaultdict, float)) - - for debiter, amount, crediter in results: - results_dict[debiter][crediter] = amount - - template_name = "delivery_balance.html" - template_args = { - "delivery": delivery, - "debiters": debiters, - "crediters": crediters, - "results": results_dict, - "debiters_groups": groups.groups, - "crediters_groups": producer_groups, - } - - if request.url.endswith(b".pdf"): - date = delivery.to_date.strftime("%Y-%m-%d") - response.pdf( - template_name, - template_args, - filename=utils.prefix("répartition-des-chèques.pdf", delivery), - ) - else: - response.html(template_name, template_args) - - -def configure(): - config.init() +from .models import Product, Person, Order, Delivery +from .views.core import app @minicli.cli() diff --git a/copanier/imports.py b/copanier/imports.py index 89094ab..aa139d8 100644 --- a/copanier/imports.py +++ b/copanier/imports.py @@ -1,6 +1,3 @@ -import csv -import functools - from zipfile import BadZipFile from openpyxl import load_workbook, Workbook @@ -30,7 +27,7 @@ def items_from_xlsx(data, items, model_class, required_fields, append_method): raw = {k: v for k, v in dict(zip(headers, row)).items() if v} try: append_method(items, model_class(**raw)) - except TypeError as e: + except TypeError: raise ValueError(f"Erreur durant l'importation de {raw['ref']}") return items diff --git a/copanier/models.py b/copanier/models.py index a8f44fb..a86f2e2 100644 --- a/copanier/models.py +++ b/copanier/models.py @@ -163,6 +163,7 @@ class Groups(PersistedBase): @dataclass class Producer(Base): id: str + name: str referent: str = "" referent_tel: str = "" referent_name: str = "" @@ -182,8 +183,6 @@ class Product(Base): last_update: datetime_field = datetime.now() unit: str = "" description: str = "" - url: str = "" - img: str = "" packing: int = None producer: str = "" rupture: str = None @@ -199,7 +198,6 @@ class Product(Base): self.price = form.float("price") self.unit = form.get("unit") self.description = form.get("description") - self.url = form.get("url") self.last_update = datetime.now() if form.get("packing"): self.packing = form.int("packing") @@ -241,13 +239,12 @@ class Order(Base): def total(self, products): def _get_price(ref): product = products.get(ref) - return product.price if product else 0 + return product.price if product and not product.rupture else 0 products = {p.ref: p for p in products} 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): @@ -270,13 +267,11 @@ class Delivery(PersistedBase): to_date: datetime_field order_before: datetime_field contact: str - description: str = "" instructions: str = "" where: str = "Marché de la Briche" products: List[Product] = field(default_factory=list) producers: Dict[str, Producer] = field(default_factory=dict) orders: Dict[str, Order] = field(default_factory=dict) - infos_url: str = "" def __post_init__(self): self.id = None # Not a field because we don't want to persist it. @@ -379,7 +374,7 @@ class Delivery(PersistedBase): def archive(self): if self.is_archived: - raise ValueError("La livraison est déjà archivée") + raise ValueError("La distribution est déjà archivée") current = self.path self.id = f"archive/{self.id}" current.rename(self.path) @@ -387,7 +382,7 @@ class Delivery(PersistedBase): def unarchive(self): if not self.is_archived: raise ValueError( - "Impossible de désarchiver une livraison qui n'est pas archivée" + "Impossible de désarchiver une distribution qui n'est pas archivée" ) current = self.path self.id = self.path.stem @@ -418,6 +413,12 @@ class Delivery(PersistedBase): if products: return products[0] + def delete_product(self, ref): + product = self.get_product(ref) + if product: + self.products.remove(product) + return product + def total_for_producer(self, producer, person=None): producer_products = [p for p in self.products if p.producer == producer] if person: diff --git a/copanier/static/app.css b/copanier/static/app.css index 9dcbd02..37e1937 100644 --- a/copanier/static/app.css +++ b/copanier/static/app.css @@ -670,4 +670,14 @@ ul.actions { ul.actions > li { display: inline; margin-left: 1em; +} + +#groups ul, #groups li{ + list-style-type: none; +} + +.toplink { + padding-left: 0.5em; + padding-top: 0.5em; + padding-bottom: -0.5em; } \ No newline at end of file diff --git a/copanier/static/flash.min.css b/copanier/static/flash.min.css new file mode 100644 index 0000000..f8fcdac --- /dev/null +++ b/copanier/static/flash.min.css @@ -0,0 +1 @@ +.flash-container{position:fixed;top:75px;right:15px;z-index:1000;max-width:25%}.flash-container .flash-message{position:relative;opacity:0;min-height:28px;-webkit-transform:translateX(-20px);-ms-transform:translateX(-20px);transform:translateX(-20px);-webkit-transition:all .5s;-o-transition:all .5s;transition:all .5s;background-color:#fff;color:#2c3433;-webkit-border-radius:0;border-radius:0;-webkit-border-top-right-radius:4px;border-top-right-radius:4px;-webkit-border-bottom-right-radius:4px;border-bottom-right-radius:4px;margin-bottom:10px;padding:5px 35px 5px 20px;-webkit-box-shadow:2px 2px 33px 8px rgba(0,0,0,.1);box-shadow:2px 2px 33px 8px rgba(0,0,0,.1);line-height:1.4;cursor:pointer}.flash-container .flash-message .flash-progress{position:absolute;right:0;top:auto;bottom:0;left:0;width:0;height:3px;opacity:1;background-color:rgba(0,0,0,.15);-webkit-transition:opacity .1s;-o-transition:opacity .1s;transition:opacity .1s}.flash-container .flash-message .flash-progress.is-hidden{opacity:0}.flash-container .flash-message .flash-progress.flash-progress-top{top:0;bottom:auto}.flash-container .flash-message:before{position:absolute;content:"";width:7px;height:100%;top:0;bottom:0;left:-7px;background-color:transparent;-webkit-border-top-left-radius:4px;border-top-left-radius:4px;-webkit-border-bottom-left-radius:4px;border-bottom-left-radius:4px}.flash-container .flash-message:after{position:absolute;content:"";font-family:fontAwesome;top:5px;right:8px;text-align:center;vertical-align:middle;color:#9e9e9e}.flash-container .flash-message.is-visible{opacity:1;-webkit-transform:translateX(0);-ms-transform:translateX(0);transform:translateX(0)}.flash-container .flash-message.flash-success .flash-progress{background-color:rgba(76,175,80,.15)}.flash-container .flash-message.flash-success:before{background-color:#4caf50}.flash-container .flash-message.flash-success:after{color:rgba(76,175,80,.5);content:"\F058"}.flash-container .flash-message.flash-warning .flash-progress{background-color:rgba(255,133,27,.15)}.flash-container .flash-message.flash-warning:before{background-color:#ff851b}.flash-container .flash-message.flash-warning:after{color:rgba(255,133,27,.5);content:"\F071"}.flash-container .flash-message.flash-danger .flash-progress,.flash-container .flash-message.flash-error .flash-progress{background-color:rgba(255,65,54,.15)}.flash-container .flash-message.flash-danger:before,.flash-container .flash-message.flash-error:before{background-color:#ff4136}.flash-container .flash-message.flash-danger:after,.flash-container .flash-message.flash-error:after{color:rgba(255,65,54,.5);content:"\F06A"}.flash-container .flash-message.flash-info .flash-progress{background-color:rgba(0,116,217,.15)}.flash-container .flash-message.flash-info:before{background-color:#0074d9}.flash-container .flash-message.flash-info:after{color:rgba(0,116,217,.5);content:"\F05A"}.flash-container .flash-message.flash-bug .flash-progress{background-color:rgba(138,43,226,.15)}.flash-container .flash-message.flash-bug:before{background-color:#8a2be2}.flash-container .flash-message.flash-bug:after{color:rgba(138,43,226,.5);content:"\F188"}.flash-container .flash-message.flash-disabled .flash-progress{background-color:hsla(0,0%,67%,.15)}.flash-container .flash-message.flash-disabled:before{background-color:#aaa}.flash-container .flash-message.flash-disabled:after{color:hsla(0,0%,67%,.5);content:"\F05E"}.flash-container .flash-message.flash-default{padding-right:20px}@media (max-width:1280px){.flash-container{max-width:33.334%}}@media (max-width:768px){.flash-container{max-width:50%}}@media (max-width:480px){.flash-container{right:10px;left:10px;max-width:100%}}.flash-container .flash-message.dark-theme{background-color:#2c3433;color:#fff}.flash-container .flash-message.dark-theme .flash-progress{background-color:hsla(0,0%,100%,.5)} \ No newline at end of file diff --git a/copanier/static/js/flash.min.js b/copanier/static/js/flash.min.js new file mode 100644 index 0000000..76bbbd9 --- /dev/null +++ b/copanier/static/js/flash.min.js @@ -0,0 +1 @@ +!function(e){function t(s){if(n[s])return n[s].exports;var i=n[s]={i:s,l:!1,exports:{}};return e[s].call(i.exports,i,i.exports,t),i.l=!0,i.exports}var n={};t.m=e,t.c=n,t.d=function(e,n,s){t.o(e,n)||Object.defineProperty(e,n,{configurable:!1,enumerable:!0,get:s})},t.n=function(e){var n=e&&e.__esModule?function(){return e.default}:function(){return e};return t.d(n,"a",n),n},t.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},t.p="",t(t.s=1)}([function(e,t,n){"use strict";function s(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}var i=function(){function e(e,t){for(var n=0;n0&&void 0!==arguments[0]?arguments[0]:null,n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};s(this,e),t&&t.constructor===Object&&(n=t,t=null),this.selector=t,this.options=Object.assign({},e.DEFAULT_OPTIONS,n),this._bag=[],this._setElement(),this._process()}return i(e,[{key:"getBag",value:function(){return this._bag}},{key:"setBag",value:function(e){return this._bag.push(e),this}},{key:"attach",value:function(){var e;return(e=this._bag).push.apply(e,arguments),this._checkLimit(),this}},{key:"detach",value:function(e){return this._bag=this._bag.filter(function(t){return e instanceof FlashMessage&&t!==e}),this}},{key:"_setElement",value:function(){!this.selector||this.selector instanceof Element||this.selector.constructor===String&&(this.selector=document.querySelectorAll(this.selector)||null)}},{key:"_process",value:function(){var e=this;this.selector&&(Array.isArray(this.selector)||this.selector.constructor===NodeList?this.selector.forEach(function(t){return e.setBag(new FlashMessage(t,e.options))}):this.setBag(new FlashMessage(this.selector,this.options)),this._checkLimit())}},{key:"_checkLimit",value:function(){if(this.options.limit&&this._bag.length>this.options.limit)for(var e=0;e0&&void 0!==arguments[0]?arguments[0]:null,arguments.length>1&&void 0!==arguments[1]?arguments[1]:{})}},{key:"DEFAULT_OPTIONS",get:function(){return{limit:0}}}]),e}();t.a=r},function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0});var s=n(2),i=(n.n(s),n(0)),r=n(3),o=n(4);n.n(o);!function(e){void 0!==e&&(e.Flash||(e.Flash=i.a),e.FlashMessage||(e.FlashMessage=r.a))}(window),t.default={Flash:i.a,FlashMessage:r.a}},function(e,t){"document"in self&&("classList"in document.createElement("_")&&(!document.createElementNS||"classList"in document.createElementNS("http://www.w3.org/2000/svg","g"))||function(e){"use strict";if("Element"in e){var t="classList",n="prototype",s=e.Element[n],i=Object,r=String[n].trim||function(){return this.replace(/^\s+|\s+$/g,"")},o=Array[n].indexOf||function(e){for(var t=0,n=this.length;n>t;t++)if(t in this&&this[t]===e)return t;return-1},a=function(e,t){this.name=e,this.code=DOMException[e],this.message=t},l=function(e,t){if(""===t)throw new a("SYNTAX_ERR","An invalid or illegal string was specified");if(/\s/.test(t))throw new a("INVALID_CHARACTER_ERR","String contains an invalid character");return o.call(e,t)},u=function(e){for(var t=r.call(e.getAttribute("class")||""),n=t?t.split(/\s+/):[],s=0,i=n.length;i>s;s++)this.push(n[s]);this._updateClassName=function(){e.setAttribute("class",""+this)}},h=u[n]=[],c=function(){return new u(this)};if(a[n]=Error[n],h.item=function(e){return this[e]||null},h.contains=function(e){return e+="",-1!==l(this,e)},h.add=function(){var e,t=arguments,n=0,s=t.length,i=!1;do{e=t[n]+"",-1===l(this,e)&&(this.push(e),i=!0)}while(++nn;n++)e=arguments[n],t.call(this,e)}};t("add"),t("remove")}if(e.classList.toggle("c3",!1),e.classList.contains("c3")){var n=DOMTokenList.prototype.toggle;DOMTokenList.prototype.toggle=function(e,t){return 1 in arguments&&!this.contains(e)==!t?t:n.call(this,e)}}e=null}())},function(e,t,n){"use strict";function s(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}var i=(n(0),function(){function e(e,t){for(var n=0;n1&&void 0!==arguments[1]?arguments[1]:e._CONSTANTS.TYPES.ERROR,i=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};s(this,e),n.constructor===Object&&(i=n,n=e._CONSTANTS.TYPES.ERROR),this.$_element=null,this.setOptions(i),t instanceof Element?(this.$_element=t,this._composeMessage()):(this.message=t,this.type=n),this.$_container=document.querySelector(this.options.container)||null,this._c_timeout=null,this.$_progress=null,this._progress_value=0,this._progress_offset=0,this._progress_interval=null,this._createContainer(),this._createMessage()}return i(e,null,[{key:"_CONSTANTS",get:function(){return{TYPES:{SUCCESS:"success",WARNING:"warning",ERROR:"error",INFO:"info"},THEME:"default",CONTAINER:".flash-container",CLASSES:{CONTAINER:"flash-container",VISIBLE:"is-visible",FLASH:"flash-message",PROGRESS:"flash-progress",PROGRESS_HIDDEN:"is-hidden"}}}},{key:"DEFAULT_OPTIONS",get:function(){return{progress:!1,interactive:!0,timeout:8e3,appear_delay:200,remove_delay:600,container:e._CONSTANTS.CONTAINER,classes:{container:e._CONSTANTS.CLASSES.CONTAINER,visible:e._CONSTANTS.CLASSES.VISIBLE,flash:e._CONSTANTS.CLASSES.FLASH,progress:e._CONSTANTS.CLASSES.PROGRESS,progress_hidden:e._CONSTANTS.CLASSES.PROGRESS_HIDDEN},theme:e._CONSTANTS.THEME,onShow:null,onClick:null,onClose:null}}}]),i(e,[{key:"setOptions",value:function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};return this.options=Object.assign({},e.DEFAULT_OPTIONS,t),this}},{key:"destroy",value:function(){this._close()}},{key:"_createContainer",value:function(){null!==this.$_container&&document.body.contains(this.$_container)||(this.$_container=document.createElement("div"),this.$_container.classList.add(this.options.classes.container),document.body.firstChild?document.body.insertBefore(this.$_container,document.body.firstChild):document.body.appendChild(this.$_container))}},{key:"_composeMessage",value:function(){this.message=this.$_element.dataset.message||this.$_element.innerHTML||"",this.type=this.$_element.dataset.type||e._CONSTANTS.TYPES.ERROR,void 0!==this.$_element.dataset.progress&&this.setOptions({progress:!0}),this.$_element.classList.add("flash-"+this.type)}},{key:"_createMessage",value:function(){if(this.$_element)this.$_element.querySelector(".thumb")&&this.$_element.classList.add("has-thumb");else{if(this.$_element=document.createElement("div"),this.$_element.classList.add(this.options.classes.flash,"flash-"+this.type),this.$_element.setAttribute("data-type",this.type),this.$_element.setAttribute("data-message",this.message),this.$_element.innerHTML=this.message,this.options.thumb){var e=document.createElement("img");e.classList.add("thumb"),e.src=this.options.thumb,this.$_element.classList.add("has-thumb"),this.$_element.appendChild(e)}this._append()}this._setTheme(),this._hasProgress()&&this._progressBar(),this.$_element.dataset.timeout&&(this.options.timeout=parseInt(this.$_element.dataset.timeout,10)),this._behavior(),!0===this._isInteractive()&&this._bindEvents()}},{key:"_append",value:function(){this.$_container.appendChild(this.$_element)}},{key:"_behavior",value:function(){var e=this;this._run(),window.setTimeout(function(){return e.$_element.classList.add(e.options.classes.visible)},this.options.appear_delay)}},{key:"_run",value:function(){var e=this;this._startProgress(),this._c_timeout=window.setTimeout(function(){return e._close()},this.options.timeout)}},{key:"_stop",value:function(){null!==this._c_timeout&&(window.clearTimeout(this._c_timeout),this._stopProgress(),this._c_timeout=null)}},{key:"_close",value:function(){var e=this;this._stopProgress(),this._isInteractive()&&this._unbindEvents(),this.$_element.classList.remove(this.options.classes.visible),this.$_element.addEventListener("transitionend",function(){e.$_container.removeChild(e.$_element),e._clear()})}},{key:"_clear",value:function(){!this.$_container.children.length&&this.$_container.parentNode.contains(this.$_container)&&this.$_container.parentNode.removeChild(this.$_container)}},{key:"_bindEvents",value:function(){var e=this;this._bindEvent("mouseover",function(t){return e._stop()}),this._bindEvent("mouseleave",function(t){return e._run()}),this._bindEvent("click",function(t){return e._close()})}},{key:"_bindEvent",value:function(e,t){try{this.$_element.addEventListener?this.$_element.addEventListener(e,t,!1):this.$_element.attachEvent("on"+this._getCapitalizedEventName(e),t)}catch(e){throw new Error("FlashMessage._bindEvent - Cannot add event on element - "+e)}}},{key:"_unbindEvents",value:function(){var e=this;this._unbindEvent("mouseover",function(t){return e._stop()}),this._unbindEvent("mouseleave",function(t){return e._run()}),this._unbindEvent("click",function(t){return e._close()})}},{key:"_unbindEvent",value:function(e,t){try{this.$_element.removeEventListener?this.$_element.removeEventListener(e,t,!1):this.$_element.detachEvent("on"+this._getCapitalizedEventName(e),t)}catch(e){throw new Error("FlashMessage._unbindEvent - Cannot remove event on element - "+e)}}},{key:"_isInteractive",value:function(){return Boolean(!0===this.options.interactive)}},{key:"_getCapitalizedEventName",value:function(e){return e.charAt(0).toUpperCase()+e.substr(1)}},{key:"_hasProgress",value:function(){return Boolean(this.options.progress)}},{key:"_progressBar",value:function(){this.$_progress=document.createElement("div"),this.$_progress.classList.add(this.options.classes.progress),this.$_element.appendChild(this.$_progress)}},{key:"_startProgress",value:function(){var e=this;this._hasProgress()&&(this.$_progress||this._progressBar(),this._stopProgress(),this._progress_offset=0,this.$_progress.classList.remove(this.options.classes.progress_hidden),this._progress_interval=window.setInterval(function(){return e._setProgress()},16))}},{key:"_setProgress",value:function(){this.$_progress.style.width=this._progress_value+"%",this._progress_value=(100*this._progress_offset/this.options.timeout).toFixed(2),this._progress_offset+=16,this._progress_value>=100&&this._stopProgress()}},{key:"_stopProgress",value:function(){this._hasProgress()&&this.$_progress&&(this.$_progress.classList.add("is-hidden"),window.clearInterval(this._progress_interval),this._progress_interval=null,this._progress_value=0)}},{key:"_setTheme",value:function(){var t=this.$_element.dataset.theme||this.options.theme||"";t.length&&t!==e._CONSTANTS.THEME&&this.$_element.classList.add(t+"-theme")}}],[{key:"create",value:function(t){return new e(t,arguments.length>1&&void 0!==arguments[1]?arguments[1]:e._CONSTANTS.TYPES.ERROR,arguments.length>2&&void 0!==arguments[2]?arguments[2]:{})}},{key:"success",value:function(t){var n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};return new e(t,e._CONSTANTS.TYPES.SUCCESS,n)}},{key:"warning",value:function(t){var n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};return new e(t,e._CONSTANTS.TYPES.WARNING,n)}},{key:"error",value:function(t){var n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};return new e(t,e._CONSTANTS.TYPES.ERROR,n)}},{key:"info",value:function(t){var n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};return new e(t,e._CONSTANTS.TYPES.INFO,n)}},{key:"addCustomVerbs",value:function(){for(var t=arguments.length,n=Array(t),s=0;s1&&void 0!==arguments[1]?arguments[1]:{};return new e(n,t,s)})})}}]),e}();t.a=r},function(e,t){}]); \ No newline at end of file diff --git a/copanier/static/page.css b/copanier/static/page.css index 5af0576..0e18b60 100644 --- a/copanier/static/page.css +++ b/copanier/static/page.css @@ -9,7 +9,7 @@ } @media print { - nav, .notification, .toolbox, .edit, .hide-on-print, .button{ + nav, .notification, .toolbox, .edit, .hide-on-print, .header, #menu, .toplink, .button{ display: none; } diff --git a/copanier/static/side-menu.css b/copanier/static/side-menu.css index d49be24..d468949 100644 --- a/copanier/static/side-menu.css +++ b/copanier/static/side-menu.css @@ -249,4 +249,10 @@ Hides the menu at `48em`, but modify this based on your app's needs. .pure-menu-item a { display: block; +} + +#sitename { + color: #ccc; + display: block; + padding-left: 1em; } \ No newline at end of file diff --git a/copanier/templates/base.html b/copanier/templates/base.html index b09db3d..78032f9 100644 --- a/copanier/templates/base.html +++ b/copanier/templates/base.html @@ -9,6 +9,7 @@ + {% block head %} {% endblock head %} @@ -20,77 +21,52 @@ - + {% if request.user %} - + {% else %} +

{{ config.SITE_NAME }}

+ {% endif %} +
- {% if message %} -
{{ message[0] }}
- {% endif %}
- {% block body %} - {% endblock body %} + {% block body %} + {% endblock body %}
- - - -
- -
-
-
+ + + + {% if message %} + + {% endif %} diff --git a/copanier/templates/delivery.html b/copanier/templates/delivery.html deleted file mode 100644 index 93964e4..0000000 --- a/copanier/templates/delivery.html +++ /dev/null @@ -1,76 +0,0 @@ -{% extends "base.html" %} -{% block body %} - -
-

{{ delivery.name }} ({% if delivery.status == delivery.OPEN %}Commandes jusqu'au {{ delivery.order_before|date|capitalize }}{% elif delivery.status == delivery.ADJUSTMENT %}Ajustements en cours{% elif delivery.status == delivery.CLOSED %}Fermée{% else %}Archivée{% endif %}) -

-

- Distribution {{ delivery.from_date|date|capitalize }}, {{ delivery.from_date|time }} - {{ delivery.to_date|time }}, à {{ delivery.where }}. -

- -
- -
    -
  • Mise à jour des prix
  • -
  • Ouverture des commandes
  • -
  • Ajustements
  • -
  • Achats par les référent⋅e⋅s
  • -
  • Distribution
  • -
-
-{% if delivery.has_products %} - {% include "includes/delivery_table.html" %} -{% else %} -
-

Aucun produit n'est encore défini pour cette livraison.

- {% if request.user and request.user.is_staff %} -
- Pour rajouter des produits, deux options: -
    -
  1. Télécharger le tableur avec les produits, le modifier localement sur votre machine puis  
    {% with unique_id="import-products" %}{% include "includes/modal_import_products.html" %}{% endwith %}
  2. -
  3. Ou bien ajouter les product⋅eurs⋅rices un⋅e par un⋅e dans l'interface
  4. -
-
- {% endif %} -
-{% endif %} -
-
-
    - -
-{% endblock body %} diff --git a/copanier/templates/archive.html b/copanier/templates/delivery/archives.html similarity index 74% rename from copanier/templates/archive.html rename to copanier/templates/delivery/archives.html index 6daffec..cfa1849 100644 --- a/copanier/templates/archive.html +++ b/copanier/templates/delivery/archives.html @@ -1,5 +1,5 @@ {% extends "base.html" %} {% block body %} -

Livraisons archivées

+

Distributions archivées

{% include "includes/delivery_list.html" %} {% endblock body %} diff --git a/copanier/templates/delivery_balance.html b/copanier/templates/delivery/balance.html similarity index 88% rename from copanier/templates/delivery_balance.html rename to copanier/templates/delivery/balance.html index 88d4b63..db9486c 100644 --- a/copanier/templates/delivery_balance.html +++ b/copanier/templates/delivery/balance.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% block title %}

  Répartition des paiements Télécharger

{% endblock %} +{% block title %}

  Répartition des paiements Télécharger

{% endblock %} {% block body %}

{{ delivery.name }} du {{ delivery.to_date | date }}.

Les personnes indiquées avec un * à côté de leur nom sont celles qui ont payé cette commande pour leur groupe.

diff --git a/copanier/templates/delivery/copy.html b/copanier/templates/delivery/copy.html new file mode 100644 index 0000000..5e10a92 --- /dev/null +++ b/copanier/templates/delivery/copy.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} + +{% block body %} +
+

Copier les produits d'une autre distribution

+
+ +
+ +
+ +
+
+
+{% endblock body %} diff --git a/copanier/templates/edit_delivery.html b/copanier/templates/delivery/edit.html similarity index 57% rename from copanier/templates/edit_delivery.html rename to copanier/templates/delivery/edit.html index 0911ce5..eceaa05 100644 --- a/copanier/templates/edit_delivery.html +++ b/copanier/templates/delivery/edit.html @@ -1,20 +1,31 @@ {% extends "base.html" %} - +{% block toplink %}{% if delivery.id %}↶ Retourner à la distribution{% endif %}{% endblock %} {% block body %} +
{% if delivery.id %} -

Modifier la livraison

+

Modifier la distribution

+
+ +
{% else %} -

Nouvelle livraison

+

Nouvelle distribution

{% endif %} + +
- -

-{% if delivery %} - -{% endif %} {% endblock body %} diff --git a/copanier/templates/delivery/list.html b/copanier/templates/delivery/list.html new file mode 100644 index 0000000..fb2bdc4 --- /dev/null +++ b/copanier/templates/delivery/list.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} +{% block body %} + + + + {% with deliveries=incoming %} + {% include "includes/delivery_list.html" %} + {% endwith %} + {% if former %} +
+

Distributions passées

+
+ {% with deliveries=former %} + {% include "includes/delivery_small_list.html" %} + {% endwith %} + {% endif %} + {% if archives %} + Voir les distributions archivées +
+ {% endif %} +{% endblock body %} diff --git a/copanier/templates/place_order.html b/copanier/templates/delivery/place_order.html similarity index 87% rename from copanier/templates/place_order.html rename to copanier/templates/delivery/place_order.html index 5fa63c0..5dcc0b6 100644 --- a/copanier/templates/place_order.html +++ b/copanier/templates/delivery/place_order.html @@ -1,10 +1,10 @@ {% extends "base.html" %} {% block body %} -{% set group_name = request.groups.groups[person.name].name %} +{% set group_name = request.groups.groups.get(person.name, person).name %}

- {{ delivery.name }} + {{ delivery.name }} — Commande pour {{ group_name }} {% if order.phone_number %} ({{ order.phone_number }}) @@ -13,8 +13,8 @@ {% include "includes/delivery_head.html" %}
- {% for producer in delivery.producers %} -

{{ producer }}

+ {% for producer in delivery.producers.values() %} +

{{ producer.name }}

@@ -30,7 +30,7 @@ - {% for product in delivery.get_products_by(producer) %} + {% for product in delivery.get_products_by(producer.id) %} - + - + {% endfor %}
{{ product }} {% if product.rupture %}(RUPTURE !){% endif %} {% if product.description or product.img %} @@ -54,13 +54,12 @@ {% endfor %}

On y est presque ! Est-ce que tu peux entrer un numéro de téléphone au cas où on ait besoin de vous joindre ?

-

Total: {{ order.total(delivery.products) if order else 0 }} €

{% if delivery.status != delivery.CLOSED or request.user.is_staff %} {% endif %} {% if request.user.is_staff and delivery.status == delivery.CLOSED %} - Ajuster + Ajuster {% endif %} diff --git a/copanier/templates/prepare_referent_email.html b/copanier/templates/delivery/referent_email.html similarity index 72% rename from copanier/templates/prepare_referent_email.html rename to copanier/templates/delivery/referent_email.html index 833aaf1..ba28f84 100644 --- a/copanier/templates/prepare_referent_email.html +++ b/copanier/templates/delivery/referent_email.html @@ -1,8 +1,9 @@ {% extends "base.html" %} +{% block toplink %}↶ Retourner à la boîte à outils{% endblock %} {% block body %}

Envoi d'un mail aux référent⋅e⋅s

-

Le mail ci dessous (vous pouvez le modifier !) va être envoyé aux référent⋅e⋅s, avec en pièce jointe le tableau comportant les commandes pour les product⋅eurs⋅rices.

+

Le mail ci dessous (modifiable) va être envoyé aux référent⋅e⋅s, avec en pièce jointe les commandes pour les product⋅eurs⋅rices de chaque référent⋅e.


@@ -12,7 +13,7 @@ Bonjour, Et voilà, les commandes maintenant terminées, il est maintenant temps de passer à l'action ! En pièce-jointe, les informations pour les producteurs⋅rices dont tu est référent⋅e. -Tu peux aussi retrouver le doc à cette URL : https://{{ request.host }}/livraison/{{ delivery.id }}/producteurices +Tu peux aussi retrouver le doc à cette URL : https://{{ request.host }}/distribution/{{ delivery.id }}/produits Rendez-vous pour la distribution, le {{ delivery.from_date|date }} de {{ delivery.from_date|time }} à {{ delivery.to_date|time }} à {{ delivery.where }}. diff --git a/copanier/templates/delivery/show.html b/copanier/templates/delivery/show.html new file mode 100644 index 0000000..de57237 --- /dev/null +++ b/copanier/templates/delivery/show.html @@ -0,0 +1,68 @@ +{% extends "base.html" %} +{% block body %} + +
+

{{ delivery.name }}

+

{% if delivery.products %}{% if delivery.status == delivery.OPEN %}Commandes jusqu'au {{ delivery.order_before|date|capitalize }}{% elif delivery.status == delivery.ADJUSTMENT %}Ajustements en cours{% elif delivery.status == delivery.CLOSED %}Fermée{% else %}Archivée{% endif %}{% endif %}. + {% include "includes/order_button.html" %} +

+

+ Distribution {{ delivery.from_date|date|capitalize }}, {{ delivery.from_date|time }} - {{ delivery.to_date|time }}, à {{ delivery.where }}. +

+ +
+ + +
+ + + +
+ +
+{% if delivery.has_products %} + {% include "includes/delivery_table.html" %} +{% else %} +
+

😔 Pour le moment, cette distribution est bien vide…

+ {% if request.user and request.user.is_staff %} + Occupons-nous donc de ça ! Deux options : +
    +
  1. Ajouter les product⋅eurs⋅rices à la main ;
  2. +
  3. Ou bien copier les produits d'une autre distribution.
  4. +
+
+ {% endif %} + +{% endif %} +
+
+
    + +
+{% endblock body %} diff --git a/copanier/templates/signing_sheet.html b/copanier/templates/delivery/signing_sheet.html similarity index 100% rename from copanier/templates/signing_sheet.html rename to copanier/templates/delivery/signing_sheet.html diff --git a/copanier/templates/delivery/toolbox.html b/copanier/templates/delivery/toolbox.html new file mode 100644 index 0000000..7c0eb5c --- /dev/null +++ b/copanier/templates/delivery/toolbox.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} + +{% block toplink %}↶ Retourner à la distribution{% endblock %} + +{% block body %} +

Gérer « {{ delivery.name }} »

+ +{% set display_counts = True %} +{% include "includes/delivery_head.html" %} + +

Emails des référent⋅e⋅s

+

Au cas où, quoi.

+ + +

Avant et pendant la distribution

+  Modifier la commande (dates, lieu, référent⋅e, etc) +  Modifier les produits, les product⋅rices⋅eurs +  Gérer les groupes / colocs + +

Une fois les commandes passées (après le {{ delivery.order_before|date }})

+  Télécharger les bons de distribution +  Télécharger le tableau des commandes +  Envoyer les infos de commande aux référent⋅e⋅s + +

Pour préparer la distribution (le {{ delivery.from_date|date }})

+  Fiches de commandes par groupe +  Faire la répartition des paiements + +{% endblock %} \ No newline at end of file diff --git a/copanier/templates/delivery_toolbox.html b/copanier/templates/delivery_toolbox.html deleted file mode 100644 index b30ace0..0000000 --- a/copanier/templates/delivery_toolbox.html +++ /dev/null @@ -1,27 +0,0 @@ -{% extends "base.html" %} - -{% block body %} -

Gérer « {{ delivery.name }} »

- -{% set display_counts = True %} -{% include "includes/delivery_head.html" %} - -

Emails des référent⋅e⋅s

-

Au cas où, quoi.

- - -

Avant et pendant la distribution

-  Modifier la commande (dates, lieu, référent⋅e, etc) -  Modifier les produits, les product⋅rices⋅eurs -  Gérer les groupes / colocs - -

Une fois les commandes passées (après le {{ delivery.order_before|date }})

-  Télécharger les bons de livraison -  Télécharger le tableau des commandes -  Envoyer les infos de commande aux référent⋅e⋅s - -

Pour préparer la distribution (le {{ delivery.from_date|date }})

-  Fiches de commandes par groupe -  Faire la répartition des paiements - -{% endblock %} \ No newline at end of file diff --git a/copanier/templates/deliverybase.html b/copanier/templates/deliverybase.html deleted file mode 100644 index 29b3587..0000000 --- a/copanier/templates/deliverybase.html +++ /dev/null @@ -1,64 +0,0 @@ -{% extends "base.html" %} -{% block body %} -
- - - - - - -
-
-

Page Title

-

A subtitle for your page goes here

-
- -
-

How to use this layout

-

- To use this layout, you can just copy paste the HTML, along with the CSS in side-menu.css, and the JavaScript in ui.js. The JS file uses vanilla JavaScript to simply toggle an active class that makes the menu responsive. -

- -

Now Let's Speak Some Latin

-

- Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. -

- -
-
- Peyto Lake -
-
- Train -
-
- T-Shirt Store -
-
- Mountain -
-
- -

Try Resizing your Browser

-

- Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. -

-
-
-
-{% endblock body %} \ No newline at end of file diff --git a/copanier/templates/emails/order_summary.html b/copanier/templates/emails/order_summary.html index b715d2e..fb68dab 100644 --- a/copanier/templates/emails/order_summary.html +++ b/copanier/templates/emails/order_summary.html @@ -1,9 +1,9 @@

Bonjour,

Voici le résumé de ta commande «{{ delivery.name }}»

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

Livraison: {{ delivery.where }}, le {{ delivery.from_date|date }} de {{ delivery.from_date|time }} à {{ delivery.to_date|time }}

+

Distribution: {{ delivery.where }}, le {{ delivery.from_date|date }} de {{ delivery.from_date|time }} à {{ delivery.to_date|time }}

{% if delivery.is_open %} -

Tu peux la modifier (jusqu'au {{ delivery.order_before|date }}) en cliquant ici.

+

Tu peux la modifier (jusqu'au {{ delivery.order_before|date }}) en cliquant ici.

{% endif %}

Bonne journée!

{{ config.EMAIL_SIGNATURE }}

diff --git a/copanier/templates/emails/order_summary.txt b/copanier/templates/emails/order_summary.txt index da37e87..1c1638e 100644 --- a/copanier/templates/emails/order_summary.txt +++ b/copanier/templates/emails/order_summary.txt @@ -1,6 +1,6 @@ Salut salut, -Voici le résumé de votre commande «{{ delivery.name }}» pour « {{ request['user']['group_name'] }} ». +Voici le résumé de la commande « {{ delivery.name }} » pour « {{ request['user']['group_name'] }} ». Produit | Prix unitaire | Quantité @@ -9,12 +9,12 @@ Produit | Prix unitaire | Quantité Total: {{ order.total(delivery.products) if order else 0 }} € -Livraison: {{ delivery.where }}, le {{ delivery.from_date|date }} de {{ delivery.from_date|time }} à {{ delivery.to_date|time }} +Distribution: {{ delivery.where }}, le {{ delivery.from_date|date }} de {{ delivery.from_date|time }} à {{ delivery.to_date|time }} {% if delivery.is_open %} Tu peux la modifier (jusqu'au {{ delivery.order_before|date }}) en cliquant ici: -https://{{ request.host }}/livraison/{{ delivery.id }}/commander +https://{{ request.host }}/distribution/{{ delivery.id }}/commander {% endif %} diff --git a/copanier/templates/groups.html b/copanier/templates/groups.html deleted file mode 100644 index 0f0737b..0000000 --- a/copanier/templates/groups.html +++ /dev/null @@ -1,24 +0,0 @@ -{% extends "base.html" %} - -{% block body %} -
-

Groupes

- -
- -{% if not request['user'].group_id %} -

Bienvenue ! Avant de pouvoir commander, peux-tu nous indiquer de quelle coloc / famille fais-tu partie ?

-{% endif %} - - -{% for group in groups.groups.values() %} -

{{ group.name }} {% if group.id != request['user'].group_id %}rejoindre{% endif %} éditer

-
    - {% for member in group.members %} -
  • {{ member }}
  • - {% endfor %} -
-{% endfor %} -{% endblock %} \ No newline at end of file diff --git a/copanier/templates/edit_group.html b/copanier/templates/groups/edit.html similarity index 100% rename from copanier/templates/edit_group.html rename to copanier/templates/groups/edit.html diff --git a/copanier/templates/groups/list.html b/copanier/templates/groups/list.html new file mode 100644 index 0000000..d40c122 --- /dev/null +++ b/copanier/templates/groups/list.html @@ -0,0 +1,47 @@ +{% extends "base.html" %} + +{% block body %} +
+

Groupes

+ +
+ +{% if not request['user'].group_id %} +

Bienvenue ! Avant de pouvoir commander, peux-tu nous indiquer de quelle coloc / famille fais-tu partie ?

+{% endif %} + + + + + + + + + + + + {% for group in groups.groups.values() %} + + + + + + {% endfor %} + +
GroupeMembresActions
{{ group.name }} +
    + {% for member in group.members %} +
  • {{ member }}
  • + {% endfor %} +
+
{% if group.id != request['user'].group_id %}rejoindre{% endif %} éditer
+ +

Tu ne fais partie d'aucun des groupes listés ici ? Tu peux  en créer un nouveau

+ +{% endblock %} \ No newline at end of file diff --git a/copanier/templates/home.html b/copanier/templates/home.html deleted file mode 100644 index 511b648..0000000 --- a/copanier/templates/home.html +++ /dev/null @@ -1,30 +0,0 @@ -{% extends "base.html" %} -{% block body %} - - - {% with deliveries=incoming %} - {% include "includes/delivery_list.html" %} - {% endwith %} - {% if former %} -
-

Distributions passées

-
- {% with deliveries=former %} - {% include "includes/delivery_small_list.html" %} - {% endwith %} - {% endif %} - {% if archives %} - Voir les livraisons archivées -
- {% endif %} -{% endblock body %} diff --git a/copanier/templates/includes/delivery_head.html b/copanier/templates/includes/delivery_head.html index 30bd423..0c1db26 100644 --- a/copanier/templates/includes/delivery_head.html +++ b/copanier/templates/includes/delivery_head.html @@ -5,7 +5,7 @@ {% else %}
  • Référent⋅e {{ delivery.contact }}
  • {% endif %} -
  • Date de livraison
  • +
  • Date de distribution
  • {% if delivery.status == delivery.OPEN %}Date limite de commande {% elif delivery.status == delivery.ADJUSTMENT %}Ajustement en cours{% elif delivery.status == delivery.CLOSED %}Fermée{% else %}Archivée{% endif %}
  • {% if delivery.instructions %}
  • À savoir {{ delivery.instructions }}
  • {% endif %} {% if delivery.infos_url %}
  • Plus d'infos {{ delivery.infos_url|truncate(20)}}
  • {% endif %} diff --git a/copanier/templates/includes/delivery_list.html b/copanier/templates/includes/delivery_list.html index c342d90..869900b 100644 --- a/copanier/templates/includes/delivery_list.html +++ b/copanier/templates/includes/delivery_list.html @@ -2,7 +2,7 @@
      {% for delivery in deliveries %}
    • -

      {{ delivery.name }} {% include "includes/order_button.html" %} Voir les commandes

      +

      {{ delivery.name }} {% include "includes/order_button.html" %} Voir les commandes

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

    • diff --git a/copanier/templates/includes/delivery_small_list.html b/copanier/templates/includes/delivery_small_list.html index 2966036..981d0e8 100644 --- a/copanier/templates/includes/delivery_small_list.html +++ b/copanier/templates/includes/delivery_small_list.html @@ -2,10 +2,10 @@ {% else %} -

      Aucune livraison.

      +

      Aucune distribution.

      {% endif %} diff --git a/copanier/templates/includes/delivery_table.html b/copanier/templates/includes/delivery_table.html index 8554492..53b38bf 100644 --- a/copanier/templates/includes/delivery_table.html +++ b/copanier/templates/includes/delivery_table.html @@ -5,19 +5,18 @@ {% endif %} {% for producer in producers %} {% set producer_obj = delivery.producers[producer] %} -

      {{ producer }} +

      {{ producer_obj.name }} {% if edit_mode or request.user.is_staff or producer_obj.referent == request.user.email %} -   Éditer -   Ajouter un produit - {% if delivery.can_generate_reports %} -   Télécharger le bon de commande - {% endif %} +   Gérer ce⋅tte producteur⋅rice {% endif %}

      {% if producer_obj.description %}{{ producer_obj.description }}{% endif %}. Référent⋅e : {{ producer_obj.referent_name }} / {{ producer_obj.referent_tel }}
      - +{% if not delivery.get_products_by(producer) %} +😔 Ce⋅tte producteur⋅rice n'a pas encore de produits. Voulez vous en rajouter un ? +{% else %} +
      @@ -25,14 +24,13 @@ {% if delivery.has_packing %} {% endif %} - {% if edit_mode %}{% endif %} {% if not list_only %} {% for orderer, order in delivery.orders.items() %} {% set orderer_name = request.groups.groups[orderer].name %} {% for product in delivery.get_products_by(producer) %} - {% if delivery.has_packing %} {% endif %} - {% if edit_mode %}{% endif %} {{ delivery.product_wanted(product) }} {% if delivery.status == delivery.ADJUSTMENT and delivery.product_missing(product) %} (−{{ delivery.product_missing(product) }}) - {% if request.user.is_staff %}ajuster{% endif %} + {% if request.user.is_staff %}ajuster{% endif %} {% endif %} {% if not list_only %} @@ -80,4 +77,6 @@
      ProduitConditionnementÉditerTotal {% if request.user and (request.user.is_staff or request.user.is_referent(delivery)) %} - {{ orderer_name }} + {{ orderer_name }} {% else %} {{ orderer_name }} {% endif %} @@ -44,16 +42,15 @@
      {% if edit_mode %}{% endif %}{{ product }}{% if edit_mode %}{% endif %}{% if product.rupture %} {{ product.rupture }}{% endif %} + {% if edit_mode %}{% endif %}{% if edit_mode %} {% endif %}{{ product }}{% if edit_mode %}{% endif %}{% if product.rupture %} {{ product.rupture }}{% endif %} {{ product.price | round(2) }} €{% if product.packing %}{{ product.packing }} x {% endif %} {{ product.unit }}modifier
      +{% endif %} +
      {% endfor %} \ No newline at end of file diff --git a/copanier/templates/includes/modal.html b/copanier/templates/includes/modal.html deleted file mode 100644 index d417557..0000000 --- a/copanier/templates/includes/modal.html +++ /dev/null @@ -1,8 +0,0 @@ - - - -
      - {% block modal_body %} - {% endblock modal_body %} - -
      diff --git a/copanier/templates/includes/modal_add_command.html b/copanier/templates/includes/modal_add_command.html deleted file mode 100644 index 9a1624d..0000000 --- a/copanier/templates/includes/modal_add_command.html +++ /dev/null @@ -1,10 +0,0 @@ -{% extends "includes/modal.html" %} - -{% block modal_label %} Ajouter une commande{% endblock modal_label %} -{% block modal_body %} - -

      Ajouter une commande pour quelqu'un d'autre

      - - - -{% endblock modal_body %} diff --git a/copanier/templates/includes/modal_copy_emails.html b/copanier/templates/includes/modal_copy_emails.html deleted file mode 100644 index 8de45c2..0000000 --- a/copanier/templates/includes/modal_copy_emails.html +++ /dev/null @@ -1,8 +0,0 @@ -{% extends "includes/modal.html" %} - -{% block modal_label %} Copier les emails{% endblock modal_label %} -{% block modal_body %} -
      - -
      -{% endblock modal_body %} diff --git a/copanier/templates/includes/modal_import_command.html b/copanier/templates/includes/modal_import_command.html deleted file mode 100644 index 6208cdd..0000000 --- a/copanier/templates/includes/modal_import_command.html +++ /dev/null @@ -1,19 +0,0 @@ -{% extends "includes/modal.html" %} - -{% block modal_label %} Importer une commande{% endblock modal_label %} -{% block modal_body %} -

      Importer une commande

      -

      Colonnes: ref*, wanted*

      -
      - - - -
      - -

      Importer plusieurs commandes

      -

      Colonnes: ref*, toto@domain.tld, etc.

      -
      - - -
      -{% endblock modal_body %} diff --git a/copanier/templates/includes/modal_import_products.html b/copanier/templates/includes/modal_import_products.html deleted file mode 100644 index 70e24cb..0000000 --- a/copanier/templates/includes/modal_import_products.html +++ /dev/null @@ -1,44 +0,0 @@ -{% extends "includes/modal.html" %} - -{% block modal_label %} Importer les produits depuis un tableur{% endblock modal_label %} -{% block modal_body %} -

      Importer des produits

      -

      Format pris en charge: xlsx

      -
      -

      Le tableur doit contenir deux onglets, le premier avec les produits, le second avec les producteurs.

      - Détails des colonnes produits -
      -
      ref
      -
      Référence unique du produit (permet de mettre à jour les produits en cours de commande ou d'importer des commandes individuelles).
      -
      name
      -
      Nom du produit: mettre juste assez d'info pour distinguer les produits les uns des autres.
      -
      price
      -
      Prix d'une unité, en euros.
      -
      unit
      -
      Conditionnement d'une unité: 1kg, 33cl…
      -
      producer
      -
      Le nom du producteur. Utilisé pour pouvoir grouper les produits par producteur.
      -
      description
      -
      Plus de détails sur le produit.
      -
      packing
      -
      Contionnement final pour grouper les commandes, le cas échéant, en nombre d'unités.
      -
      url
      -
      Une URL éventuelle pointant sur une page présentant le produit.
      -
      img
      -
      Une URL éventuelle pointant sur une image du produit (attention, utiliser seulement des liens https).
      -
      - Détails des colonnes producteur -
      -
      id
      -
      Nom du producteur (doit être le même que le premier onglet).
      -
      referent
      -
      Le nom du ou de la référent⋅e
      -
      contact
      -
      Un moyen de contacter le⋅a product⋅eur⋅rice
      -
      -
      -
      - - -
      -{% endblock modal_body %} diff --git a/copanier/templates/includes/modal_product.html b/copanier/templates/includes/modal_product.html deleted file mode 100644 index f48ca84..0000000 --- a/copanier/templates/includes/modal_product.html +++ /dev/null @@ -1,9 +0,0 @@ -{% extends "includes/modal.html" %} - -{% block modal_label %}(Détails){% endblock modal_label %} -{% block modal_body %} -

      {{ product.description }}

      -

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

      -{% endblock modal_body %} diff --git a/copanier/templates/includes/order_button.html b/copanier/templates/includes/order_button.html index 84828ec..79f3ebb 100644 --- a/copanier/templates/includes/order_button.html +++ b/copanier/templates/includes/order_button.html @@ -1,5 +1,5 @@ {% if (delivery.status == delivery.OPEN or delivery.status == delivery.ADJUSTMENT) and delivery.has_products %} - + {% if delivery.status == delivery.ADJUSTMENT %} Ajuster ma commande {% elif delivery.status == delivery.OPEN %} diff --git a/copanier/templates/includes/toplinks.html b/copanier/templates/includes/toplinks.html new file mode 100644 index 0000000..dd54c05 --- /dev/null +++ b/copanier/templates/includes/toplinks.html @@ -0,0 +1,7 @@ +{% if link == "deliveries" %} +↶ Retourner à la liste des distributions +{% elif link == "products" %} +↶ Retourner à la liste des produits +{% elif link == "producer" %} +↶ Retourner à la fiche product⋅eur⋅rice +{% endif %} \ No newline at end of file diff --git a/copanier/templates/list_products.html b/copanier/templates/list_products.html deleted file mode 100644 index f16ec40..0000000 --- a/copanier/templates/list_products.html +++ /dev/null @@ -1,10 +0,0 @@ -{% extends "base.html" %} -{% block body %} -

      - Télécharger la liste des commandes en PDF - {% if referent %}Voici la liste des product⋅eurs⋅rices dont {{ referent }} est référent⋅e. voir tous les produits{% endif %} -

      -
      -{% include "includes/delivery_table.html" %} -
      -{% endblock body %} diff --git a/copanier/templates/adjust_product.html b/copanier/templates/products/ajust.html similarity index 88% rename from copanier/templates/adjust_product.html rename to copanier/templates/products/ajust.html index b123e42..63e0fcf 100644 --- a/copanier/templates/adjust_product.html +++ b/copanier/templates/products/ajust.html @@ -2,7 +2,7 @@ {% block body %}
      -

      {{ delivery.producer }} — Ajuster le produit «{{ product.name }}»

      +

      {{ delivery.producer }} — Ajuster le produit «{{ product.name }}»

      Conditionnement {{ product.packing }} x {{ product.unit }}

      Total commandé {{ delivery.product_wanted(product) }}

      Manquant {{ delivery.product_missing(product) }}

      diff --git a/copanier/templates/products/delete.html b/copanier/templates/products/delete.html new file mode 100644 index 0000000..38c6afb --- /dev/null +++ b/copanier/templates/products/delete.html @@ -0,0 +1,67 @@ +{% extends "base.html" %} + +{% block body %} +

      Supprimer le produit « {{ product.name }} »

      +
      + + + + + +
      + +
      + +
      +
      + +{% if products %} +

      Produits

      + + + + + + + + + + + + + +{% for product in products %} + + + + + + + + + +{% endfor %} +
      ProduitPrixUnitéDescriptionPackagingRupture ?
      {{ product.name }}{{ product.price }}€{{ product.unit }}{{ product.description }}{% if product.packing %}{{ product.packing }}{% endif %}{% if product.rupture %}RUPTURE !!{% endif %}éditer
      +{% endif %} + +
      +{% endblock body %} diff --git a/copanier/templates/edit_product.html b/copanier/templates/products/edit.html similarity index 68% rename from copanier/templates/edit_product.html rename to copanier/templates/products/edit.html index 963b640..4d68938 100644 --- a/copanier/templates/edit_product.html +++ b/copanier/templates/products/edit.html @@ -1,10 +1,12 @@ {% extends "base.html" %} +{% block toplink %}↶ Retourner aux produits de {{ producer.name }}{% endblock %} + {% block body %} {% if product.ref %}

      Modifier le produit « {{ product.name }} »

      {% else %} -

      {{ producer_id }} : Nouveau produit

      +

      {{ producer.name }} : Nouveau produit

      {% endif %}
      -
      @@ -59,13 +57,13 @@ {% for product in products %}
    {{ product.name }}{{ product.name }} {{ product.price }}€ {{ product.unit }} {{ product.description }} {% if product.packing %}{{ product.packing }}{% endif %} {% if product.rupture %}RUPTURE !!{% endif %}éditeréditer
    diff --git a/copanier/templates/edit_producer.html b/copanier/templates/products/edit_producer.html similarity index 58% rename from copanier/templates/edit_producer.html rename to copanier/templates/products/edit_producer.html index da3a664..07a9b21 100644 --- a/copanier/templates/edit_producer.html +++ b/copanier/templates/products/edit_producer.html @@ -1,18 +1,28 @@ {% extends "base.html" %} +{% block toplink %}↶ Retourner aux produits{% endblock %} {% block body %} -{% if producer.id %} -

    Modifier les informations pour « {{ producer.id }} »

    +
    +{% if producer and producer.id %} +

    Modifier les informations pour « {{ producer.name }} »

    +
    + +
    {% else %} -

    Nouve⋅eau⋅lle product⋅eur⋅rice

    +

    Ajouter un⋅e producteur⋅rice

    {% endif %} +
    {% if producer.id %} {% else %} {% endif %}
    @@ -41,9 +51,9 @@ {% if products %} -

    Produits Ajouter un produit

    - - +

    Produits Ajouter un produit

    +

    Vous pouvez éditer les produits en cliquant sur leur nom.

    +
    @@ -57,13 +67,13 @@ {% for product in products %} - + - + {% endfor %}
    Produit
    {{ product.name }}{{ product.name }} {{ product.price }}€ {{ product.unit }} {{ product.description }} {% if product.packing %}{{ product.packing }}{% endif %} {% if product.rupture %}RUPTURE !!{% endif %}éditersupprimer
    diff --git a/copanier/templates/products/list.html b/copanier/templates/products/list.html new file mode 100644 index 0000000..ecf1a48 --- /dev/null +++ b/copanier/templates/products/list.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} +{% block toplink %}↶ Retourner à la distribution{% endblock %} +{% block body %} + +

    + {% if producers %} + Télécharger la liste des commandes en PDF + {% endif %} + {% if referent %}Voici la liste des product⋅eurs⋅rices dont {{ referent }} est référent⋅e. voir tous les produits{% endif %} +

    + +
    +{% include "includes/delivery_table.html" %} +
    +{% endblock body %} diff --git a/copanier/utils.py b/copanier/utils.py index 0058d1d..fa73b20 100644 --- a/copanier/utils.py +++ b/copanier/utils.py @@ -27,3 +27,11 @@ def read_token(token): def prefix(string, delivery): date = delivery.to_date.strftime("%Y-%m-%d") return f"{config.SITE_NAME}-{date}-{string}" + + +def date_filter(value): + return value.strftime(r"%A %d %B") + + +def time_filter(value): + return value.strftime(r"%H:%M") diff --git a/copanier/views/__init__.py b/copanier/views/__init__.py new file mode 100644 index 0000000..ce7960f --- /dev/null +++ b/copanier/views/__init__.py @@ -0,0 +1 @@ +from . import delivery, products, groups, login # noqa : import to scan the routes. diff --git a/copanier/views/core.py b/copanier/views/core.py new file mode 100644 index 0000000..1e4dc10 --- /dev/null +++ b/copanier/views/core.py @@ -0,0 +1,130 @@ +import ujson as json + +from pathlib import Path +from jinja2 import Environment, PackageLoader, select_autoescape +from roll.extensions import traceback +from roll import Roll as BaseRoll, Response as RollResponse + +from weasyprint import HTML + +from . import session +from .. import config, utils, loggers + + +class Response(RollResponse): + def render_template(self, template_name, *args, **kwargs): + context = app.context() + context.update(kwargs) + context["request"] = self.request + context["config"] = config + context["request"] = self.request + if self.request.cookies.get("message"): + context["message"] = json.loads(self.request.cookies["message"]) + self.cookies.set("message", "") + return env.get_template(template_name).render(*args, **context) + + def html(self, template_name, *args, **kwargs): + self.headers["Content-Type"] = "text/html; charset=utf-8" + self.body = self.render_template(template_name, *args, **kwargs) + + def render_pdf(self, template_name, *args, **kwargs): + html = self.render_template(template_name, *args, **kwargs) + + static_folder = Path(__file__).parent.parent / "static" + stylesheets = [ + static_folder / "app.css", + static_folder / "icomoon.css", + static_folder / "page.css", + ] + if "css" in kwargs: + stylesheets.append(static_folder / kwargs["css"]) + + return HTML(string=html).write_pdf(stylesheets=stylesheets) + + def pdf(self, template_name, *args, **kwargs): + self.body = self.render_pdf(template_name, *args, **kwargs) + mimetype = "application/pdf" + filename = kwargs.get("filename", "file.pdf") + self.headers["Content-Disposition"] = f'attachment; filename="{filename}"' + self.headers["Content-Type"] = f"{mimetype}; charset=utf-8" + + def xlsx(self, body, filename=f"{config.SITE_NAME}.xlsx"): + self.body = body + mimetype = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + self.headers["Content-Disposition"] = f'attachment; filename="{filename}"' + self.headers["Content-Type"] = f"{mimetype}; charset=utf-8" + + def redirect(self, location): + self.status = 302 + self.headers["Location"] = location + + redirect = property(None, redirect) + + def message(self, text, status="success"): + self.cookies.set("message", json.dumps((text, status))) + + +class Roll(BaseRoll): + Response = Response + + _context_func = [] + + def context(self): + context = {} + for func in self._context_func: + context.update(func()) + return context + + def register_context(self, func): + self._context_func.append(func) + + +def staff_only(view): + async def decorator(request, response, *args, **kwargs): + user = session.user.get(None) + if not user or not user.is_staff: + response.message("Désolé, c'est réservé au staff par ici", "warning") + response.redirect = request.headers.get("REFERRER", "/") + return + return await view(request, response, *args, **kwargs) + + return decorator + + +def configure(): + config.init() + + +env = Environment( + loader=PackageLoader("copanier", "templates"), + autoescape=select_autoescape(["copanier"]), +) + +env.filters["date"] = utils.date_filter +env.filters["time"] = utils.time_filter + +app = Roll() +traceback(app) + + +@app.listen("request") +async def attach_request(request, response): + response.request = request + + +@app.listen("request") +async def log_request(request, response): + if request.method == "POST": + message = { + "date": utils.utcnow().isoformat(), + "data": request.form, + "user": request.get("user"), + } + loggers.request_logger.info( + json.dumps(message, sort_keys=True, ensure_ascii=False) + ) + + +@app.listen("startup") +async def on_startup(): + configure() diff --git a/copanier/views/delivery.py b/copanier/views/delivery.py new file mode 100644 index 0000000..b8a70d8 --- /dev/null +++ b/copanier/views/delivery.py @@ -0,0 +1,379 @@ +from collections import defaultdict +from functools import partial +from roll import HttpError + +from debts.solver import order_balance, check_balance, reduce_balance + +from .core import app, staff_only, session, env +from ..models import Delivery, Person, Order, ProductOrder +from .. import utils, reports, emails, config + + +@app.listen("startup") +async def on_startup(): + Delivery.init_fs() + + +@app.route("/", methods=["GET"]) +async def home(request, response): + if not request["user"].group_id: + response.redirect = "/groupes" + return + response.html( + "delivery/list.html", + incoming=Delivery.incoming(), + former=Delivery.former(), + archives=list(Delivery.all(is_archived=True)), + ) + + +@app.route("/archives", methods=["GET"]) +async def view_archives(request, response): + response.html( + "delivery/archives.html", {"deliveries": Delivery.all(is_archived=True)} + ) + + +@app.route("/distribution/archive/{id}", methods=["GET"]) +async def view_archive(request, response, id): + delivery = Delivery.load(f"archive/{id}") + response.html("delivery/show.html", {"delivery": delivery}) + + +@app.route("/distribution/{id}/archiver", methods=["GET"]) +@staff_only +async def archive_delivery(request, response, id): + delivery = Delivery.load(id) + delivery.archive() + response.message("La distribution a été archivée") + response.redirect = f"/distribution/{delivery.id}" + + +@app.route("/distribution/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 distribution a été désarchivée") + response.redirect = f"/distribution/{delivery.id}" + + +@app.route("/distribution", methods=["GET"]) +async def new_delivery(request, response): + response.html("delivery/edit.html", delivery={}) + + +@app.route("/distribution", methods=["POST"]) +@staff_only +async def create_delivery(request, response): + form = request.form + data = {} + data["from_date"] = f"{form.get('date')} {form.get('from_time')}" + data["to_date"] = f"{form.get('date')} {form.get('to_time')}" + for name in Delivery.__dataclass_fields__.keys(): + if name in form: + data[name] = form.get(name) + delivery = Delivery(**data) + delivery.persist() + response.message("La distribution a bien été créée!") + response.redirect = f"/distribution/{delivery.id}" + + +@app.route("/distribution/{id}/{producer}/bon-de-commande.pdf", methods=["GET"]) +async def pdf_for_producer(request, response, id, producer): + delivery = Delivery.load(id) + response.pdf( + "product_list.html", + {"list_only": True, "delivery": delivery, "producers": [producer]}, + filename=utils.prefix(f"bon-de-commande-{producer}.pdf", delivery), + ) + + +@app.route("/distribution/{id}/gérer", methods=["GET"]) +async def delivery_toolbox(request, response, id): + delivery = Delivery.load(id) + response.html( + "delivery/toolbox.html", + { + "delivery": delivery, + "referents": [p.referent for p in delivery.producers.values()], + }, + ) + + +@app.route("/distribution/{id}/envoi-email-referentes", methods=["GET", "POST"]) +async def send_referent_emails(request, response, id): + delivery = Delivery.load(id) + if request.method == "POST": + email_body = request.form.get("email_body") + email_subject = request.form.get("email_subject") + sent_mails = 0 + for referent in delivery.get_referents(): + producers = delivery.get_producers_for_referent(referent) + attachments = [] + for producer in producers: + if delivery.producers[producer].has_active_products(delivery): + pdf_file = response.render_pdf( + "list_products.html", + { + "list_only": True, + "delivery": delivery, + "producers": [producer], + }, + ) + + attachments.append( + ( + utils.prefix(f"{producer}.pdf", delivery), + pdf_file, + "application/pdf", + ) + ) + + if attachments: + sent_mails = sent_mails + 1 + emails.send( + referent, + email_subject, + email_body, + copy=delivery.contact, + attachments=attachments, + ) + response.message(f"Un mail à été envoyé aux {sent_mails} référent⋅e⋅s") + response.redirect = f"/distribution/{id}/gérer" + + response.html("delivery/referent_email.html", {"delivery": delivery}) + + +@app.route("/distribution/{id}/exporter", methods=["GET"]) +async def export_products(request, response, id): + delivery = Delivery.load(id) + response.xlsx(reports.products(delivery)) + + +@app.route("/distribution/{id}/edit", methods=["GET"]) +@staff_only +async def edit_delivery(request, response, id): + delivery = Delivery.load(id) + response.html("delivery/edit.html", {"delivery": delivery}) + + +@app.route("/distribution/{id}/edit", methods=["POST"]) +@staff_only +async def post_delivery(request, response, id): + delivery = Delivery.load(id) + form = request.form + delivery.from_date = f"{form.get('date')} {form.get('from_time')}" + delivery.to_date = f"{form.get('date')} {form.get('to_time')}" + for name in Delivery.__dataclass_fields__.keys(): + if name in form: + setattr(delivery, name, form.get(name)) + delivery.persist() + response.message("La distribution a bien été mise à jour!") + response.redirect = f"/distribution/{delivery.id}" + + +@app.route("/distribution/{id}", methods=["GET"]) +async def view_delivery(request, response, id): + delivery = Delivery.load(id) + response.html("delivery/show.html", {"delivery": delivery}) + + +@app.route("/distribution/{id}/commander", methods=["POST", "GET"]) +async def place_order(request, response, id): + delivery = Delivery.load(id) + # email = request.query.get("email", None) + user = session.user.get(None) + orderer = request.query.get("orderer", None) + if orderer: + orderer = Person(email=orderer, group_id=orderer) + + delivery_url = f"/distribution/{delivery.id}" + if not orderer and user: + orderer = user + + if not orderer: + response.message("Impossible de comprendre pour qui passer commande…", "error") + response.redirect = delivery_url + return + + if request.method == "POST": + # When the delivery is closed, only staff can access. + if delivery.status == delivery.CLOSED and not (user and user.is_staff): + response.message("La distribution est fermée", "error") + response.redirect = delivery_url + return + + form = request.form + order = Order(phone_number=form.get("phone_number", "")) + for product in delivery.products: + try: + wanted = form.int(f"wanted:{product.ref}", 0) + except HttpError: + continue + try: + 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: + delivery.orders = {} + + if not order.products: + if orderer.id in delivery.orders: + del delivery.orders[orderer.id] + delivery.persist() + response.message("La commande est vide.", status="warning") + response.redirect = delivery_url + return + delivery.orders[orderer.id] = order + delivery.persist() + + if user and orderer.id == user.id: + # Send the emails to everyone in the group. + groups = request["groups"].groups + if orderer.group_id in groups.keys(): + for email in groups[orderer.group_id].members: + emails.send_order( + request, + env, + person=Person(email=email), + delivery=delivery, + order=order, + ) + else: + emails.send_order( + request, + env, + person=Person(email=orderer.email), + delivery=delivery, + order=order, + ) + response.message( + f"La commande pour « {orderer.name} » a bien été prise en compte, " + "on a envoyé un récap par email 😘" + ) + response.redirect = f"/distribution/{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( + "delivery/place_order.html", + delivery=delivery, + person=orderer, + order=order, + force_adjustment=force_adjustment, + ) + + +@app.route("/distribution/{id}/émargement", methods=["GET"]) +async def signing_sheet(request, response, id): + delivery = Delivery.load(id) + response.pdf( + "delivery/signing_sheet.html", + {"delivery": delivery}, + css="signing-sheet.css", + filename=utils.prefix("commandes-par-groupe.pdf", delivery), + ) + + +@app.route("/distribution/{id}/rapport-complet.xlsx", methods=["GET"]) +async def xls_full_report(request, response, id): + delivery = Delivery.load(id) + date = delivery.to_date.strftime("%Y-%m-%d") + response.xlsx( + reports.full(delivery), + filename=f"{config.SITE_NAME}-{date}-rapport-complet.xlsx", + ) + + +@app.route("/distribution/{id}/ajuster/{ref}", methods=["GET", "POST"]) +@staff_only +async def adjust_product(request, response, id, ref): + delivery = Delivery.load(id) + delivery_url = f"/distribution/{delivery.id}" + product = None + 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}) + + +@app.route("/distribution/{id}/solde", methods=["GET"]) +@app.route("/distribution/{id}/solde.pdf", methods=["GET"]) +@staff_only +async def delivery_balance(request, response, id): + delivery = Delivery.load(id) + groups = request["groups"] + + balance = [] + for group_id, order in delivery.orders.items(): + balance.append((group_id, order.total(delivery.products) * -1)) + + producer_groups = {} + + for producer in delivery.producers.values(): + group = groups.get_user_group(producer.referent) + # When a group contains multiple producer contacts, + # the first one is elected to receive the money, + # and all the other ones are separated in the table. + group_id = None + if hasattr(group, "id"): + if ( + group.id not in producer_groups + or producer_groups[group.id] == producer.referent_name + ): + producer_groups[group.id] = producer.referent_name + group_id = group.id + if not group_id: + group_id = producer.referent_name + + amount = delivery.total_for_producer(producer.id) + if amount: + balance.append((group_id, amount)) + + debiters, crediters = order_balance(balance) + check_balance(debiters, crediters) + results = reduce_balance(debiters[:], crediters[:]) + + results_dict = defaultdict(partial(defaultdict, float)) + + for debiter, amount, crediter in results: + results_dict[debiter][crediter] = amount + + template_name = "delivery/balance.html" + template_args = { + "delivery": delivery, + "debiters": debiters, + "crediters": crediters, + "results": results_dict, + "debiters_groups": groups.groups, + "crediters_groups": producer_groups, + } + + if request.url.endswith(b".pdf"): + response.pdf( + template_name, + template_args, + filename=utils.prefix("répartition-des-chèques.pdf", delivery), + ) + else: + response.html(template_name, template_args) diff --git a/copanier/views/groups.py b/copanier/views/groups.py new file mode 100644 index 0000000..b85c5bc --- /dev/null +++ b/copanier/views/groups.py @@ -0,0 +1,72 @@ +from slugify import slugify +from ..models import Groups, Group +from .core import app, session + + +@app.listen("startup") +async def on_startup(): + Groups.init_fs() + + +@app.route("/groupes", methods=["GET"]) +async def handle_groups(request, response): + response.html("groups/list.html", {"groups": request["groups"]}) + + +@app.route("/groupes/{id}/rejoindre", method=["GET"]) +async def join_group(request, response, id): + user = session.user.get(None) + group = request["groups"].add_user(user.email, id) + request["groups"].persist() + redirect = "/" if not request["user"].group_id else "/groupes" + + response.message(f"Vous avez bien rejoint le groupe « {group.name} »") + response.redirect = redirect + + +@app.route("/groupes/créer", methods=["GET", "POST"]) +async def create_group(request, response): + group = None + if request.method == "POST": + form = request.form + members = [] + if form.get("members"): + members = [m.strip() for m in form.get("members").split(",")] + + if not request["user"].group_id and request["user"].email not in members: + members.append(request["user"].email) + + group = Group.create( + id=slugify(form.get("name")), name=form.get("name"), members=members + ) + request["groups"].add_group(group) + request["groups"].persist() + response.message(f"Le groupe {group.name} à bien été créé") + response.redirect = "/" + response.html("groups/edit.html", group=group) + + +@app.route("/groupes/{id}/éditer", methods=["GET", "POST"]) +async def edit_group(request, response, id): + assert id in request["groups"].groups, "Impossible de trouver le groupe" + group = request["groups"].groups[id] + if request.method == "POST": + form = request.form + members = [] + if form.get("members"): + members = [m.strip() for m in form.get("members").split(",")] + group.members = members + group.name = form.get("name") + request["groups"].groups[id] = group + request["groups"].persist() + response.redirect = "/groupes" + response.html("groups/edit.html", group=group) + + +@app.route("/groupes/{id}/supprimer", methods=["GET"]) +async def delete_group(request, response, id): + assert id in request["groups"].groups, "Impossible de trouver le groupe" + deleted = request["groups"].groups.pop(id) + request["groups"].persist() + response.message(f"Le groupe {deleted.name} à bien été supprimé") + response.redirect = "/groupes" diff --git a/copanier/views/login.py b/copanier/views/login.py new file mode 100644 index 0000000..8725136 --- /dev/null +++ b/copanier/views/login.py @@ -0,0 +1,77 @@ +from .core import app, session, env + +from ..models import Groups, Person +from .. import utils, emails, config + + +@app.listen("request") +async def auth_required(request, response): + # Should be handled Roll side? + # In dev mode, we serve the static, but we don't have yet a way to mark static + # route as unprotected. + if request.path.startswith("/static/"): + return + if request.route.payload and not request.route.payload.get("unprotected"): + token = request.cookies.get("token") + email = None + if token: + decoded = utils.read_token(token) + email = decoded.get("sub") + if not email: + response.redirect = f"/sésame?next={request.path}" + return response + + groups = Groups.load() + request["groups"] = groups + + group = groups.get_user_group(email) + user_info = {"email": email} + if group: + user_info.update(dict(group_id=group.id, group_name=group.name)) + user = Person(**user_info) + request["user"] = user + session.user.set(user) + + +@app.route("/sésame", methods=["GET"], unprotected=True) +async def sesame(request, response): + response.html("sesame.html") + + +@app.route("/sésame", methods=["POST"], unprotected=True) +async def send_sesame(request, response): + email = request.form.get("email").lower() + token = utils.create_token(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 = "/" + + +@app.route("/sésame/{token}", methods=["GET"], unprotected=True) +async def set_sesame(request, response, token): + decoded = utils.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, httponly=True, max_age=60 * 60 * 24 * 7 + ) + response.redirect = "/" + + +@app.route("/déconnexion", methods=["GET"]) +async def logout(request, response): + response.cookies.set(name="token", value="", httponly=True) + response.redirect = "/" diff --git a/copanier/views/products.py b/copanier/views/products.py new file mode 100644 index 0000000..92aaabd --- /dev/null +++ b/copanier/views/products.py @@ -0,0 +1,186 @@ +from slugify import slugify +from .core import app +from ..models import Delivery, Product, Producer +from .. import utils + + +@app.route("/distribution/{id}/produits") +@app.route("/distribution/{id}/produits.pdf") +async def list_products(request, response, id): + delivery = Delivery.load(id) + template_name = "products/list.html" + template_params = { + "edit_mode": True, + "list_only": True, + "delivery": delivery, + "referent": request.query.get("referent", None), + } + + if request.url.endswith(b".pdf"): + template_params["edit_mode"] = False + response.pdf( + template_name, + template_params, + filename=utils.prefix("producteurices.pdf", delivery), + ) + else: + response.html(template_name, template_params) + + +@app.route("/distribution/{delivery_id}/{producer_id}/éditer", methods=["GET", "POST"]) +async def edit_producer(request, response, delivery_id, producer_id): + delivery = Delivery.load(delivery_id) + producer = delivery.producers.get(producer_id) + if request.method == "POST": + form = request.form + producer.referent = form.get("referent") + producer.referent_tel = form.get("referent_tel") + producer.referent_name = form.get("referent_name") + producer.description = form.get("description") + producer.contact = form.get("contact") + delivery.producers[producer_id] = producer + delivery.persist() + + response.html( + "products/edit_producer.html", + { + "delivery": delivery, + "producer": producer, + "products": delivery.get_products_by(producer.id), + }, + ) + + +@app.route("/distribution/{delivery_id}/{producer_id}/frais", methods=["GET", "POST"]) +async def handle_shipping_fees(request, response, delivery_id, producer_id): + delivery = Delivery.load(delivery_id) + producer = delivery.producers.get(producer_id) + if request.method == "POST": + form = request.form + producer.referent = form.get("referent") + producer.referent_tel = form.get("referent_tel") + producer.referent_name = form.get("referent_name") + producer.description = form.get("description") + producer.contact = form.get("contact") + delivery.producers[producer_id] = producer + delivery.persist() + + response.html( + "products/shipping_fees.html", {"delivery": delivery, "producer": producer} + ) + + +@app.route("/producteurices/créer/{delivery_id}", methods=["GET", "POST"]) +async def create_producer(request, response, delivery_id): + delivery = Delivery.load(delivery_id) + producer = None + if request.method == "POST": + form = request.form + name = form.get("name") + producer_id = slugify(name) + + producer = Producer(name=name, id=producer_id) + producer.referent = form.get("referent") + producer.referent_tel = form.get("referent_tel") + producer.referent_name = form.get("referent_name") + producer.description = form.get("description") + producer.contact = form.get("contact") + + delivery.producers[producer_id] = producer + delivery.persist() + response.redirect = f"/distribution/{delivery.id}/produits" + + response.html( + "products/edit_producer.html", + {"delivery": delivery, "producer": producer or None}, + ) + + +@app.route( + "/distribution/{delivery_id}/{producer_id}/produit/{product_ref}/éditer", + methods=["GET", "POST"], +) +async def edit_product(request, response, delivery_id, producer_id, product_ref): + delivery = Delivery.load(delivery_id) + product = delivery.get_product(product_ref) + producer = delivery.producers.get(producer_id) + + if request.method == "POST": + form = request.form + product.name = form.get("name") + product.price = form.float("price") + product.unit = form.get("unit") + product.description = form.get("description") + product.url = form.get("url", None) + if form.get("packing"): + product.packing = form.int("packing") + else: + product.packing = None + if "rupture" in form: + product.rupture = form.get("rupture") + else: + product.rupture = None + delivery.persist() + response.message("Le produit à bien été modifié") + response.redirect = f"/distribution/{delivery_id}/{producer_id}/éditer" + return + + response.html( + "products/edit.html", + {"delivery": delivery, "product": product, "producer": producer}, + ) + + +@app.route( + "/distribution/{delivery_id}/{producer_id}/{product_ref}/supprimer", methods=["GET"] +) +async def delete_product(request, response, delivery_id, producer_id, product_ref): + delivery = Delivery.load(delivery_id) + product = delivery.delete_product(product_ref) + delivery.persist() + response.message(f"Le produit « { product.name } » à bien été supprimé.") + response.redirect = f"/distribution/{delivery_id}/{producer_id}/éditer" + + +@app.route( + "/distribution/{delivery_id}/{producer_id}/ajouter-produit", methods=["GET", "POST"] +) +async def create_product(request, response, delivery_id, producer_id): + delivery = Delivery.load(delivery_id) + product = Product(name="", ref="", price=0) + producer = delivery.producers.get(producer_id) + + if request.method == "POST": + product.producer = producer_id + form = request.form + product.update_from_form(form) + product.ref = slugify(f"{producer_id}-{product.name}-{product.unit}") + + delivery.products.append(product) + delivery.persist() + response.message("Le produit à bien été créé") + response.redirect = ( + f"/distribution/{delivery_id}/producteurice/{producer_id}/éditer" + ) + return + + response.html( + "products/edit.html", + {"delivery": delivery, "producer": producer, "product": product}, + ) + + +@app.route("/distribution/{id}/copier", methods=["GET"]) +async def copy_products(request, response, id): + deliveries = Delivery.all() + response.html("delivery/copy.html", {"deliveries": deliveries}) + + +@app.route("/distribution/{id}/copier", methods=["POST"]) +async def copy_products(request, response, id): + delivery = Delivery.load(id) + to_copy = delivery.load(request.form.get("to_copy")) + delivery.producers = to_copy.producers + delivery.products = to_copy.products + delivery.persist() + response.redirect = f"/distribution/{id}/producteurices" diff --git a/copanier/session.py b/copanier/views/session.py similarity index 100% rename from copanier/session.py rename to copanier/views/session.py diff --git a/docs/usage.fr.md b/docs/usage.fr.md index cca3800..144e8c3 100644 --- a/docs/usage.fr.md +++ b/docs/usage.fr.md @@ -7,7 +7,7 @@ Vous êtes la personne référente pour une distribution, et vous vous demandez ### Création d'une nouvelle distribution La première chose à faire, c'est de créer une nouvelle distribution. -Dans l'interface, en bas à gauche il y a un bouton « Nouvelle livraison ». +Dans l'interface, en bas à gauche il y a un bouton « Nouvelle distribution ». Il vous sera demandé quelques informations, puis vous pouvez valider. @@ -16,15 +16,15 @@ Il vous sera demandé quelques informations, puis vous pouvez valider. Par défaut, une nouvelle distribution est vide. Il faut donc ajouter les produits. Le plus simple à cette étape est de le faire avec un tableur. -Soit on télécharge un tableur vide et on le remplit, soit on récupère les infos d'une autre livraison. +Soit on télécharge un tableur vide et on le remplit, soit on récupère les infos d'une autre distribution. -### Récupérer les infos d'une autre livraison +### Récupérer les infos d'une autre distribution -Il faut aller dans la livraison en question, puis, en bas, faire « Télécharger les infos produits » +Il faut aller dans la distribution en question, puis, en bas, faire « Télécharger les infos produits » ### Importer des produits -Une fois la nouvelle livraison créée, il est possible d'importer les produits. Quand on clique dessus, il y a un bouton qui permet de le faire, on selectionne le tableur avec les infos produits et on l'importe. +Une fois la nouvelle distribution créée, il est possible d'importer les produits. Quand on clique dessus, il y a un bouton qui permet de le faire, on selectionne le tableur avec les infos produits et on l'importe. ## Mise à jour des prix @@ -43,7 +43,7 @@ Avant et pendant la distrib : Gérer les groupes / colocs Une fois les commandes passées (après le dimanche 26 janvier) - Télécharger les bons de livraison + Télécharger les bons de distribution Télécharger le tableau des commandes Envoyer les infos de commande aux référent⋅e⋅s diff --git a/tests/test_views.py b/tests/test_views.py index 45f3876..d6d529d 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -40,7 +40,7 @@ async def test_create_delivery(client): "order_before": "2019-02-12", "contact": "lucky@you.me", } - resp = await client.post("/livraison", body=body) + resp = await client.post("/distribution", body=body) assert resp.status == 302 assert len(list(Delivery.all())) == 1 delivery = list(Delivery.all())[0] @@ -56,7 +56,7 @@ async def test_create_delivery(client): async def test_place_order_with_session(client, delivery): delivery.persist() body = {"wanted:123": "3"} - resp = await client.post(f"/livraison/{delivery.id}/commander", body=body) + resp = await client.post(f"/distribution/{delivery.id}/commander", body=body) assert resp.status == 302 delivery = Delivery.load(id=delivery.id) assert delivery.orders["foo@bar.org"] @@ -65,7 +65,7 @@ async def test_place_order_with_session(client, delivery): async def test_place_empty_order(client, delivery): delivery.persist() - resp = await client.post(f"/livraison/{delivery.id}/commander", body={}) + resp = await client.post(f"/distribution/{delivery.id}/commander", body={}) assert resp.status == 302 delivery = Delivery.load(id=delivery.id) assert not delivery.orders @@ -74,7 +74,7 @@ async def test_place_empty_order(client, delivery): async def test_place_empty_order_should_delete_previous(client, delivery): delivery.orders["foo@bar.org"] = Order(products={"123": ProductOrder(wanted=1)}) delivery.persist() - resp = await client.post(f"/livraison/{delivery.id}/commander", body={}) + resp = await client.post(f"/distribution/{delivery.id}/commander", body={}) assert resp.status == 302 delivery = Delivery.load(delivery.id) assert not delivery.orders @@ -83,7 +83,7 @@ async def test_place_empty_order_should_delete_previous(client, delivery): async def test_place_order_with_empty_string(client, delivery): delivery.persist() body = {"wanted:123": ""} # User deleted the field value. - resp = await client.post(f"/livraison/{delivery.id}/commander", body=body) + resp = await client.post(f"/distribution/{delivery.id}/commander", body=body) assert resp.status == 302 delivery = Delivery.load(id=delivery.id) assert not delivery.orders @@ -94,7 +94,7 @@ async def test_get_place_order_with_closed_delivery(client, delivery, monkeypatc 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") + resp = await client.get(f"/distribution/{delivery.id}/commander") doc = pq(resp.body) assert doc('[name="wanted:123"]').attr("readonly") assert not doc('[name="adjustment:123"]') @@ -102,7 +102,7 @@ async def test_get_place_order_with_closed_delivery(client, delivery, monkeypatc async def test_get_place_order_with_adjustment_status(client, delivery): - resp = await client.get(f"/livraison/{delivery.id}/commander") + resp = await client.get(f"/distribution/{delivery.id}/commander") doc = pq(resp.body) assert not doc('[name="wanted:123"]').attr("readonly") assert not doc('[name="adjustment:123"]') @@ -115,7 +115,7 @@ async def test_get_place_order_with_adjustment_status(client, delivery): ) delivery.persist() assert delivery.status == delivery.ADJUSTMENT - resp = await client.get(f"/livraison/{delivery.id}/commander") + resp = await client.get(f"/distribution/{delivery.id}/commander") doc = pq(resp.body) assert doc('[name="wanted:123"]').attr("readonly") assert doc('[name="adjustment:123"]') @@ -138,7 +138,7 @@ async def test_get_place_order_with_closed_delivery_but_adjustments(client, deli ) delivery.persist() assert delivery.status == delivery.CLOSED - resp = await client.get(f"/livraison/{delivery.id}/commander") + resp = await client.get(f"/distribution/{delivery.id}/commander") doc = pq(resp.body) assert doc('[name="wanted:123"]').attr("readonly") assert doc('[name="adjustment:123"]') @@ -149,11 +149,11 @@ async def test_get_place_order_with_closed_delivery_but_force(client, delivery): 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") + resp = await client.get(f"/distribution/{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") + resp = await client.get(f"/distribution/{delivery.id}/commander?adjust") doc = pq(resp.body) assert doc('[name="wanted:123"]').attr("readonly") is not None assert doc('[name="adjustment:123"]') @@ -164,7 +164,7 @@ async def test_cannot_place_order_on_closed_delivery(client, delivery, monkeypat 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) + resp = await client.post(f"/distribution/{delivery.id}/commander", body=body) assert resp.status == 302 delivery = Delivery.load(id=delivery.id) assert not delivery.orders @@ -178,7 +178,7 @@ async def test_get_adjust_product(client, delivery): ) delivery.persist() assert delivery.status == delivery.ADJUSTMENT - resp = await client.get(f"/livraison/{delivery.id}/ajuster/123") + resp = await client.get(f"/distribution/{delivery.id}/ajuster/123") doc = pq(resp.body) assert doc('[name="foo@bar.org"]') assert doc('[name="foo@bar.org"]').attr("value") == "1" @@ -191,7 +191,7 @@ async def test_post_adjust_product(client, delivery): delivery.persist() assert delivery.status == delivery.ADJUSTMENT body = {"foo@bar.org": "1"} - resp = await client.post(f"/livraison/{delivery.id}/ajuster/123", body=body) + resp = await client.post(f"/distribution/{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 @@ -204,10 +204,10 @@ async def test_only_staff_can_adjust_product(client, delivery, monkeypatch): 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") + resp = await client.get(f"/distribution/{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) + resp = await client.post(f"/distribution/{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 @@ -216,7 +216,7 @@ async def test_only_staff_can_adjust_product(client, delivery, monkeypatch): async def test_export_products(client, delivery): delivery.persist() - resp = await client.get(f"/livraison/{delivery.id}/exporter") + resp = await client.get(f"/distribution/{delivery.id}/exporter") wb = load_workbook(filename=BytesIO(resp.body)) assert list(wb.active.values) == [ ("name", "ref", "price", "unit", "description", "url", "img", "packing", "producer"),