Reorganize the files,

I've split everything in different concepts (groups, login, products and delivery) so that it's easier to find what we're looking for (the __init__.py file was really too big).
This commit is contained in:
Alexis MÃtaireau 2020-03-11 00:05:06 +01:00
parent 11f6d7054b
commit b451a82c7b
57 changed files with 1336 additions and 1338 deletions

32
TODO
View file

@ -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 dune 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

View file

@ -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()

View file

@ -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

View file

@ -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:

View file

@ -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;
}

1
copanier/static/flash.min.css vendored Normal file
View file

@ -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)}

1
copanier/static/js/flash.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -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;
}

View file

@ -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;
}

View file

@ -9,6 +9,7 @@
<link rel="stylesheet" type="text/css" href="/static/icomoon.css">
<link rel="stylesheet" type="text/css" href="/static/purecss.css">
<link rel="stylesheet" type="text/css" href="/static/side-menu.css">
<link rel="stylesheet" type="text/css" href="/static/flash.min.css">
{% block head %}
{% endblock head %}
@ -20,77 +21,52 @@
<a href="#menu" id="menuLink" class="menu-link">
<span></span>
</a>
{% if request.user %}
<div id="menu">
<div class="pure-menu">
<h1>{{ config.SITE_NAME }}</h1>
<h1 id="sitename">{{ config.SITE_NAME }}</h1>
<ul class="pure-menu-list">
{% if delivery %}
<li class="pure-menu-heading">
Cette distrib
</li>
<li class="pure-menu-item">
<a href="/livraison/{{ delivery.id }}/commander">Commander</a>
<a href="/"><i class="icon-happy"></i>&nbsp;Voir les distrib</a>
</li>
{% if request.user and request.user.is_staff %}
<li class="pure-menu-item">
<a href="/livraison/{{ delivery.id }}/émargement">Fiches de commandes par coloc</a>
</li>
<li class="pure-menu-item">
<a href="/livraison/{{ delivery.id }}/producteurices.pdf">Fiches commandes product⋅eurs⋅ices</a>
</li>
<li class="pure-menu-item">
<a href="/livraison/{{ delivery.id }}/solde">Paiements</a>
</li>
{% if request.user.is_staff %}
<li class="pure-menu-item">
<a href="/livraison/{{ delivery.id }}/gérer">Boîte à outils</a>
</li>
<li class="pure-menu-item">
<a href="/livraison/{{ delivery.id }}/edit">Modifier les infos</a>
</li>
{% endif %}
{% endif %}
<li class="pure-menu-heading">
Actions
</li>
<li class="pure-menu-item">
<a href="/"><i class="icon-happy"></i>&nbsp;Voir les distributions</a>
</li>
{% if request.user.is_staff %}
<li class="pure-menu-item">
<a href="/livraison"><i class="icon-hotairballoon"></i>&nbsp;Nouvelle distribution</a>
<a href="/distribution"><i class="icon-hotairballoon"></i>&nbsp;Nouvelle distribution</a>
</li>
{% endif %}
<li class="pure-menu-item">
<a class="pure-menu-link" href="/groupes"><i class="icon-globe"></i>&nbsp;Gérer les groupes</a>
</li>
<li class="pure-menu-item">
<a href="#" class="pure-menu-link"><i class="icon-lock"></i>&nbsp;Se déconnecter</a>
<a href="/déconnexion" class="pure-menu-link"><i class="icon-lock"></i>&nbsp;Se déconnecter</a>
</li>
</ul>
</div>
</div>
{% else %}
<h1 id="sitename">{{ config.SITE_NAME }}</h1>
{% endif %}
<div class="toplink">
{% block toplink %}{% endblock %}
</div>
<div id="main">
{% if message %}
<section class="notification {{ message[1] }}"><i class="icon-megaphone"></i> {{ message[0] }}</section>
{% endif %}
<div class="content">
{% block body %}
{% endblock body %}
{% block body %}
{% endblock body %}
</div>
</div>
</div>
<header>
<section class="menu">
</section>
</header>
<main>
</main>
<script src="/static/js/flash.min.js"></script>
{% if message %}
<script>
new window.FlashMessage("{{ message[0] }}", "{{ message[1] }}", {
progress: true,
interactive: true,
timeout: 8000,
});
</script>
{% endif %}
</body>
</html>

View file

@ -1,76 +0,0 @@
{% extends "base.html" %}
{% block body %}
<div class="header">
<h1>{{ 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 %})
</h1>
<h4>
Distribution <i class="icon-clock"></i> {{ delivery.from_date|date|capitalize }}, {{ delivery.from_date|time }} - {{ delivery.to_date|time }}, à <i class="icon-streetsign"></i> {{ delivery.where }}.
</h4>
<ul class="actions">
{% include "includes/order_button.html" %}
{% if request['user'].email == delivery.contact and delivery.status > delivery.EMPTY %}
<li><a class="button" href="/livraison/{{ delivery.id }}/gérer">Voir la boîte à outils</a></li>
{% elif request['user'].email in delivery.get_referents() %}
<li><a class="button" href="/livraison/{{ delivery.id }}/producteurices?referent={{request['user'].email}}">Gérer les produits dont vous vous occupez</a></li>
{% endif %}
{% if not delivery.is_archived and delivery.status == delivery.CLOSED %}
<li>
<a href="/livraison/{{ delivery.id }}/producteurices.pdf"><i class="icon-ribbon"></i> Bon de commande</a>
</li>
<li>
<a href="/livraison/{{ delivery.id }}/rapport-complet.xlsx"><i class="icon-grid"></i> Rapport complet</a>
</li>
<li>
<a href="/livraison/{{ delivery.id }}/émargement" target="_blank"><i class="icon-document"></i> Résumé de commandes</a>
</li>
{% endif %}
{% if request.user and request.user.is_staff %}
{% if delivery.is_archived %}
<li>
<a href="/livraison/{{ delivery.id }}/désarchiver" class="button danger"><i class="icon-hazardous"></i>&nbsp;Désarchiver</a>
</li>
{% endif %}
<li>
<a href="/livraison/{{ delivery.id }}/exporter"><i class="icon-layers"></i> Télécharger les infos produits</a>
</li>
<li>
<a href="/livraison/{{ delivery.id }}/edit"><i class="icon-adjustments"></i> Modifier la livraison</a>
</li>
<li>
<a href="/livraison/{{ delivery.id }}/solde"><i class="icon-wallet"></i> Répartition des paiements</a>
</li>
{% endif %}
</ul>
</div>
<ul class="progressbar">
<li {% if delivery.status == delivery.EMPTY %}class="active"{% endif %}>Mise à jour des prix</li>
<li {% if delivery.status == delivery.OPEN %}class="active"{% endif %}>Ouverture des commandes</li>
<li {% if delivery.status == delivery.ADJUSTMENT %}class="active"{% endif %}>Ajustements</li>
<li {% if delivery.can_generate_reports %}class="active"{% endif %}>Achats par les référent⋅e⋅s</li>
<li>Distribution</li>
</ul>
<article class="delivery">
{% if delivery.has_products %}
{% include "includes/delivery_table.html" %}
{% else %}
<div class="placeholder">
<h2>Aucun produit n'est encore défini pour cette livraison.</h2>
{% if request.user and request.user.is_staff %}
<div class="inline-text">
Pour rajouter des produits, deux options:
<ol>
<li><a href="/livraison/{{ delivery.id }}/exporter"><i class="icon-layers"></i>Télécharger le tableur avec les produits</a>, le modifier localement sur votre machine puis &nbsp;<div class="inline-text">{% with unique_id="import-products" %}{% include "includes/modal_import_products.html" %}{% endwith %}</div></li>
<li>Ou bien ajouter les product⋅eurs⋅rices un⋅e par un⋅e dans l'interface</li>
</ol>
</div>
{% endif %}
</div>
{% endif %}
</article>
<hr>
<ul class="toolbox">
</ul>
{% endblock body %}

