This commit is contained in:
Yohan Boniface 2019-03-22 19:35:32 +01:00
parent cd12223d6d
commit edd17e6630
27 changed files with 1876 additions and 2 deletions

3
.flake8 Normal file
View file

@ -0,0 +1,3 @@
[flake8]
# Black crazyness.
max-line-length = 88

24
kaba/config.py Normal file
View file

@ -0,0 +1,24 @@
import os
from pathlib import Path
getenv = os.environ.get
DATA_ROOT = Path(__file__).parent.parent / "db"
SECRET = "sikretfordevonly"
# JWT_ALGORITHM = "HS256"
SEND_EMAILS = False
SMTP_HOST = "smtp.gmail.com"
SMTP_PASSWORD = ""
FROM_EMAIL = "contact@epinamap.org"
def init():
for key, value in globals().items():
if key.isupper():
env_key = "KABA_" + key
typ = type(value)
if env_key in os.environ:
globals()[key] = typ(os.environ[env_key])
init()

32
kaba/emails.py Normal file
View file

@ -0,0 +1,32 @@
import smtplib
from email.message import EmailMessage
from . import config
ACCESS_GRANTED = """Hey ho!
Voici le sésame:
https://{hostname}/sésame/{token}
Les gentils gens d'Épinamap
"""
def send(to, subject, body):
msg = EmailMessage()
msg.set_content(body)
msg["Subject"] = subject
msg["From"] = config.FROM_EMAIL
msg["To"] = to
if not config.SEND_EMAILS:
return print("Sending email", str(msg))
try:
server = smtplib.SMTP_SSL(config.SMTP_HOST)
server.login(config.FROM_EMAIL, config.SMTP_PASSWORD)
server.send_message(msg)
except smtplib.SMTPException:
raise RuntimeError
finally:
server.quit()

168
kaba/models.py Normal file
View file

@ -0,0 +1,168 @@
import inspect
import os
import threading
import uuid
from datetime import datetime
from dataclasses import dataclass, field, asdict
from pathlib import Path
from typing import List, Dict
import yaml
from . import config, utils
class DoesNotExist(ValueError):
pass
def datetime_field(value):
if isinstance(value, datetime):
return value
if isinstance(value, int):
return datetime.fromtimestamp(value)
if isinstance(value, str):
return datetime.fromisoformat(value)
raise ValueError
def price_field(value):
if isinstance(value, str):
value = value.replace(",", ".")
return float(value)
@dataclass
class Base:
@classmethod
def create(cls, data=None, **kwargs):
if isinstance(data, Base):
return data
return cls(**(data or kwargs))
def __post_init__(self):
for name, field_ in self.__dataclass_fields__.items():
value = getattr(self, name)
type_ = field_.type
if not isinstance(value, Base): # Do not recast our classes.
try:
setattr(self, name, self.cast(type_, value))
except (TypeError, ValueError):
raise
raise ValueError(f"Wrong value for field `{name}`: `{value}`")
def cast(self, type, value):
if hasattr(type, "_name"):
if type._name == "List":
if type.__args__:
args = type.__args__
type = lambda v: [self.cast(args[0], s) for s in v]
else:
type = list
elif type._name == "Dict":
if type.__args__:
args = type.__args__
type = lambda o: {
self.cast(args[0], k): self.cast(args[1], v)
for k, v in o.items()
}
else:
type = dict
elif inspect.isclass(type) and issubclass(type, Base):
type = type.create
return type(value)
def dump(self):
return yaml.dump(asdict(self), allow_unicode=True)
@dataclass
class Person(Base):
email: str
first_name: str = ""
last_name: str = ""
@dataclass
class Product(Base):
name: str
ref: str
price: price_field
weight: str = ""
description: str = ""
url: str = ""
img: str = ""
@dataclass
class ProductOrder(Base):
wanted: int
ordered: int = 0
@dataclass
class Order(Base):
products: Dict[str, ProductOrder] = field(default_factory=lambda *a, **k: {})
def get_quantity(self, product):
choice = self.products.get(product.ref)
return choice.wanted if choice else 0
def total(self, products):
products = {p.ref: p for p in products}
return round(
sum(p.wanted * products[ref].price for ref, p in self.products.items()), 2
)
@dataclass
class Delivery(Base):
__root__ = "delivery"
__lock__ = threading.Lock()
producer: str
when: datetime_field
order_before: datetime_field
description: str = ""
where: str = "Marché de la Briche"
products: List[Product] = field(default_factory=lambda *a, **k: [])
orders: Dict[str, Order] = field(default_factory=lambda *a, **k: {})
id: str = field(default_factory=lambda *a, **k: uuid.uuid4().hex)
@property
def total(self):
return round(sum(o.total(self.products) for o in self.orders.values()), 2)
@property
def is_open(self):
return self.order_before > utils.utcnow()
@classmethod
def get_root(cls):
return Path(config.DATA_ROOT) / cls.__root__
@classmethod
def load(cls, id):
path = cls.get_root() / f"{id}.yml"
if not path.exists():
raise DoesNotExist
return cls(**yaml.safe_load(path.read_text()))
@classmethod
def all(cls):
for path in cls.get_root().glob("*.yml"):
yield Delivery.load(path.stem)
def persist(self):
with self.__lock__:
path = self.get_root() / f"{self.id}.yml"
path.write_text(self.dump())
def product_wanted(self, product):
total = 0
for order in self.orders.values():
if product.ref in order.products:
total += order.products[product.ref].wanted
return total