View file

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

View file

@ -1,5 +1,5 @@
{% extends "base.html" %}
{% block title %}<h1><i class="icon-wallet"></i>&nbsp; Répartition des paiements <a id="download" class="button" href="/livraison/{{ delivery.id }}/solde.pdf">Télécharger</a></h1>{% endblock %}
{% block title %}<h1><i class="icon-wallet"></i>&nbsp; Répartition des paiements <a id="download" class="button" href="/distribution/{{ delivery.id }}/solde.pdf">Télécharger</a></h1>{% endblock %}
{% block body %}
<p class="info">{{ delivery.name }} du {{ delivery.to_date | date }}.</p>
<p class="info">Les personnes indiquées avec un <code>*</code> à côté de leur nom sont celles qui ont payé cette commande pour leur groupe.</p>

View file

@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block body %}
<div class="header">
<h1>Copier les produits d'une autre distribution</h1>
</div>
<form method="post">
<label>
<p>Distribution à copier</p>
<select name="to_copy">
{% for delivery in deliveries %}
<option value="{{ delivery.id }}">{{ delivery.name }} / {{ delivery.to_date | date }}</option>
{% endfor %}
</select>
</label>
<div>
<input type="submit" name="submit" value="Valider" class="primary">
</div>
</form>
<hr>
{% endblock body %}

View file

@ -1,20 +1,31 @@
{% extends "base.html" %}
{% block toplink %}{% if delivery.id %}<a href="/distribution/{{ delivery.id }}">↶ Retourner à la distribution</a>{% endif %}{% endblock %}
{% block body %}
<div class="header">
{% if delivery.id %}
<h1>Modifier la livraison</h1>
<h1>Modifier la distribution</h1>
<div class="pure-menu pure-menu-horizontal">
<ul class="pure-menu-list">
{% if delivery.status == delivery.CLOSED %}
<li class="pure-menu-item">
<a class="pure-menu-link danger" href="/distribution/{{ delivery.id }}/archiver"><i class="icon-layers"></i>&nbsp;Archiver</a>
</li>
{% endif %}
<li class="pure-menu-item">
<a class="pure-menu-link" href="/distribution/{{ delivery.id }}/produits"><i class="icon-pencil"></i> Gérer les produits / product⋅eur⋅rice⋅s</a>
</li>
</ul>
</div>
{% else %}
<h1>Nouvelle livraison</h1>
<h1>Nouvelle distribution</h1>
{% endif %}
</div>
<form method="post">
<label>
<p>Nom de la livraison</p>
<p>Nom de la distribution</p>
<input type="text" name="name" value="{{ delivery.name or '' }}">
</label>
<label>
<h5>Description des produits</h5>
<input type="text" name="description" value="{{ delivery.description or '' }}">
</label>
<label>
<p>Référent⋅e</p>
<input type="email" name="contact" value="{{ delivery.contact or request.user.email }}" required>
@ -24,7 +35,7 @@
<input type="text" name="where" value="{{ delivery.where or '' }}" required>
</label>
<label>
<p>Date de livraison</p>
<p>Date de distribution</p>
<input type="date" name="date" value="{{ delivery.from_date.date() if delivery.from_date else '' }}" required> de <input type="time" name="from_time" value="{{ delivery.from_date.time() if delivery.from_date else '' }}" required> à <input type="time" name="to_time" value="{{ delivery.to_date.time() if delivery.to_date else '' }}" required>
</label>
<label>
@ -35,30 +46,9 @@
<p>Instructions particulières</p>
<input type="text" name="instructions" value="{{ delivery.instructions or '' }}">
</label>
<label>
<p>URL externe d'information</p>
<input type="url" name="infos_url" value="{{ delivery.infos_url or '' }}">
</label>
<div>
<input type="submit" name="submit" value="Valider" class="primary">
</div>
</form>
<hr>
{% if delivery %}
<ul class="toolbox">
{% if delivery.status == delivery.CLOSED %}
<li>
<a href="/livraison/{{ delivery.id }}/archiver" class="button danger"><i class="icon-layers"></i>&nbsp;Archiver</a>
</li>
{% endif %}
<li>
{% with unique_id="import-products" %}
{% include "includes/modal_import_products.html" %}
{% endwith %}
</li>
<li>
<a href="/livraison/{{ delivery.id }}/producteurices"><i class="icon-pencil"></i> Gérer les produits / product⋅eur⋅rice⋅s</a>
</li>
</ul>
{% endif %}
{% endblock body %}

View file

@ -0,0 +1,33 @@
{% extends "base.html" %}
{% block body %}
<div class="header">
<h1>Distributions</h1>
<div class="pure-menu pure-menu-horizontal">
<ul class="pure-menu-list">
<li class="pure-menu-item">
<a class="pure-menu-link" href="/distribution"><i class="icon-hotairballoon"></i>&nbsp;Créer une nouvelle distribution</a>&nbsp;
</li>
<li class="pure-menu-item">
<a class="pure-menu-link" href="/archives"><i class="icon-telescope"></i>&nbsp;Voir les distributions archivées</a>&nbsp;
</li>
</ul>
</div>
</div>
{% with deliveries=incoming %}
{% include "includes/delivery_list.html" %}
{% endwith %}
{% if former %}
<div class="header">
<h1>Distributions passées</h1>
</div>
{% with deliveries=former %}
{% include "includes/delivery_small_list.html" %}
{% endwith %}
{% endif %}
{% if archives %}
<a href="/archives">Voir les distributions archivées</a>
<hr>
{% endif %}
{% endblock body %}

View file

@ -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 %}
<article class="order">
<h3>
<a href="/livraison/{{ delivery.id }}">{{ delivery.name }}</a>
<a href="/distribution/{{ delivery.id }}">{{ delivery.name }}</a>
— Commande pour {{ group_name }}
{% if order.phone_number %}
(<a href="tel:{{ order.phone_number }}">{{ order.phone_number }}</a>)
@ -13,8 +13,8 @@
{% include "includes/delivery_head.html" %}
<form method="post">
{% for producer in delivery.producers %}
<h2>{{ producer }}</h2>
{% for producer in delivery.producers.values() %}
<h2>{{ producer.name }}</h2>
<table class="order">
<thead>
<tr>
@ -30,7 +30,7 @@
</tr>
</thead>
<tbody>
{% for product in delivery.get_products_by(producer) %}
{% for product in delivery.get_products_by(producer.id) %}
<tr>
<th class="product {% if product.rupture %}rupture{% endif %}">{{ product }} {% if product.rupture %}(RUPTURE !){% endif %}
{% if product.description or product.img %}
@ -54,13 +54,12 @@
{% endfor %}
<p>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 ?</p>
<input type="text" name="phone_number" value="{{ order.phone_number }}" placeholder="06 12 34 56 78">
<p>Total: {{ order.total(delivery.products) if order else 0 }} €</p>
<input type="hidden" name="email" value="{{ person.email }}">
{% if delivery.status != delivery.CLOSED or request.user.is_staff %}
<input type="submit" value="Enregistrer la commande" class="primary">
{% endif %}
{% if request.user.is_staff and delivery.status == delivery.CLOSED %}
<a class="button danger" href="/livraison/{{ delivery.id }}/commander?email={{ person.email }}&adjust">Ajuster</a>
<a class="button danger" href="/distribution/{{ delivery.id }}/commander?email={{ person.email }}&adjust">Ajuster</a>
{% endif %}
</form>
</article>

View file

@ -1,8 +1,9 @@
{% extends "base.html" %}
{% block toplink %}<a href="/distribution/{{ delivery.id }}/gérer">↶ Retourner à la boîte à outils</a>{% endblock %}
{% block body %}
<h1>Envoi d'un mail aux référent⋅e⋅s</h1>
<p>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.</p>
<p>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.</p>
<form method="post">
<input type="text" name="email_subject" style="width:800px" value="{{ config.SITE_NAME }} - Les commandes pour vos producteurs⋅rices" /><br />
@ -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 }}.

View file