372
kaba/static/app.css Normal file
View file

@ -0,0 +1,372 @@
:root {
--primary-color: #0062b7;
--primary-color-light: #e6f0fa;
--secondary-color: #e10055;
--text-color: #414664;
--border-color: #e6e6eb;
--primary-background-color: #fff;
--secondary-background-color: #fafafb;
--disease: #7846af;
--disease-light: #f5ebfa;
--country: #0f8796;
--country-light: #e6f5f5;
--group: #d03800;
--group-light: #fff5eb;
--keyword: #8c5a2d;
--keyword-light: #f5f0eb;
--kind: #cd0073;
--kind-light: #faf0f5;
--ern: #32009b;
--ern-light: #ebebf5;
}
/* latin-ext */
@font-face {
font-family: 'Work Sans';
font-style: normal;
font-weight: 300;
src: local('Work Sans Light'), local('WorkSans-Light'), url(./fonts/WorkSans/WorkSansLightLatinExt.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Work Sans';
font-style: normal;
font-weight: 300;
src: local('Work Sans Light'), local('WorkSans-Light'), url(./fonts/WorkSans/WorkSansLightLatin.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'Work Sans';
font-style: normal;
font-weight: 400;
src: local('Work Sans'), local('WorkSans-Regular'), url(./fonts/WorkSans/WorkSansRegularLatinExt.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Work Sans';
font-style: normal;
font-weight: 400;
src: local('Work Sans'), local('WorkSans-Regular'), url(./fonts/WorkSans/WorkSansRegularLatin.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'Work Sans';
font-style: normal;
font-weight: 600;
src: local('Work Sans SemiBold'), local('WorkSans-SemiBold'), url(./fonts/WorkSans/WorkSansSemiBoldLatinExt.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Work Sans';
font-style: normal;
font-weight: 600;
src: local('Work Sans SemiBold'), local('WorkSans-SemiBold'), url(./fonts/WorkSans/WorkSansSemiBoldLatin.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'Work Sans';
font-style: normal;
font-weight: 700;
src: local('Work Sans Bold'), local('WorkSans-Bold'), url(./fonts/WorkSans/WorkSansBoldLatinExt.woff) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Work Sans';
font-style: normal;
font-weight: 700;
src: local('Work Sans Bold'), local('WorkSans-Bold'), url(./fonts/WorkSans/WorkSansBoldLatin.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
* {
box-sizing: border-box;
font-family: inherit;
}
*, ::after, ::before {
box-sizing: inherit;
}
html {
font-size: 20px;
line-height: 1.5;
}
body {
color: var(--text-color);
font-size: .8rem;
font-family: 'Work Sans', sans-serif;
text-rendering: optimizeLegibility;
background-color: var(--secondary-background-color);
}
header {
border-bottom: 1px solid #eee;
padding: 20px;
}
h1,
h2,
h3,
h4,
h5,
legend {
/*margin: 0;*/
color: var(--primary-color);
line-height: 1;
font-weight: 300;
}
h1 { font-size: 2.2rem }
h2 { font-size: 1.8rem }
h3 { font-size: 1.4rem }
h4 { font-size: 1.1rem }
a {
color: #00d1b2;
cursor: pointer;
text-decoration: none;
-webkit-transition: none 86ms ease-out;
transition: none 86ms ease-out;
}
a:hover {
color: #363636;
}
main a {
padding: 0 .1rem;
color: inherit;
text-decoration: none;
background-color: transparent;
border-bottom: 1px solid var(--primary-color);
transition: all .3s;
}
main a:hover {
padding-bottom: 1px;
color: var(--primary-color);
background-color: #e2eaf1;
border-bottom: 0;
}
button,
a.button,
input[type=submit] {
display: flex;
align-items: center;
justify-content: center;
width: auto;
height: 1.6rem;
padding: .1rem .8rem;
color: var(--primary-color);
font-size: .6rem;
font-weight: 500;
line-height: 2;
outline: 0;
text-align: center;
text-decoration: none;
vertical-align: middle;
background-color: transparent;
white-space: nowrap;
border: .05rem solid var(--primary-color);
border-radius: 0.1rem;
transition: all .2s ease;
cursor: pointer;
}
.button:hover {
color: #fff;
background-color: var(--primary-color);
}
button.primary,
a.button.primary,
input[type=submit].primary {
color: #fff;
background: var(--primary-color);
}
button.danger,
a.button.danger,
input[type=submit].danger {
color: #d9534f;
border-color: #d9534f;
}
/* Forms */
fieldset {
padding: 0;
border: 0;
}
legend {
width: 100%;
/* margin-top: 2rem;
margin-bottom: 1rem; */
font-size: 1.2rem;
font-weight: bold;
}
input,
textarea {
display: block;
position: relative;
height: 1rem;
padding: .4rem .8rem;
color: #50596c;
font-size: .8rem;
line-height: 1rem;
background-color: #fff;
border: .05rem solid #bbc;
border-radius: .1rem;
transition: all .2s ease;
}
textarea {
min-height: 13.2rem;
}
select {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
min-width: 4rem;
height: 2rem;
padding: .25rem .4rem;
padding-right: 1.2rem;
color: inherit;
font-size: .8rem;
line-height: 1.2rem;
background: #fff url("data:image/svg+xml;charset=utf8,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%204%205'%3E%3Cpath%20fill='%23667189'%20d='M2%200L0%202h4zm0%205L0%203h4z'/%3E%3C/svg%3E") no-repeat right .35rem center/.4rem .5rem;
border: .05rem solid #caced7;
border-radius: .1rem;
outline: 0;
vertical-align: middle;
}
[type="file"] {
display: flex;
}
input:focus,
select:focus {
box-shadow: 0 0 .1rem var(--primary-color);
}
table {
border-collapse: collapse;
border-spacing: 0;
width: 100%;
table-layout: fixed;
border-bottom: 1px solid #aaa;
overflow-x: auto;
}
tr {
height: 30px;
}
td,
th {
padding: 0 5px;
line-height: 1rem;
vertical-align: middle;
text-align: center;
}
td + td {
border-left: 1px solid white;
}
th + td,
td + th {
border-left: 1px solid #aaa;
}
th {
color: #363636;
}
th.person {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
th.product {
width: 15%;
text-align: left;
}
th.price {
width: 5%;
text-align: center;
}
th.amount {
width: 5%;
}
td.total,
th.total {
background-color: #bbb;
}
tr:nth-child(even) {
background-color: #ddd;
}
tr:nth-child(1) {
background-color: #3498db;
}
tr:nth-child(1) * {
color: #f1f1f1;
}
hr {
background-color: #dbdbdb;
border: none;
display: block;
height: 1px;
margin: 1.5rem 0;
}
.carrelage {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px , 1fr));
grid-gap: 10px;
}
.carrelage li {
list-style: none;
border: 1px solid #eee;
text-align: center;
padding: 5px;
}
.carrelage li input {
/*max-width: 80%;*/
}
.carrelage li img {
width: 50%;
}
.notification {
width: 100%;
text-align: center;
color: #f1f1f1;
line-height: : 3rem;
vertical-align: middle;
}
.notification.success {
background-color: #0f8796;
}
.toggle {
display: none;
}
.toggle-label {
cursor: pointer;
}
.toggle-container {
display: none;
position: absolute;
top: calc(50% - 200px);
left: calc(50% - 200px);
height: 400px;
width: 400px;
border: 1px solid #999;
background: white;
padding: 5px;
}
.toggle:checked ~ .toggle-container {
display: block;
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 758 KiB

Binary file not shown.

Binary file not shown.

326
kaba/static/icomoon.css Normal file
View file

@ -0,0 +1,326 @@
@font-face {
font-family: 'icomoon';
src: url('./fonts/icomoon/icomoon.eot?195opb');
src: url('./fonts/icomoon/icomoon.eot?195opb#iefix') format('embedded-opentype'),
url('./fonts/icomoon/icomoon.ttf?195opb') format('truetype'),
url('./fonts/icomoon/icomoon.woff?195opb') format('woff'),
url('./fonts/icomoon/icomoon.svg?195opb#icomoon') format('svg');
font-weight: normal;
font-style: normal;
}
[class^="icon-"], [class*=" icon-"] {
/* use !important to prevent issues with browser extensions that change fonts */
font-family: 'icomoon' !important;
speak: none;
font-style: normal;
font-weight: normal;
font-variant: normal;
text-transform: none;
line-height: 1;
/* Better Font Rendering =========== */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.icon-mobile:before {
content: "\e000";
}
.icon-laptop:before {
content: "\e001";
}
.icon-desktop:before {
content: "\e002";
}
.icon-tablet:before {
content: "\e003";
}
.icon-phone:before {
content: "\e004";
}
.icon-document:before {
content: "\e005";
}
.icon-documents:before {
content: "\e006";
}
.icon-search:before {
content: "\e007";
}
.icon-clipboard:before {
content: "\e008";
}
.icon-newspaper:before {
content: "\e009";
}
.icon-notebook:before {
content: "\e00a";
}
.icon-book-open:before {
content: "\e00b";
}
.icon-browser:before {
content: "\e00c";
}
.icon-calendar:before {
content: "\e00d";
}
.icon-presentation:before {
content: "\e00e";
}
.icon-picture:before {
content: "\e00f";
}
.icon-pictures:before {
content: "\e010";
}
.icon-video:before {
content: "\e011";
}
.icon-camera:before {
content: "\e012";
}
.icon-printer:before {
content: "\e013";
}
.icon-toolbox:before {
content: "\e014";
}
.icon-briefcase:before {
content: "\e015";
}
.icon-wallet:before {
content: "\e016";
}
.icon-gift:before {
content: "\e017";
}
.icon-bargraph:before {
content: "\e018";
}
.icon-grid:before {
content: "\e019";
}
.icon-expand:before {
content: "\e01a";
}
.icon-focus:before {
content: "\e01b";
}
.icon-edit:before {
content: "\e01c";
}
.icon-adjustments:before {
content: "\e01d";
}
.icon-ribbon:before {
content: "\e01e";
}
.icon-hourglass:before {
content: "\e01f";
}
.icon-lock:before {
content: "\e020";
}
.icon-megaphone:before {
content: "\e021";
}
.icon-shield:before {
content: "\e022";
}
.icon-trophy:before {
content: "\e023";
}
.icon-flag:before {
content: "\e024";
}
.icon-map:before {
content: "\e025";
}
.icon-puzzle:before {
content: "\e026";
}
.icon-basket:before {
content: "\e027";
}
.icon-envelope:before {
content: "\e028";
}
.icon-streetsign:before {
content: "\e029";
}
.icon-telescope:before {
content: "\e02a";
}
.icon-gears:before {
content: "\e02b";
}
.icon-key:before {
content: "\e02c";
}
.icon-paperclip:before {
content: "\e02d";
}
.icon-attachment:before {
content: "\e02e";
}
.icon-pricetags:before {
content: "\e02f";
}
.icon-lightbulb:before {
content: "\e030";
}
.icon-layers:before {
content: "\e031";
}
.icon-pencil:before {
content: "\e032";
}
.icon-tools:before {
content: "\e033";
}
.icon-tools-:before {
content: "\e034";
}
.icon-scissors:before {
content: "\e035";
}
.icon-paintbrush:before {
content: "\e036";
}
.icon-magnifying-glass:before {
content: "\e037";
}
.icon-circle-compass:before {
content: "\e038";
}
.icon-linegraph:before {
content: "\e039";
}
.icon-mic:before {
content: "\e03a";
}
.icon-strategy:before {
content: "\e03b";
}
.icon-beaker:before {
content: "\e03c";
}
.icon-caution:before {
content: "\e03d";
}
.icon-recycle:before {
content: "\e03e";
}
.icon-anchor:before {
content: "\e03f";
}
.icon-profile-male:before {
content: "\e040";
}
.icon-profile-female:before {
content: "\e041";
}
.icon-bike:before {
content: "\e042";
}
.icon-wine:before {
content: "\e043";
}
.icon-hotairballoon:before {
content: "\e044";
}
.icon-globe:before {
content: "\e045";
}
.icon-genius:before {
content: "\e046";
}
.icon-map-pin:before {
content: "\e047";
}
.icon-dial:before {
content: "\e048";
}
.icon-chat:before {
content: "\e049";
}
.icon-heart:before {
content: "\e04a";
}
.icon-cloud:before {
content: "\e04b";
}
.icon-upload:before {
content: "\e04c";
}
.icon-download:before {
content: "\e04d";
}
.icon-target:before {
content: "\e04e";
}
.icon-hazardous:before {
content: "\e04f";
}
.icon-piechart:before {
content: "\e050";
}
.icon-speedometer:before {
content: "\e051";
}
.icon-global:before {
content: "\e052";
}
.icon-compass:before {
content: "\e053";
}
.icon-lifesaver:before {
content: "\e054";
}
.icon-clock:before {
content: "\e055";
}
.icon-aperture:before {
content: "\e056";
}
.icon-quote:before {
content: "\e057";
}
.icon-scope:before {
content: "\e058";
}
.icon-alarmclock:before {
content: "\e059";
}
.icon-refresh:before {
content: "\e05a";
}
.icon-happy:before {
content: "\e05b";
}
.icon-sad:before {
content: "\e05c";
}
.icon-facebook:before {
content: "\e05d";
}
.icon-twitter:before {
content: "\e05e";
}
.icon-googleplus:before {
content: "\e05f";
}
.icon-rss:before {
content: "\e060";
}
.icon-tumblr:before {
content: "\e061";
}
.icon-linkedin:before {
content: "\e062";
}
.icon-dribbble:before {
content: "\e063";
}

View file

@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1"
x="0px"
y="0px"
viewBox="0 0 278 285.12503"
id="svg10"
sodipodi:docname="default_product.svg"
width="278"
height="285.12503"
inkscape:version="0.92.4 5da689c313, 2019-01-14">
<metadata
id="metadata16">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs14" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="3840"
inkscape:window-height="2031"
id="namedview12"
showgrid="false"
fit-margin-top="100"
fit-margin-left="100"
fit-margin-right="100"
fit-margin-bottom="100"
inkscape:zoom="1.888"
inkscape:cx="-315.44915"
inkscape:cy="127.94388"
inkscape:window-x="0"
inkscape:window-y="55"
inkscape:window-maximized="1"
inkscape:current-layer="svg10" />
<path
inkscape:connector-curvature="0"
id="path2"
overflow="visible"
display="inline"
visibility="visible"
marker="none"
d="M 139,100 102.53125,115.9062 139,131.8125 175.46875,115.9062 Z m -39,19.1562 v 49.84384 l 37,16.125 V 135.2812 Z m 78,0 -37,16.125 v 49.84384 l 37,-16.125 z"
style="color:#000000;display:inline;overflow:visible;visibility:visible;fill:#666666;fill-opacity:1;stroke:none;enable-background:accumulate" />
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -6,6 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css" type="text/css"> --> <!-- <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css" type="text/css"> -->
<link rel="stylesheet" type="text/css" href="/static/app.css"> <link rel="stylesheet" type="text/css" href="/static/app.css">
<link rel="stylesheet" type="text/css" href="/static/icomoon.css">
{% block head %} {% block head %}
{% endblock head %} {% endblock head %}
@ -15,6 +16,8 @@
{% if message %} {% if message %}
<div class="notification {{ message[1] }}">{{ message[0] }}</div> <div class="notification {{ message[1] }}">{{ message[0] }}</div>
{% endif %} {% endif %}
<h1><a href="/">Panio</a></h1>
<h5>Commandes groupées Épinamap</h5><a href="/livraison/new">Nouvelle livraison</a>
</header> </header>
{% block body %} {% block body %}
{% endblock body %} {% endblock body %}

View file

@ -0,0 +1,51 @@
{% extends "base.html" %}
{% block body %}
<h3>{{ delivery.producer }}</h3>
<p><i class="icon-basket"></i> Description: {{ delivery.description }}</p>
<p><i class="icon-map-pin"></i> Lieu: {{ delivery.where }}</p>
<p><i class="icon-clock"></i> Date: {{ delivery.when }}</p>
<p><i class="icon-alarmclock"></i> Date limite de commande: {{ delivery.order_before.date() }}</p>
<table class="delivery">
<tbody>
<tr>
<th class="product">Produit</th>
<th class="price">Prix</th>
{% for email, order in delivery.orders.items() %}
<th class="person"><a href="/livraison/{{ delivery.id }}/commander?email={{ email }}" title="{{ email }}">{{ email }}</a></th>
{% endfor %}
<th class="amount">Total</th>
</tr>
{% for product in delivery.products %}
<tr>
<th class="product">{{ product.name }}</th>
<td>{{ product.price }} €</td>
{% for email, order in delivery.orders.items() %}
{% if product.ref in order.products %}
<td>{{ order.products[product.ref].wanted }}</td>
{% else %}
<td></td>
{% endif %}
{% endfor %}
<th>{{ delivery.product_wanted(product) }}</th>
</tr>
{% endfor %}
<tr><th class="total"><i class="icon-pricetags"></i> Total</th><td class="total">{{ delivery.total }} €</td>
{% for email, order in delivery.orders.items() %}
<td>{{ order.total(delivery.products) }} €</td>
{% endfor %}
<th></th>
</tr>
</tbody>
</table>
<hr>
<p><a href="/livraison/{{ delivery.id }}/edit">Modifier la livraison (admin)</a></p>
<p><a href="/livraison/{{ delivery.id }}/rapport.xlsx">Générer un rapport</a></p>
<h3>Importer une commande</h3>
<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 une commande">
</form>
{% endblock body %}

View file

@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block body %}
<p><a href="/">&lt; Revenir aux livraisons</a></p>
<h1>Nouvelle livraison</h1>
<form method="post">
<label>
<h5>Producteur</h5>
<input type="text" name="producer" value="{{ delivery.producer or '' }}">
</label>
<label>
<h5>Description</h5>
<input type="text" name="description" value="{{ delivery.description or '' }}">
</label>
<label>
<h5>Lieu</h5>
<input type="text" name="where" value="{{ delivery.where or '' }}">
</label>
<label>
<h5>Date de livraison</h5>
<input type="date" name="when" value="{{ delivery.when.date() if delivery.when else '' }}">
<input type="time" name="when_time" value="{{ delivery.when.time() if delivery.when else '' }}">
</label>
<label>
<h5>Date de limite de commande</h5>
<input type="date" name="order_before" value="{{ delivery.order_before.date() if delivery.order_before else '' }}">
</label>
<div>
<input type="submit" name="submit" value="Valider">
</div>
</form>
{% if delivery %}
<h3>Importer des produits (CSV)</h3>
<p>Colonnes: ref*, name*, price*, description</p>
<form action="/livraison/{{ delivery.id }}/importer/produits" method="post" enctype="multipart/form-data">
<input type="file" name="data">
<input type="submit" name="Importer des produits">
</form>
{% endif %}
{% endblock body %}

View file

@ -13,5 +13,4 @@
<a href="/livraison/{{ delivery.id }}">Résumé de la livraison</a> <a href="/livraison/{{ delivery.id }}">Résumé de la livraison</a>
<hr> <hr>
{% endfor %} {% endfor %}
<a href="/livraison/new">Nouvelle livraison</a>
{% endblock body %} {% endblock body %}

View file

@ -1,7 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block body %} {% block body %}
<p><a href="/">&lt; Commandes</a></p>
<p>Producteur: {{ delivery.producer }} — Lieu: {{ delivery.where }} — Date: {{ delivery.when }} — Date limite de commande: {{ delivery.order_before }}</p> <p>Producteur: {{ delivery.producer }} — Lieu: {{ delivery.where }} — Date: {{ delivery.when }} — Date limite de commande: {{ delivery.order_before }}</p>
<p>Commande de «{{ person }}»</p> <p>Commande de «{{ person }}»</p>
<form method="post"> <form method="post">

5
kaba/utils.py Normal file
View file

@ -0,0 +1,5 @@
from datetime import datetime, timezone
def utcnow():
return datetime.now(timezone.utc)

26
tests/test_config.py Normal file
View file

@ -0,0 +1,26 @@
import os
from pathlib import Path
from kaba import config
def test_config_should_read_from_env():
old_root = os.environ.get("KABA_DATA_ROOT", "")
old_secret = os.environ.get("KABA_SECRET", "")
assert config.SECRET == "sikretfordevonly"
assert isinstance(config.DATA_ROOT, Path)
os.environ["KABA_DATA_ROOT"] = "changeme"
os.environ["KABA_SECRET"] = "ultrasecret"
config.init()
assert config.SECRET == "ultrasecret"
assert isinstance(config.DATA_ROOT, Path)
assert str(config.DATA_ROOT) == "changeme"
if old_root:
os.environ["KABA_DATA_ROOT"] = old_root
else:
del os.environ["KABA_DATA_ROOT"]
if old_secret:
os.environ["KABA_SECRET"] = old_secret
else:
del os.environ["KABA_SECRET"]
config.init()

16
tests/test_views.py Normal file
View file

@ -0,0 +1,16 @@
import pytest
pytestmark = pytest.mark.asyncio
async def test_empty_home(client):
resp = await client.get('/')
assert resp.status == 200
async def test_home_should_list_active_delivery(client, delivery):
delivery.persist()
resp = await client.get('/')
assert resp.status == 200
assert delivery.producer in resp.body.decode()