@ -0,0 +1,68 @@
{% extends "base.html" %}
{% block body %}
<div class="header">
<h1>{{ delivery.name }}</h1>
<h4>{% 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" %}
</h4>
<h4>
Distribution <i class="icon-clock"></i> {{ delivery.from_date|date|capitalize }}, {{ delivery.from_date|time }} - {{ delivery.to_date|time }}, à <i class="icon-streetsign"></i> {{ delivery.where }}.
</h4>
<div class="pure-menu pure-menu-horizontal">
<ul class="pure-menu-list">
<li class="pure-menu-item pure-menu-has-children pure-menu-allow-hover">
<a href="#" class="pure-menu-link"><i class="icon-printer"></i>&nbsp;Imprimer…</a>
<ul class="pure-menu-children">
<li class="pure-menu-item"><a href="/distribution/{{ delivery.id }}/produits.pdf" class="pure-menu-link"><i class="icon-ribbon"></i>&nbsp;Commande producteurs⋅rices</a></li>
<li class="pure-menu-item"><a href="/distribution/{{ delivery.id }}/émargement" class="pure-menu-link"><i class="icon-grid"></i>&nbsp;Résumé de commande par groupe</a></li>
<li class="pure-menu-item"><a href="/distribution/{{ delivery.id }}/solde" class="pure-menu-link"><i class="icon-wallet"></i>&nbsp;Répartition des paiements</a></li>
</ul>
</li>
{% if request['user'].email == delivery.contact and delivery.status > delivery.EMPTY %}
<li class="pure-menu-item"><a class="pure-menu-link" href="/distribution/{{ delivery.id }}/gérer"><i class="icon-tools"></i>&nbsp;Boîte à outils</a></li> {% endif %}
{% if request.user and request.user.is_staff %}
{% if delivery.is_archived %}
<li class="pure-menu-item">
<a class="pure-menu-link" href="/distribution/{{ delivery.id }}/désarchiver"><i class="icon-hazardous"></i>&nbsp;Désarchiver</a>
</li>
{% endif %}
<li class="pure-menu-item">
<a class="pure-menu-link" href="/distribution/{{ delivery.id }}/edit"><i class="icon-adjustments"></i>&nbsp;Modifier la distrib</a>
</li>
<li class="pure-menu-item">
<a class="pure-menu-link" href="/distribution/{{ delivery.id }}/produits"><i class="icon-pricetags"></i>&nbsp;Produits</a>
</li>
{% endif %}
</ul
</li>
</ul>
</div>
</ul>
</div>
<article class="delivery">
{% if delivery.has_products %}
{% include "includes/delivery_table.html" %}
{% else %}
<div class="placeholder">
<h2>😔 Pour le moment, cette distribution est bien vide…</h2>
{% if request.user and request.user.is_staff %}
Occupons-nous donc de ça ! Deux options :
<ol>
<li><a href="/producteurices/créer/{{ delivery.id }}">Ajouter les product⋅eurs⋅rices</a> à la main ;</li>
<li>Ou bien <a href="/distribution/{{ delivery.id }}/copier">copier les produits d'une autre distribution</a>.</li>
</ol>
</div>
{% endif %}
</div>
{% endif %}
</article>
<hr>
<ul class="toolbox">
</ul>
{% endblock body %}

View file

@ -0,0 +1,29 @@
{% extends "base.html" %}
{% block toplink %}<a href="/distribution/{{ delivery.id }}">↶ Retourner à la distribution</a>{% endblock %}
{% block body %}
<h1>Gérer « {{ delivery.name }} »</h1>
{% set display_counts = True %}
{% include "includes/delivery_head.html" %}
<h3>Emails des référent⋅e⋅s</h3>
<p>Au cas où, quoi.</p>
<textarea class="list-emails" rows=5>{{ referents | join(', ') }}</textarea>
<h3>Avant et pendant la distribution</h3>
<a class="button" href="/distribution/{{ delivery.id }}/edit"><i class="icon-pencil"></i>&nbsp; Modifier la commande (dates, lieu, référent⋅e, etc)</a>
<a class="button" href="/distribution/{{ delivery.id }}/products"><i class="icon-pencil"></i>&nbsp; Modifier les produits, les product⋅rices⋅eurs</a>
<a class="button" href="/groupes"><i class="icon-globe"></i>&nbsp; Gérer les groupes / colocs</a>
<h3>Une fois les commandes passées (après le {{ delivery.order_before|date }})</h3>
<a class="button" href="/distribution/{{ delivery.id }}/products.pdf"><i class="icon-download"></i>&nbsp; Télécharger les bons de distribution</a>
<a class="button" href="/distribution/{{ delivery.id }}/rapport-complet.xlsx"><i class="icon-download"></i>&nbsp; Télécharger le tableau des commandes</a>
<a class="button" href="/distribution/{{ delivery.id }}/envoi-email-referentes"><i class="icon-envelope"></i>&nbsp; Envoyer les infos de commande aux référent⋅e⋅s</a>
<h3>Pour préparer la distribution (le {{ delivery.from_date|date }})</h3>
<a class="button" href="/distribution/{{ delivery.id }}/émargement"><i class="icon-document"></i>&nbsp; Fiches de commandes par groupe</a>
<a class="button" href="/distribution/{{ delivery.id }}/solde"><i class="icon-gears"></i>&nbsp; Faire la répartition des paiements</a>
{% endblock %}

View file

@ -1,27 +0,0 @@
{% extends "base.html" %}
{% block body %}
<h1>Gérer « {{ delivery.name }} »</h1>
{% set display_counts = True %}
{% include "includes/delivery_head.html" %}
<h3>Emails des référent⋅e⋅s</h3>
<p>Au cas où, quoi.</p>
<textarea class="list-emails" rows=5>{{ referents | join(', ') }}</textarea>
<h3>Avant et pendant la distribution</h3>
<a class="button" href="/livraison/{{ delivery.id }}/edit"><i class="icon-pencil"></i>&nbsp; Modifier la commande (dates, lieu, référent⋅e, etc)</a>
<a class="button" href="/livraison/{{ delivery.id }}/producteurices"><i class="icon-pencil"></i>&nbsp; Modifier les produits, les product⋅rices⋅eurs</a>
<a class="button" href="/groupes"><i class="icon-globe"></i>&nbsp; Gérer les groupes / colocs</a>
<h3>Une fois les commandes passées (après le {{ delivery.order_before|date }})</h3>
<a class="button" href="/livraison/{{ delivery.id }}/producteurices.pdf"><i class="icon-download"></i>&nbsp; Télécharger les bons de livraison</a>
<a class="button" href="/livraison/{{ delivery.id }}/rapport-complet.xlsx"><i class="icon-download"></i>&nbsp; Télécharger le tableau des commandes</a>
<a class="button" href="/livraison/{{ delivery.id }}/envoi-email-referentes"><i class="icon-envelope"></i>&nbsp; Envoyer les infos de commande aux référent⋅e⋅s</a>
<h3>Pour préparer la distribution (le {{ delivery.from_date|date }})</h3>
<a class="button" href="/livraison/{{ delivery.id }}/émargement"><i class="icon-document"></i>&nbsp; Fiches de commandes par groupe</a>
<a class="button" href="/livraison/{{ delivery.id }}/solde"><i class="icon-gears"></i>&nbsp; Faire la répartition des paiements</a>
{% endblock %}

View file

@ -1,64 +0,0 @@
{% extends "base.html" %}
{% block body %}
<div id="layout">
<a href="#menu" id="menuLink" class="menu-link">
<span></span>
</a>
<div id="menu">
<div class="pure-menu">
<a class="pure-menu-heading" href="#">Company</a>
<ul class="pure-menu-list">
<li class="pure-menu-item"><a href="#" class="pure-menu-link">Home</a></li>
<li class="pure-menu-item"><a href="#" class="pure-menu-link">About</a></li>
<li class="pure-menu-item menu-item-divided pure-menu-selected">
<a href="#" class="pure-menu-link">Services</a>
</li>
<li class="pure-menu-item"><a href="#" class="pure-menu-link">Contact</a></li>
</ul>
</div>
</div>
<div id="main">
<div class="header">
<h1>Page Title</h1>
<h2>A subtitle for your page goes here</h2>
</div>
<div class="content">
<h2 class="content-subhead">How to use this layout</h2>
<p>
To use this layout, you can just copy paste the HTML, along with the CSS in <a href="/css/layouts/side-menu.css" alt="Side Menu CSS">side-menu.css</a>, and the JavaScript in <a href="/js/ui.js">ui.js</a>. The JS file uses vanilla JavaScript to simply toggle an <code>active</code> class that makes the menu responsive.
</p>
<h2 class="content-subhead">Now Let's Speak Some Latin</h2>
<p>
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.
</p>
<div class="pure-g">
<div class="pure-u-1-4">
<img class="pure-img-responsive" src="http://farm3.staticflickr.com/2875/9069037713_1752f5daeb.jpg" alt="Peyto Lake">
</div>
<div class="pure-u-1-4">
<img class="pure-img-responsive" src="http://farm3.staticflickr.com/2813/9069585985_80da8db54f.jpg" alt="Train">
</div>
<div class="pure-u-1-4">
<img class="pure-img-responsive" src="http://farm6.staticflickr.com/5456/9121446012_c1640e42d0.jpg" alt="T-Shirt Store">
</div>
<div class="pure-u-1-4">
<img class="pure-img-responsive" src="http://farm8.staticflickr.com/7357/9086701425_fda3024927.jpg" alt="Mountain">
</div>
</div>
<h2 class="content-subhead">Try Resizing your Browser</h2>
<p>
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.
</p>
</div>
</div>
</div>
{% endblock body %}

View file

@ -1,9 +1,9 @@
<p>Bonjour,</p>
<p>Voici le résumé de ta commande «{{ delivery.name }}»</p>
{% include "includes/order_summary.html" %}
<p>Livraison: {{ delivery.where }}, le {{ delivery.from_date|date }} de {{ delivery.from_date|time }} à {{ delivery.to_date|time }}</p>
<p>Distribution: {{ delivery.where }}, le {{ delivery.from_date|date }} de {{ delivery.from_date|time }} à {{ delivery.to_date|time }}</p>
{% if delivery.is_open %}
<p>Tu peux la modifier (jusqu'au {{ delivery.order_before|date }}) <a href="https://{{ request.host }}/livraison/{{ delivery.id }}/commander">en cliquant ici</a>.</p>
<p>Tu peux la modifier (jusqu'au {{ delivery.order_before|date }}) <a href="https://{{ request.host }}/distribution/{{ delivery.id }}/commander">en cliquant ici</a>.</p>
{% endif %}
<p>Bonne journée!</p>
<p>{{ config.EMAIL_SIGNATURE }}</p>

View file

@ -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 %}

View file

@ -1,24 +0,0 @@
{% extends "base.html" %}
{% block body %}
<div class="header">
<h1>Groupes</h1>
<ul class="actions">
<li><a class="button" href="/groupes/créer"><i class="icon-globe"></i>&nbsp;Créer un nouveau groupe</a></li>
</ul>
</div>
{% if not request['user'].group_id %}
<p>Bienvenue ! Avant de pouvoir commander, peux-tu nous indiquer de quelle coloc / famille fais-tu partie ? </p>
{% endif %}
{% for group in groups.groups.values() %}
<h3>{{ group.name }} {% if group.id != request['user'].group_id %}<a class="button" href="/groupes/{{ group.id}}/rejoindre">rejoindre</a>{% endif %} <a class="button" href="/groupes/{{ group.id }}/éditer">éditer</a></h3>
<ul>
{% for member in group.members %}
<li>{{ member }}</li>
{% endfor %}
</ul>
{% endfor %}
{% endblock %}

View file

@ -0,0 +1,47 @@
{% extends "base.html" %}
{% block body %}
<div class="header">
<h1>Groupes</h1>
<div class="pure-menu pure-menu-horizontal">
<ul class="pure-menu-list">
<li class="pure-menu-item">
<a class="pure-menu-link" href="/groupes/créer"><i class="icon-globe"></i>&nbsp;Créer un nouveau groupe</a>
</li>
</ul>
</div>
</div>
{% if not request['user'].group_id %}
<p>Bienvenue ! Avant de pouvoir commander, peux-tu nous indiquer de quelle coloc / famille fais-tu partie ? </p>
{% endif %}
<table id="groups" class="pure-table">
<thead>
<tr>
<th>Groupe</th>
<th>Membres</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for group in groups.groups.values() %}
<tr>
<th>{{ group.name }}</th>
<td>
<ul>
{% for member in group.members %}
<li>{{ member }}</li>
{% endfor %}
</ul>
</td>
<td>{% if group.id != request['user'].group_id %}<a class="pure-button" href="/groupes/{{ group.id}}/rejoindre">rejoindre</a>{% endif %} <a class="pure-button" href="/groupes/{{ group.id }}/éditer">éditer</a></td>
</tr>
{% endfor %}
</tbody>
</table>
<p>Tu ne fais partie d'aucun des groupes listés ici ? Tu peux <a class="button" href="/groupes/créer"><i class="icon-globe"></i>&nbsp;en créer un nouveau</a></p>
{% endblock %}

View file

@ -1,30 +0,0 @@
{% extends "base.html" %}
{% block body %}
<div class="header">
<h1>Distributions</h1>
<ul class="actions">
<li>
<a class="button" href="/livraison"><i class="icon-hotairballoon"></i>&nbsp;Créer une nouvelle distribution</a>&nbsp;
</li>
<li>
<a class="button" href="/livraison"><i class="icon-telescope"></i>&nbsp;Voir les distribution passées</a>&nbsp;
</li>
</ul>
</div>
{% with deliveries=incoming %}
{% include "includes/delivery_list.html" %}
{% endwith %}
{% if former %}
<div class="header">
<h1>Distributions passées</h1>
</div>
{% with deliveries=former %}
{% include "includes/delivery_small_list.html" %}
{% endwith %}
{% endif %}
{% if archives %}
<a href="/archives">Voir les livraisons archivées</a>
<hr>
{% endif %}
{% endblock body %}

View file

@ -5,7 +5,7 @@
{% else %}
<li class="hide-on-print"><i class="icon-strategy"></i> <strong>Référent⋅e</strong> <a href="mailto:{{ delivery.contact }}">{{ delivery.contact }}</a></li>
{% endif %}
<li><i class="icon-clock"></i> <strong>Date de livraison</strong> <time datetime="{{ delivery.from_date }}">{{ delivery.from_date|date|capitalize }} de {{ delivery.from_date|time }} à {{ delivery.to_date|time }}</time></li>
<li><i class="icon-clock"></i> <strong>Date de distribution</strong> <time datetime="{{ delivery.from_date }}">{{ delivery.from_date|date|capitalize }} de {{ delivery.from_date|time }} à {{ delivery.to_date|time }}</time></li>
<li class="hide-on-print"><i class="icon-hourglass"></i> {% if delivery.status == delivery.OPEN %}<strong>Date limite de commande</strong> <time datetime="{{ delivery.order_before.date() }}">{{ delivery.order_before|date|capitalize }}</time>{% elif delivery.status == delivery.ADJUSTMENT %}<strong>Ajustement en cours</strong>{% elif delivery.status == delivery.CLOSED %}<strong>Fermée</strong>{% else %}<strong>Archivée</strong>{% endif %}</li>
{% if delivery.instructions %}<li><i class="icon-lightbulb"></i> <strong>À savoir</strong> {{ delivery.instructions }}</li>{% endif %}
{% if delivery.infos_url %}<li><i class="icon-global"></i><strong>Plus d'infos</strong> <a href="{{ delivery.infos_url }}" title="{{ delivery.infos_url }}">{{ delivery.infos_url|truncate(20)}}</a></li>{% endif %}

View file

@ -2,7 +2,7 @@
<ul class="delivery">
{% for delivery in deliveries %}
<li>
<h3><a href="/livraison/{{ delivery.id }}"><i class="icon-hotairballoon"></i> {{ delivery.name }}</a> {% include "includes/order_button.html" %} <a class="button" href="/livraison/{{ delivery.id }}">Voir les commandes</a></h3>
<h3><a href="/distribution/{{ delivery.id }}"><i class="icon-hotairballoon"></i> {{ delivery.name }}</a> {% include "includes/order_button.html" %} <a class="button" href="/distribution/{{ delivery.id }}">Voir les commandes</a></h3>
{% include "includes/delivery_head.html" %}
</li>
<hr>

View file

@ -2,10 +2,10 @@
<ul class="delivery">
{% for delivery in deliveries %}
<li>
<a href="/livraison/{{ delivery.id }}"><i class="icon-hotairballoon"></i> {{ delivery.name }}</a> du {{ delivery.from_date|date|capitalize }}
<a href="/distribution/{{ delivery.id }}"><i class="icon-hotairballoon"></i> {{ delivery.name }}</a> du {{ delivery.from_date|date|capitalize }}
</li>
{% endfor %}
</ul>
{% else %}
<p>Aucune livraison.</p>
<p>Aucune distribution.</p>
{% endif %}

View file

@ -5,19 +5,18 @@
{% endif %}
{% for producer in producers %}
{% set producer_obj = delivery.producers[producer] %}
<h2>{{ producer }}
<h2>{{ producer_obj.name }}
{% if edit_mode or request.user.is_staff or producer_obj.referent == request.user.email %}
<span class="edit">
<a class="button" href="/livraison/{{ delivery.id }}/{{ producer }}/éditer"><i class="icon-ribbon"></i>&nbsp; Éditer</a>
<a class="button" href="/livraison/{{ delivery.id }}/{{ producer }}/ajouter-produit"><i class="icon-puzzle"></i>&nbsp; Ajouter un produit </a>
{% if delivery.can_generate_reports %}
<a class="button" href="/livraison/{{ delivery.id }}/{{ producer }}/bon-de-commande.pdf"><i class="icon-grid"></i>&nbsp; Télécharger le bon de commande</a>
{% endif %}
<a class="button" href="/distribution/{{ delivery.id }}/{{ producer_obj.id }}/éditer"><i class="icon-ribbon"></i>&nbsp; Gérer ce⋅tte producteur⋅rice</a>
</span>
{% endif %}
</h2>
<h5>{% if producer_obj.description %}{{ producer_obj.description }}{% endif %}. Référent⋅e : <a href="mailto:{{ producer_obj.referent }}">{{ producer_obj.referent_name }}</a> / {{ producer_obj.referent_tel }}</h5>
<table class="delivery">
{% if not delivery.get_products_by(producer) %}
😔 Ce⋅tte producteur⋅rice n'a pas encore de produits. Voulez vous <a href="/distribution/{{ delivery.id }}/{{ producer }}/ajouter-produit">en rajouter un ?</a>
{% else %}
<table class="delivery pure-table">
<thead>
<tr>
<th class="product">Produit</th>
@ -25,14 +24,13 @@
{% if delivery.has_packing %}
<th class="packing">Conditionnement</th>
{% endif %}
{% if edit_mode %}<th>Éditer</th>{% endif %}
<th class="amount">Total</th>
{% if not list_only %}
{% for orderer, order in delivery.orders.items() %}
{% set orderer_name = request.groups.groups[orderer].name %}
<th class="person">
{% if request.user and (request.user.is_staff or request.user.is_referent(delivery)) %}
<a href="/livraison/{{ delivery.id }}/commander?orderer={{ orderer }}" title="{{ orderer }}">{{ orderer_name }}</a>
<a href="/distribution/{{ delivery.id }}/commander?orderer={{ orderer }}" title="{{ orderer }}">{{ orderer_name }}</a>
{% else %}
<span title="{{ orderer }}">{{ orderer_name }}</span>
{% endif %}
@ -44,16 +42,15 @@
<tbody>
{% for product in delivery.get_products_by(producer) %}
<tr>
<th class="product {% if product.rupture %}rupture{% endif %}">{% if edit_mode %}<a href="/livraison/{{ delivery.id }}/{{ product.producer }}/{{ product.ref }}/éditer">{% endif %}{{ product }}{% if edit_mode %}</a>{% endif %}{% if product.rupture %} {{ product.rupture }}{% endif %}
<th class="product {% if product.rupture %}rupture{% endif %}">{% if edit_mode %}<a href="/distribution/{{ delivery.id }}/{{ product.producer }}/produit/{{ product.ref }}/éditer">{% endif %}{% if edit_mode %}<i class="icon-pencil"></i>&nbsp;{% endif %}{{ product }}{% if edit_mode %}</a>{% endif %}{% if product.rupture %} {{ product.rupture }}{% endif %}
<td>{{ product.price | round(2) }} €</td>
{% if delivery.has_packing %}
<td class="packing">{% if product.packing %}{{ product.packing }} x {% endif %} {{ product.unit }}</td>
{% endif %}
{% if edit_mode %}<td><a href="/livraison/{{ delivery.id }}/{{ product.producer }}/{{ product.ref }}/éditer">modifier</a></td>{% endif %}
<th{% if delivery.status == delivery.ADJUSTMENT and delivery.product_missing(product) %} class="missing" title="Les commandes individuelles ne correspondent pas aux conditionnements"{% endif %}>
{{ delivery.product_wanted(product) }}
{% if delivery.status == delivery.ADJUSTMENT and delivery.product_missing(product) %} ({{ delivery.product_missing(product) }})
{% if request.user.is_staff %}<a href="/livraison/{{ delivery.id }}/ajuster/{{ product.ref }}" class="button" title="ajuster le produit">ajuster</a>{% endif %}
{% if request.user.is_staff %}<a href="/distribution/{{ delivery.id }}/ajuster/{{ product.ref }}" class="button" title="ajuster le produit">ajuster</a>{% endif %}
{% endif %}
</th>
{% if not list_only %}
@ -80,4 +77,6 @@
</tr>
</tbody>
</table>
{% endif %}
<br />
{% endfor %}

View file

@ -1,8 +0,0 @@
<label for="modal-{{ unique_id }}" class="toggle-label">{% block modal_label %}{% endblock %}</label>
<input type="checkbox" id="modal-{{ unique_id }}" class="toggle">
<label for="modal-{{ unique_id }}" class="toggle-background"></label>
<div class="toggle-container">
{% block modal_body %}
{% endblock modal_body %}
<label for="modal-{{ unique_id }}" class="toggle-label">Fermer</label>
</div>

View file

@ -1,10 +0,0 @@
{% extends "includes/modal.html" %}
{% block modal_label %}<i class="icon-hazardous"></i> Ajouter une commande{% endblock modal_label %}
{% block modal_body %}
<form action="/livraison/{{ delivery.id }}/commander">
<h4>Ajouter une commande pour quelqu'un d'autre</h4>
<input type="text" name="orderer" placeholder="Identifiant de groupe" required>
<input type="submit" value="Commander">
</form>
{% endblock modal_body %}

View file

@ -1,8 +0,0 @@
{% extends "includes/modal.html" %}
{% block modal_label %}<i class="icon-clipboard"></i> Copier les emails{% endblock modal_label %}
{% block modal_body %}
<div>
<textarea>{{ ",".join(delivery.orders.keys()) }}</textarea>
</div>
{% endblock modal_body %}

View file

@ -1,19 +0,0 @@
{% extends "includes/modal.html" %}
{% block modal_label %}<i class="icon-paperclip"></i> Importer une commande{% endblock modal_label %}
{% block modal_body %}
<h4>Importer une commande</h4>
<p>Colonnes: ref*, wanted*</p>
<form action="/livraison/{{ delivery.id }}/importer/commande" method="post" enctype="multipart/form-data">
<input type="file" name="data">
<input type="email" name="email" placeholder="email">
<input type="submit" name="Importer">
</form>
<h4>Importer plusieurs commandes</h4>
<p>Colonnes: ref*, toto@domain.tld, etc.</p>
<form action="/livraison/{{ delivery.id }}/importer/commandes" method="post" enctype="multipart/form-data">
<input type="file" name="data">
<input type="submit" name="Importer">
</form>
{% endblock modal_body %}

View file

@ -1,44 +0,0 @@
{% extends "includes/modal.html" %}
{% block modal_label %}<i class="icon-upload"></i> Importer les produits depuis un tableur{% endblock modal_label %}
{% block modal_body %}
<h3>Importer des produits</h3>
<p>Format pris en charge: xlsx</p>
<details>
<p>Le tableur doit contenir deux onglets, le premier avec les produits, le second avec les producteurs.</p>
<summary>Détails des colonnes produits</summary>
<dl>
<dt class="mandatory">ref</dt>
<dd>Référence unique du produit (permet de mettre à jour les produits en cours de commande ou d'importer des commandes individuelles).</dd>
<dt class="mandatory">name</dt>
<dd>Nom du produit: mettre juste assez d'info pour distinguer les produits les uns des autres.</dd>
<dt class="mandatory">price</dt>
<dd>Prix d'une unité, en euros.</dd>
<dt>unit</dt>
<dd>Conditionnement d'une unité: 1kg, 33cl…</dd>
<dt>producer</dt>
<dd>Le nom du producteur. Utilisé pour pouvoir grouper les produits par producteur.</dd>
<dt>description</dt>
<dd>Plus de détails sur le produit.</dd>
<dt>packing</dt>
<dd>Contionnement final pour grouper les commandes, le cas échéant, en nombre d'unités.</dd>
<dt>url</dt>
<dd>Une URL éventuelle pointant sur une page présentant le produit.</dd>
<dt>img</dt>
<dd>Une URL éventuelle pointant sur une image du produit (attention, utiliser seulement des liens https).</dd>
</dl>
<summary>Détails des colonnes producteur</summary>
<dl>
<dt class="mandatory">id</dt>
<dd>Nom du producteur (doit être le même que le premier onglet).</dd>
<dt>referent</dt>
<dd>Le nom du ou de la référent⋅e</dd>
<dt>contact</dt>
<dd>Un moyen de contacter le⋅a product⋅eur⋅rice</dd>
</dl>
</details>
<form action="/livraison/{{ delivery.id }}/importer/produits" method="post" enctype="multipart/form-data">
<input type="file" name="data" required>
<input type="submit" value="Mettre à jour les produits et product⋅eur⋅rice⋅s">
</form>
{% endblock modal_body %}

View file

@ -1,9 +0,0 @@
{% extends "includes/modal.html" %}
{% block modal_label %}(Détails){% endblock modal_label %}
{% block modal_body %}
<p>{{ product.description }}</p>
<p>{% if product.img %}
<img src="{{ product.img }}">
{% endif %}</p>
{% endblock modal_body %}

View file

@ -1,5 +1,5 @@
{% if (delivery.status == delivery.OPEN or delivery.status == delivery.ADJUSTMENT) and delivery.has_products %}
<a class="button" href="/livraison/{{ delivery.id }}/commander">
<a class="button" href="/distribution/{{ delivery.id }}/commander">
{% if delivery.status == delivery.ADJUSTMENT %}
Ajuster ma commande
{% elif delivery.status == delivery.OPEN %}

View file

@ -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 %}

View file

@ -1,10 +0,0 @@
{% extends "base.html" %}
{% block body %}
<p class="hide-on-print">
<a href="/livraison/{{ delivery.id }}/producteurices.pdf{% if referent %}?referent={{ referent }}{% endif %}">Télécharger la liste des commandes en PDF</a>
{% if referent %}Voici la liste des product⋅eurs⋅rices dont {{ referent }} est référent⋅e. <a class="button" href="/livraison/{{delivery.id}}/producteurices">voir tous les produits</a>{% endif %}
</p>
<article class="delivery">
{% include "includes/delivery_table.html" %}
</article>
{% endblock body %}

View file

@ -2,7 +2,7 @@
{% block body %}
<article>
<h3><a href="/livraison/{{ delivery.id }}">{{ delivery.producer }}</a> — Ajuster le produit «{{ product.name }}»</h3>
<h3><a href="/distribution/{{ delivery.id }}">{{ delivery.producer }}</a> — Ajuster le produit «{{ product.name }}»</h3>
<p><strong>Conditionnement</strong> {{ product.packing }} x {{ product.unit }}</p>
<p><strong>Total commandé</strong> {{ delivery.product_wanted(product) }}</p>
<p><strong>Manquant</strong> {{ delivery.product_missing(product) }}</p>

View file

@ -0,0 +1,67 @@
{% extends "base.html" %}
{% block body %}
<h1>Supprimer le produit « {{ product.name }} »</h1>
<form method="post">
<label>
<p>Êtes vous sur⋅e de vouloir supprimer ce produit ?</p>
<input type="text" name="name" value="{{ product.name or '' }}" required>
</label>
<label>
<p>Prix (en €), pour une unité</p>
<input type="number" step="0.001" name="price" value="{{ product.price or '' }}" required>
</label>
<label>
<p>Unité de commande</p>
<input type="text" name="unit" placeholder="sachet de 200g" value="{{ product.unit or '' }}" required>
</label>
<label>
<h5>Description du produit</h5>
<input type="text" name="description" placeholder="Bière type American Pale Ale" value="{{ product.description or '' }}">
</label>
<label>
<h5>Conditionnement</h5>
<h5><strong>A indiquer uniquement si ces produits ne peuvent pas être achetés à l'unité</strong>. Par exemple, si il est uniquement possible d'acheter des cartons de 12 bouteilles, alors indiquez « 12 » ici. Dans le doute, laissez vide.</h5>
<input type="number" name="packing" placeholder="6" value="{{ product.packing or '' }}">
</label>
<br />
<label>
Produit actuellement en rupture ?
<input type="checkbox" name="rupture" value="RUPTURE" {% if product.rupture %}checked="true"{% endif %}>
</label>
<div>
<input type="submit" name="submit" value="Valider" class="primary">
</div>
</form>
{% if products %}
<h3>Produits</h3>
<table>
<thead>
<tr>
<th>Produit</th>
<th>Prix</th>
<th>Unité</th>
<th>Description</th>
<th>Packaging</th>
<th>Rupture ?</th>
<th></th>
</tr>
</thead>
{% for product in products %}
<tr>
<td><a href="/distribution/{{ delivery.id }}/produit/{{ product.ref }}/éditer">{{ product.name }}</a></td>
<td>{{ product.price }}€</td>
<td>{{ product.unit }}</td>
<td>{{ product.description }}</td>
<td>{% if product.packing %}{{ product.packing }}{% endif %}</td>
<td>{% if product.rupture %}RUPTURE !!{% endif %}</td>
<td><a href="/distribution/{{ delivery.id }}/produit/{{ product.ref }}/éditer">éditer</a></td>
</tr>
{% endfor %}
</table>
{% endif %}
<hr>
{% endblock body %}

View file

@ -1,10 +1,12 @@
{% extends "base.html" %}
{% block toplink %}<a href="/distribution/{{ delivery.id }}/{{ producer.id }}/éditer">↶ Retourner aux produits de {{ producer.name }}</a>{% endblock %}
{% block body %}
{% if product.ref %}
<h1>Modifier le produit « {{ product.name }} »</h1>
{% else %}
<h1>{{ producer_id }} : Nouveau produit</h1>
<h1>{{ producer.name }} : Nouveau produit</h1>
{% endif %}
<form method="post">
<label>
@ -13,7 +15,7 @@
</label>
<label>
<p>Prix (en €), pour une unité</p>
<input type="number" step="0.01" name="price" value="{{ product.price or '' }}" required>
<input type="number" step="0.001" name="price" value="{{ product.price or '' }}" required>
</label>
<label>
<p>Unité de commande</p>
@ -24,12 +26,8 @@
<input type="text" name="description" placeholder="Bière type American Pale Ale" value="{{ product.description or '' }}">
</label>
<label>
<h5>URL d'une image du produit</h5>
<input type="url" name="url" placeholder="https://example.com/image.png" value="{{ product.url or '' }}">
</label>
<label>
<h5>Conditionnement (par combien d'unités est vendu ce produit ?)</h5>
<h5><em>par exemple, entrez 6 si ce sont des cartons de 6 bouteilles</em></h5>
<h5>Conditionnement</h5>
<h5><strong>A indiquer uniquement si ces produits ne peuvent pas être achetés à l'unité</strong>. Par exemple, si il est uniquement possible d'acheter des cartons de 12 bouteilles, alors indiquez « 12 » ici. Dans le doute, laissez vide.</h5>
<input type="number" name="packing" placeholder="6" value="{{ product.packing or '' }}">
</label>
<br />
@ -59,13 +57,13 @@
</thead>
{% for product in products %}
<tr>
<td><a href="/livraison/{{ delivery.id }}/produit/{{ product.ref }}/éditer">{{ product.name }}</a></td>
<td><a href="/distribution/{{ delivery.id }}/produit/{{ product.ref }}/éditer">{{ product.name }}</a></td>
<td>{{ product.price }}€</td>
<td>{{ product.unit }}</td>
<td>{{ product.description }}</td>
<td>{% if product.packing %}{{ product.packing }}{% endif %}</td>
<td>{% if product.rupture %}RUPTURE !!{% endif %}</td>
<td><a href="/livraison/{{ delivery.id }}/produit/{{ product.ref }}/éditer">éditer</a></td>
<td><a href="/distribution/{{ delivery.id }}/produit/{{ product.ref }}/éditer">éditer</a></td>
</tr>
{% endfor %}
</table>

View file

@ -1,18 +1,28 @@
{% extends "base.html" %}
{% block toplink %}<a href="/distribution/{{ delivery.id }}/produits">↶ Retourner aux produits</a>{% endblock %}
{% block body %}
{% if producer.id %}
<h1>Modifier les informations pour « {{ producer.id }} »</h1>
<div class="header">
{% if producer and producer.id %}
<h1>Modifier les informations pour « {{ producer.name }} »</h1>
<div class="pure-menu pure-menu-horizontal">
<ul class="pure-menu-list">
<li class="pure-menu-item">
<a class="pure-menu-link" href="/distribution/{{ delivery.id}}/{{ producer.id }}/ajouter-produit">Ajouter un produit</a>
</li>
</ul>
</div>
{% else %}
<h1>Nouve⋅eau⋅lle product⋅eur⋅rice</h1>
<h1>Ajouter un⋅e producteur⋅rice</h1>
{% endif %}
</div>
<form method="post">
{% if producer.id %}
<input type="hidden" name="id" value="{{ producer.id }}">
{% else %}
<label>
<p>Nom du / de la product⋅eur⋅rice</p>
<input type="text" name="id" value="{{ producer.id or '' }}">
<p>Nom</p>
<input type="text" name="name" value="{{ producer.name or '' }}">
</label>
{% endif %}
<label>
@ -32,7 +42,7 @@
<input type="tel" name="referent_tel" value="{{ producer.referent_tel or '' }}" required>
</label>
<label>
<h5>Contact du⋅la product⋅eur⋅rice</h5>
<h5>Contact product⋅eur⋅rice</h5>
<input type="text" name="contact" value="{{ producer.contact or '' }}">
</label>
<div>
@ -41,9 +51,9 @@
</form>
{% if products %}
<h3>Produits <a class="button" href="/livraison/{{ delivery.id}}/{{ producer.id }}/ajouter-produit">Ajouter un produit</a></h3>
<table>
<h3>Produits <a class="button" href="/distribution/{{ delivery.id}}/{{ producer.id }}/ajouter-produit">Ajouter un produit</a></h3>
<p>Vous pouvez éditer les produits en cliquant sur leur nom.</p>
<table class="pure-table">
<thead>
<tr>
<th>Produit</th>
@ -57,13 +67,13 @@
</thead>
{% for product in products %}
<tr>
<td><a href="/livraison/{{ delivery.id }}/{{ producer.id }}/{{ product.ref }}/éditer">{{ product.name }}</a></td>
<td><a href="/distribution/{{ delivery.id }}/{{ producer.id }}/produit/{{ product.ref }}/éditer">{{ product.name }}</a></td>
<td>{{ product.price }}€</td>
<td>{{ product.unit }}</td>
<td>{{ product.description }}</td>
<td>{% if product.packing %}{{ product.packing }}{% endif %}</td>
<td>{% if product.rupture %}RUPTURE !!{% endif %}</td>
<td><a href="/livraison/{{ delivery.id }}/{{ producer.id }}/{{ product.ref }}/éditer">éditer</a></td>
<td><a href="/distribution/{{ delivery.id }}/{{ producer.id }}/{{ product.ref }}/supprimer">supprimer</a></td>
</tr>
{% endfor %}
</table>

View file

@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block toplink %}<a href="/distribution/{{ delivery.id }}">↶ Retourner à la distribution</a>{% endblock %}
{% block body %}
<div class="header">
<h1>Gérer les produits</h1>
<div class="pure-menu pure-menu-horizontal">
<ul class="pure-menu-list">
<li class="pure-menu-item">
<a class="pure-menu-link" href="/producteurices/créer/{{ delivery.id }}"><i class="icon-heart"></i>&nbsp;Ajouter un⋅e producteur⋅rice</a>
</li>
<li class="pure-menu-item">
<a class="pure-menu-link" href="/distribution/{{ delivery.id }}/copier"><i class="icon-hotairballoon"></i>&nbsp;Réutiliser les produits d'une autre distrib</a>
</li>
</ul>
</div>
</div>
<p class="hide-on-print">
{% if producers %}
<a href="/distribution/{{ delivery.id }}/producteurices.pdf{% if referent %}?referent={{ referent }}{% endif %}">Télécharger la liste des commandes en PDF</a>
{% endif %}
{% if referent %}Voici la liste des product⋅eurs⋅rices dont {{ referent }} est référent⋅e. <a class="button" href="/distribution/{{delivery.id}}/producteurices">voir tous les produits</a>{% endif %}
</p>
<article class="delivery">
{% include "includes/delivery_table.html" %}
</article>
{% endblock body %}

View file

@ -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&nbsp;%B")
def time_filter(value):
return value.strftime(r"%H:%M")

View file

@ -0,0 +1 @@
from . import delivery, products, groups, login # noqa : import to scan the routes.

130
copanier/views/core.py Normal file
View file

@ -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()

379
copanier/views/delivery.py Normal file
View file

@ -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)

72
copanier/views/groups.py Normal file
View file

@ -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"

77
copanier/views/login.py Normal file
View file

@ -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 = "/"

186
copanier/views/products.py Normal file
View file

@ -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"

View file

@ -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

View file

@ -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"),