Compare commits

...

16 Commits

Author SHA1 Message Date
db7de847f2 remove old stuff 2025-08-07 14:27:54 +02:00
ba34b2d647 black backend 2025-08-07 12:09:27 +02:00
684987dfd5 403 and 404 handlers 2025-08-07 12:04:50 +02:00
d1923f640c   2025-08-07 11:43:10 +02:00
d32caa2c38 Store ingredients with stars in the DB 2025-08-06 22:22:46 +02:00
fbb0312c7c Restore sticky footer 2025-08-06 22:16:02 +02:00
5903c6bd36 Django won't serve static files in DEBUG = False mode 2025-08-06 20:00:16 +00:00
767b722025 Fix another CSRF issue
Also turn that DEBUG to False, just for shits & giggles
2025-08-06 21:42:31 +02:00
6b5a43b7a9 Fix autoresizing textareas 2025-08-06 19:22:08 +02:00
0cf8784259 Wider view on desktop 2025-08-06 19:17:40 +02:00
3474239727 Now with edits, delete confirmations
- and also a better alignment on actions
- and konami code that works before login
2025-08-06 19:12:44 +02:00
3d35d47254 Fix small issue with make staticfiles 2025-08-06 15:42:27 +02:00
c219091c2c Add google sign-in
Also, try to be a bit more correct with CSRF and CORS.
It works on my machine.
2025-08-06 15:28:41 +02:00
69327d9edf Add utility scripts 2025-08-05 22:24:57 +02:00
3c6579e7a3 Point frontend to production backend 2025-08-05 22:20:22 +02:00
50df9778e2 This time we have the correct font 2025-08-05 21:42:23 +02:00
25 changed files with 726 additions and 418 deletions

View File

@@ -1,7 +1,7 @@
FROM alpine:3.18 FROM alpine:3.18
RUN apk --no-cache add python3 inkscape bash imagemagick ghostscript font-droid RUN apk --no-cache add python3 inkscape bash imagemagick ghostscript font-noto py3-pip
RUN apk --no-cache add py3-pip && pip3 install --break-system-packages django tzdata gunicorn RUN pip3 install --break-system-packages django tzdata gunicorn google-api-python-client
ADD backend /zetikettes ADD backend /zetikettes

View File

@@ -38,7 +38,7 @@ staticfiles: image ## install all static files
-v $(LIBPREFIX):/libdir \ -v $(LIBPREFIX):/libdir \
-w /libdir \ -w /libdir \
$(image-name) \ $(image-name) \
/zetikettes/zetikettes/manage.py collectstatic /zetikettes/zetikettes/manage.py collectstatic --noinput
.PHONY: help .PHONY: help

View File

@@ -6,7 +6,7 @@ import sys
def main(): def main():
"""Run administrative tasks.""" """Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'zetikettes.settings') os.environ.setdefault("DJANGO_SETTINGS_MODULE", "zetikettes.settings")
try: try:
from django.core.management import execute_from_command_line from django.core.management import execute_from_command_line
except ImportError as exc: except ImportError as exc:
@@ -18,5 +18,5 @@ def main():
execute_from_command_line(sys.argv) execute_from_command_line(sys.argv)
if __name__ == '__main__': if __name__ == "__main__":
main() main()

View File

@@ -2,5 +2,5 @@ from django.apps import AppConfig
class TiketteConfig(AppConfig): class TiketteConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField' default_auto_field = "django.db.models.BigAutoField"
name = 'tikette' name = "tikette"

View File

@@ -0,0 +1,16 @@
from django.core.exceptions import PermissionDenied
from django.http import HttpResponse
def cors_everywhere(get_response):
def middleware(request):
if request.method == "OPTIONS":
response = HttpResponse()
else:
response = get_response(request)
response["Access-Control-Allow-Origin"] = request.headers.get("Origin", "*")
response["Access-Control-Allow-Credentials"] = "true"
response["Access-Control-Allow-Headers"] = "x-csrftoken"
return response
return middleware

View File

@@ -8,64 +8,120 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = []
]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='Tikategory', name="Tikategory",
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('name', models.CharField(max_length=50)), "id",
('landscape', models.BooleanField()), models.BigAutoField(
('prototempalte', models.FileField(upload_to='')), auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=50)),
("landscape", models.BooleanField()),
("prototempalte", models.FileField(upload_to="")),
], ],
options={ options={
'verbose_name_plural': 'tikategoriez', "verbose_name_plural": "tikategoriez",
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='Tisub', name="Tisub",
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('name', models.CharField(max_length=50)), "id",
('descritpion', models.TextField()), models.BigAutoField(
('default', models.TextField()), auto_created=True,
('type', models.CharField(choices=[('ST', 'Short Text'), ('LT', 'Long Text'), ('C', 'Color'), ('YM', 'Year Month'), ('B', 'Boolean')], default='ST', max_length=2)), primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=50)),
("descritpion", models.TextField()),
("default", models.TextField()),
(
"type",
models.CharField(
choices=[
("ST", "Short Text"),
("LT", "Long Text"),
("C", "Color"),
("YM", "Year Month"),
("B", "Boolean"),
],
default="ST",
max_length=2,
),
),
], ],
options={ options={
'verbose_name_plural': 'tisubz', "verbose_name_plural": "tisubz",
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='Tizer', name="Tizer",
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('email', models.CharField()), "id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("email", models.CharField()),
], ],
options={ options={
'verbose_name_plural': 'tizerz', "verbose_name_plural": "tizerz",
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='Tikette', name="Tikette",
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('title', models.CharField(max_length=100)), "id",
('designation', models.CharField(max_length=100)), models.BigAutoField(
('ingredients', models.TextField()), auto_created=True,
('description', models.TextField()), primary_key=True,
('color', models.CharField(max_length=6)), serialize=False,
('ab', models.CharField(choices=[('inline', 'Visible'), ('none', 'Invisible')], max_length=7)), verbose_name="ID",
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tikette.tikategory')), ),
),
("title", models.CharField(max_length=100)),
("designation", models.CharField(max_length=100)),
("ingredients", models.TextField()),
("description", models.TextField()),
("color", models.CharField(max_length=6)),
(
"ab",
models.CharField(
choices=[("inline", "Visible"), ("none", "Invisible")],
max_length=7,
),
),
(
"category",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="tikette.tikategory",
),
),
], ],
options={ options={
'verbose_name_plural': 'tikettz', "verbose_name_plural": "tikettz",
}, },
), ),
migrations.AddField( migrations.AddField(
model_name='tikategory', model_name="tikategory",
name='subs', name="subs",
field=models.ManyToManyField(to='tikette.tisub'), field=models.ManyToManyField(to="tikette.tisub"),
), ),
] ]

View File

@@ -1,5 +1,6 @@
from django.db import models from django.db import models
class Tisub(models.Model): class Tisub(models.Model):
class Type(models.TextChoices): class Type(models.TextChoices):
SHORT_TEXT = "ST" SHORT_TEXT = "ST"

View File

@@ -11,13 +11,18 @@ DESIGNATION_FONTSIZE = 42.6667
def inkscapize(svg_in, pdf_out): def inkscapize(svg_in, pdf_out):
png_out = tempfile.mktemp(suffix='.png') png_out = tempfile.mktemp(suffix=".png")
try: try:
cmd = ['inkscape', '--export-type=png', f'--export-filename={png_out}', cmd = [
f'--export-dpi={DEFAULT_DPI}', svg_in] "inkscape",
"--export-type=png",
f"--export-filename={png_out}",
f"--export-dpi={DEFAULT_DPI}",
svg_in,
]
subprocess.check_call(cmd) subprocess.check_call(cmd)
cmd = ['convert', png_out, pdf_out] cmd = ["convert", png_out, pdf_out]
subprocess.check_call(cmd) subprocess.check_call(cmd)
finally: finally:
if os.path.exists(png_out): if os.path.exists(png_out):
@@ -34,24 +39,25 @@ def generate(template, subs, out_dir, landscape=False):
""" """
# default designation font size # default designation font size
subs['designation_fontsize'] = subs.get('designation_fontsize', subs["designation_fontsize"] = subs.get(
DESIGNATION_FONTSIZE) "designation_fontsize", DESIGNATION_FONTSIZE
)
# fill in sticker details # fill in sticker details
sticker_out = tempfile.mktemp(suffix='.svg') sticker_out = tempfile.mktemp(suffix=".svg")
try: try:
with open(sticker_out, 'w') as stickout: with open(sticker_out, "w") as stickout:
makesticker(os.path.join(out_dir, template), stickout, subs) makesticker(os.path.join(out_dir, template), stickout, subs)
# make sticker sheet # make sticker sheet
planche_out = tempfile.mktemp(suffix='.svg') planche_out = tempfile.mktemp(suffix=".svg")
with open(sticker_out, 'r') as stickin: with open(sticker_out, "r") as stickin:
with open(planche_out, 'w') as planchout: with open(planche_out, "w") as planchout:
makeplanche(stickin, planchout, landscape=landscape) makeplanche(stickin, planchout, landscape=landscape)
# process to printable pdf # process to printable pdf
pdf_out = tempfile.mktemp(dir=out_dir, suffix='.pdf') pdf_out = tempfile.mktemp(dir=out_dir, suffix=".pdf")
inkscapize(planche_out, pdf_out) inkscapize(planche_out, pdf_out)
return os.path.basename(pdf_out) return os.path.basename(pdf_out)
@@ -59,5 +65,5 @@ def generate(template, subs, out_dir, landscape=False):
finally: finally:
if os.path.exists(sticker_out): if os.path.exists(sticker_out):
os.unlink(sticker_out) os.unlink(sticker_out)
if 'planche_out' in locals() and os.path.exists(planche_out): if "planche_out" in locals() and os.path.exists(planche_out):
os.unlink(planche_out) os.unlink(planche_out)

View File

@@ -5,29 +5,36 @@ from pathlib import Path
from string import Template from string import Template
import sys import sys
DEFAULT_SHEET_TEMPLATE = Path(__file__).parent / 'planche.svg.in' DEFAULT_SHEET_TEMPLATE = Path(__file__).parent / "planche.svg.in"
def parse_args(): def parse_args():
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(description="Make a sheet with 12 stickers")
description='Make a sheet with 12 stickers') parser.add_argument(
parser.add_argument('--template', "--template",
'-t', "-t",
default=DEFAULT_SHEET_TEMPLATE, default=DEFAULT_SHEET_TEMPLATE,
help='path to the sheet template') help="path to the sheet template",
parser.add_argument('--out', )
'-o', parser.add_argument(
"--out",
"-o",
default=sys.stdout, default=sys.stdout,
type=argparse.FileType('w'), type=argparse.FileType("w"),
help='output path (default: stdout)') help="output path (default: stdout)",
parser.add_argument('--landscape', )
action='store_true', parser.add_argument(
help='input sticker is in landscape orientation') "--landscape",
parser.add_argument('sticker', action="store_true",
type=argparse.FileType('r'), help="input sticker is in landscape orientation",
)
parser.add_argument(
"sticker",
type=argparse.FileType("r"),
default=sys.stdin, default=sys.stdin,
nargs='?', nargs="?",
help='path to the sticker SVG (default: stdin)') help="path to the sticker SVG (default: stdin)",
)
return parser.parse_args() return parser.parse_args()
@@ -37,15 +44,15 @@ def makeplanche(sticker, out, template=DEFAULT_SHEET_TEMPLATE ,landscape=False):
tpl_data = tpl.read() tpl_data = tpl.read()
lines = sticker.readlines() lines = sticker.readlines()
if lines[0].startswith('<?xml'): if lines[0].startswith("<?xml"):
lines = lines[1:] lines = lines[1:]
sticker_data = ''.join(lines) sticker_data = "".join(lines)
rotate = "translate(102, 0) rotate(90)" if not landscape else "" rotate = "translate(102, 0) rotate(90)" if not landscape else ""
subs = { subs = {
'sticker': sticker_data, "sticker": sticker_data,
'rotate': rotate, "rotate": rotate,
} }
out.write(Template(tpl_data).substitute(subs)) out.write(Template(tpl_data).substitute(subs))

View File

@@ -7,22 +7,25 @@ import typing
def parse_args(): def parse_args():
parser = argparse.ArgumentParser(description='Fill in sticker details') parser = argparse.ArgumentParser(description="Fill in sticker details")
parser.add_argument('--out', parser.add_argument(
'-o', "--out",
"-o",
default=sys.stdout, default=sys.stdout,
type=argparse.FileType('w'), type=argparse.FileType("w"),
help='output path (default: stdout)') help="output path (default: stdout)",
parser.add_argument('--dluo', )
'-d', parser.add_argument(
required=True, "--dluo", "-d", required=True, help="Date Limite d'Utilisation Optimale"
help='Date Limite d\'Utilisation Optimale') )
parser.add_argument('--lot', '-l', required=True, help='Numéro de lot') parser.add_argument("--lot", "-l", required=True, help="Numéro de lot")
parser.add_argument('--quantite', '-q', required=False, help='Quantité (volume ou masse)') parser.add_argument(
parser.add_argument('--teneur', '-t', required=False, help='Teneur en sucre') "--quantite", "-q", required=False, help="Quantité (volume ou masse)"
parser.add_argument('--fruit', '-f', required=False, help='Quantité de fruits') )
parser.add_argument('--size', '-s', required=False, help='Masse de produit') parser.add_argument("--teneur", "-t", required=False, help="Teneur en sucre")
parser.add_argument('sticker', help='path to the sticker template') parser.add_argument("--fruit", "-f", required=False, help="Quantité de fruits")
parser.add_argument("--size", "-s", required=False, help="Masse de produit")
parser.add_argument("sticker", help="path to the sticker template")
return parser.parse_args() return parser.parse_args()
@@ -30,9 +33,9 @@ def parse_args():
def makesticker(sticker: str, out: typing.IO, subs: dict): def makesticker(sticker: str, out: typing.IO, subs: dict):
with open(sticker) as fin: with open(sticker) as fin:
lines = fin.readlines() lines = fin.readlines()
if lines[0].startswith('<?xml'): if lines[0].startswith("<?xml"):
lines = lines[1:] lines = lines[1:]
sticker_data = ''.join(lines) sticker_data = "".join(lines)
out.write(Template(sticker_data).substitute(subs)) out.write(Template(sticker_data).substitute(subs))
@@ -40,12 +43,12 @@ def makesticker(sticker: str, out: typing.IO, subs: dict):
if __name__ == "__main__": if __name__ == "__main__":
args = vars(parse_args()) args = vars(parse_args())
subs = { subs = {
'dluo': args.pop('dluo'), "dluo": args.pop("dluo"),
'lot': args.pop('lot'), "lot": args.pop("lot"),
'teneur': args.pop('teneur'), "teneur": args.pop("teneur"),
'f': args.pop('fruit'), "f": args.pop("fruit"),
'q': args.pop('quantite'), "q": args.pop("quantite"),
'size': args.pop('size'), "size": args.pop("size"),
} }
args['subs'] = subs args["subs"] = subs
makesticker(**args) makesticker(**args)

View File

@@ -1,87 +1,164 @@
import json import json
import sys
from django.conf import settings from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.http import JsonResponse from django.http import JsonResponse
from django.shortcuts import render from django.shortcuts import render
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie
from google.auth.transport import requests
from google.oauth2 import id_token
from .planche import generate as stickersheet from .planche import generate as stickersheet
from .models import Tikette, Tikategory from .models import Tikette, Tikategory, Tizer
CORS={'access-control-allow-origin': '*'}
def ok(payload=None):
return JsonResponse({"status": "ok", **(payload or {})})
def post_please(f):
def __f(request):
if request.method != "POST":
return JsonResponse(
{"status": "notok", "message": "this isn't the way"},
status=405,
headers={"Allow": "POST"},
)
return f(request)
return __f
def auth_only(f):
def __f(request):
# check that email is valid
# exp?
if "user_data" not in request.session:
raise PermissionDenied("Not logged in")
email = request.session["user_data"]["email"]
resp = Tizer.objects.filter(email=email)
if not resp:
raise PermissionDenied("User not found")
return f(request)
return __f
def quirk_bold_allergens(ingredients): def quirk_bold_allergens(ingredients):
out = [] out = []
for ing in (x.strip() for x in ingredients.split(',')): for ing in (x.strip() for x in ingredients.split(",")):
if ing.startswith('*'): if ing.startswith("*"):
out.append(f'<tspan style="font-weight:bold">{ing[1:]}</tspan>') out.append(f'<tspan style="font-weight:bold">{ing[1:]}</tspan>')
else: else:
out.append(ing) out.append(ing)
return ", ".join(out) return ", ".join(out)
def handler403(request, exception):
return JsonResponse({"status": "notok", "message": "permission denied"}, status=403)
def handler404(request, exception):
return JsonResponse(
{"status": "notok", "message": "endpoint not found"}, status=404
)
@auth_only
@ensure_csrf_cookie
def get_list(request): def get_list(request):
tikettes = [{ tikettes = [
'id': x.id, {
'title': x.title, "id": x.id,
'category': x.category.name, "title": x.title,
'prototempalte': x.category.prototempalte.name, "category": x.category.name,
'landscape': x.category.landscape, "category_id": x.category.id,
'designation': x.designation, "prototempalte": x.category.prototempalte.name,
'ingredients': quirk_bold_allergens(x.ingredients), "landscape": x.category.landscape,
'description': x.description, "designation": x.designation,
'ab': x.ab, "ingredients": x.ingredients,
'color': x.color, "description": x.description,
'subs': {x.name: x.default for x in x.category.subs.all()}, "ab": x.ab,
} for x in Tikette.objects.all()] "color": x.color,
return JsonResponse({'status': 'ok', 'tikettes': tikettes}, headers=CORS) "subs": {x.name: x.default for x in x.category.subs.all()},
}
for x in Tikette.objects.all()
]
return ok({"tikettes": tikettes})
@auth_only
def get_categories(request): def get_categories(request):
tikats = [{ tikats = [
'id': x.id, {
'name': x.name, "id": x.id,
} for x in Tikategory.objects.all()] "name": x.name,
return JsonResponse({'status': 'ok', 'tikats': tikats}, headers=CORS) }
for x in Tikategory.objects.all()
]
return ok({"tikats": tikats})
@csrf_exempt @auth_only
@post_please
def generate(request): def generate(request):
if request.method != "POST":
return JsonResponse({'status': 'notok', 'message': 'this isn\'t the way'})
payload = json.loads(request.body) payload = json.loads(request.body)
subs = dict(payload['subs']) subs = dict(payload["subs"])
for key in ('designation', 'ingredients', 'description', 'color', 'AB'): for key in ("designation", "ingredients", "description", "color", "AB"):
subs[key] = payload[key] subs[key] = payload[key]
pdfpath = stickersheet.generate(template=payload['template'], subs=subs, subs["ingredients"] = quirk_bold_allergens(subs["ingredients"])
pdfpath = stickersheet.generate(
template=payload["template"],
subs=subs,
out_dir=settings.TIKETTE_OUT_DIR, out_dir=settings.TIKETTE_OUT_DIR,
landscape=payload['landscape']) landscape=payload["landscape"],
)
return JsonResponse({'status': 'ok', 'file': pdfpath}, headers=CORS) return ok({"file": pdfpath})
@csrf_exempt @auth_only
@post_please
def newtikette(request): def newtikette(request):
if request.method != "POST":
return JsonResponse({'status': 'notok', 'message': 'this isn\'t the way'})
payload = json.loads(request.body) payload = json.loads(request.body)
tikette = Tikette(**payload) tikette = Tikette(**payload)
tikette.save() tikette.save()
return JsonResponse({'status': 'ok'}, headers=CORS) return ok()
@csrf_exempt @auth_only
@post_please
def deletetikette(request): def deletetikette(request):
if request.method != "POST":
return JsonResponse({'status': 'notok', 'message': 'this isn\'t the way'})
payload = json.loads(request.body) payload = json.loads(request.body)
Tikette.objects.get(id=payload["id"]).delete() Tikette.objects.get(id=payload["id"]).delete()
return JsonResponse({'status': 'ok'}, headers=CORS) return ok()
@csrf_exempt
@post_please
def signin(request):
payload = json.loads(request.body)
token = payload["token"]
try:
user_data = id_token.verify_oauth2_token(
token, requests.Request(), settings.GOOGLE_OAUTH_CLIENT_ID
)
except ValueError:
raise
return JsonResponse({"status": "notok"}, status=403)
request.session["user_data"] = user_data
return ok(user_data)
def signout(request):
del request.session["user_data"]
return ok()

View File

@@ -11,6 +11,6 @@ import os
from django.core.asgi import get_asgi_application from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'zetikettes.settings') os.environ.setdefault("DJANGO_SETTINGS_MODULE", "zetikettes.settings")
application = get_asgi_application() application = get_asgi_application()

View File

@@ -16,73 +16,74 @@ from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
# TIKETTE_OUT_DIR = BASE_DIR / 'data' # TIKETTE_OUT_DIR = BASE_DIR / 'data'
TIKETTE_OUT_DIR = Path('/data') TIKETTE_OUT_DIR = Path("/data")
MEDIA_ROOT = TIKETTE_OUT_DIR MEDIA_ROOT = TIKETTE_OUT_DIR
MEDIA_URL = '/data/' MEDIA_URL = "/data/"
# Quick-start development settings - unsuitable for production # Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ # See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret! # SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-64qxpe55#9wy=5@#dl0)3w7ywxh48m!f&!slp9e7v4lh@hjdct' SECRET_KEY = "django-insecure-64qxpe55#9wy=5@#dl0)3w7ywxh48m!f&!slp9e7v4lh@hjdct"
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True DEBUG = False
ALLOWED_HOSTS = ['*'] ALLOWED_HOSTS = ["*"]
CSRF_TRUSTED_ORIGINS = ['https://*.ponteilla.net'] CSRF_TRUSTED_ORIGINS = ["https://*.ponteilla.net"]
# Application definition # Application definition
INSTALLED_APPS = [ INSTALLED_APPS = [
'django.contrib.admin', "django.contrib.admin",
'django.contrib.auth', "django.contrib.auth",
'django.contrib.contenttypes', "django.contrib.contenttypes",
'django.contrib.sessions', "django.contrib.sessions",
'django.contrib.messages', "django.contrib.messages",
'django.contrib.staticfiles', "django.contrib.staticfiles",
'tikette', "tikette",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware', "django.middleware.security.SecurityMiddleware",
'django.contrib.sessions.middleware.SessionMiddleware', "django.contrib.sessions.middleware.SessionMiddleware",
'django.middleware.common.CommonMiddleware', "django.middleware.common.CommonMiddleware",
'django.middleware.csrf.CsrfViewMiddleware', "django.middleware.csrf.CsrfViewMiddleware",
'django.contrib.auth.middleware.AuthenticationMiddleware', "django.contrib.auth.middleware.AuthenticationMiddleware",
'django.contrib.messages.middleware.MessageMiddleware', "django.contrib.messages.middleware.MessageMiddleware",
'django.middleware.clickjacking.XFrameOptionsMiddleware', "django.middleware.clickjacking.XFrameOptionsMiddleware",
"tikette.middleware.cors_everywhere",
] ]
ROOT_URLCONF = 'zetikettes.urls' ROOT_URLCONF = "zetikettes.urls"
TEMPLATES = [ TEMPLATES = [
{ {
'BACKEND': 'django.template.backends.django.DjangoTemplates', "BACKEND": "django.template.backends.django.DjangoTemplates",
'DIRS': [], "DIRS": [],
'APP_DIRS': True, "APP_DIRS": True,
'OPTIONS': { "OPTIONS": {
'context_processors': [ "context_processors": [
'django.template.context_processors.debug', "django.template.context_processors.debug",
'django.template.context_processors.request', "django.template.context_processors.request",
'django.contrib.auth.context_processors.auth', "django.contrib.auth.context_processors.auth",
'django.contrib.messages.context_processors.messages', "django.contrib.messages.context_processors.messages",
], ],
}, },
}, },
] ]
WSGI_APPLICATION = 'zetikettes.wsgi.application' WSGI_APPLICATION = "zetikettes.wsgi.application"
# Database # Database
# https://docs.djangoproject.com/en/4.2/ref/settings/#databases # https://docs.djangoproject.com/en/4.2/ref/settings/#databases
DATABASES = { DATABASES = {
'default': { "default": {
'ENGINE': 'django.db.backends.sqlite3', "ENGINE": "django.db.backends.sqlite3",
'NAME': TIKETTE_OUT_DIR / 'db.sqlite3', "NAME": TIKETTE_OUT_DIR / "db.sqlite3",
} }
} }
@@ -92,16 +93,16 @@ DATABASES = {
AUTH_PASSWORD_VALIDATORS = [ AUTH_PASSWORD_VALIDATORS = [
{ {
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
}, },
{ {
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
}, },
{ {
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
}, },
{ {
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
}, },
] ]
@@ -109,9 +110,9 @@ AUTH_PASSWORD_VALIDATORS = [
# Internationalization # Internationalization
# https://docs.djangoproject.com/en/4.2/topics/i18n/ # https://docs.djangoproject.com/en/4.2/topics/i18n/
LANGUAGE_CODE = 'en-us' LANGUAGE_CODE = "en-us"
TIME_ZONE = 'UTC' TIME_ZONE = "UTC"
USE_I18N = True USE_I18N = True
@@ -121,11 +122,16 @@ USE_TZ = True
# Static files (CSS, JavaScript, Images) # Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/4.2/howto/static-files/ # https://docs.djangoproject.com/en/4.2/howto/static-files/
STATIC_URL = '/zetikettes/srv/static/' STATIC_URL = "/zetikettes/srv/static/"
STATIC_ROOT = 'www_static' STATIC_ROOT = "www_static"
# Default primary key field type # Default primary key field type
# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field # https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# not a secret
GOOGLE_OAUTH_CLIENT_ID = (
"634510965520-c5l7f15fn4koraqhpqfe01ssn8v0q2qk.apps.googleusercontent.com"
)

View File

@@ -14,6 +14,7 @@ Including another URLconf
1. Import the include() function: from django.urls import include, path 1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
""" """
from django.conf import settings from django.conf import settings
from django.conf.urls.static import static from django.conf.urls.static import static
from django.contrib import admin from django.contrib import admin
@@ -23,10 +24,16 @@ from django.views.generic.base import TemplateView
import tikette.views import tikette.views
urlpatterns = [ urlpatterns = [
path('admin/', admin.site.urls), path("admin/", admin.site.urls),
path('list', tikette.views.get_list), path("list", tikette.views.get_list),
path('categories', tikette.views.get_categories), path("categories", tikette.views.get_categories),
path('generate', tikette.views.generate), path("generate", tikette.views.generate),
path('newtikette', tikette.views.newtikette), path("newtikette", tikette.views.newtikette),
path('deletetikette', tikette.views.deletetikette), path("deletetikette", tikette.views.deletetikette),
path("updatetikette", tikette.views.newtikette), # yes, we use newtikette
path("signin", tikette.views.signin),
path("signout", tikette.views.signout),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
handler403 = "tikette.views.handler403"
handler404 = "tikette.views.handler404"

View File

@@ -11,6 +11,6 @@ import os
from django.core.wsgi import get_wsgi_application from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'zetikettes.settings') os.environ.setdefault("DJANGO_SETTINGS_MODULE", "zetikettes.settings")
application = get_wsgi_application() application = get_wsgi_application()

View File

@@ -1 +1,2 @@
const backend_api = 'http://localhost:8000/' const backend_api = "/zetikettes/srv/"
const google_oauth_client_id = '634510965520-c5l7f15fn4koraqhpqfe01ssn8v0q2qk.apps.googleusercontent.com';

View File

@@ -14,21 +14,33 @@
<!-- Compiled and minified JavaScript --> <!-- Compiled and minified JavaScript -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js"></script>
<script src="https://accounts.google.com/gsi/client"></script>
<script src="jscolor.min.js"></script> <script src="jscolor.min.js"></script>
<script src="config.js"></script> <script src="config.js"></script>
<script src="zetikettes.js"></script> <script src="zetikettes.js"></script>
<style> <style>
/* for the footer */
body { body {
display: flex; display: flex;
min-height: 100vh; min-height: 100vh;
flex-direction: column; flex-direction: column;
} }
main { main {
flex: 1 0 auto; flex: 1 0 auto;
} }
.actions {
margin-left: auto;
white-space: nowrap;
}
.confirm {
width: 35%;
min-width: 20em;
max-width: 85%;
}
</style> </style>
</head> </head>
<body> <body>
@@ -37,7 +49,7 @@ main {
<a href="#" class="brand-logo">Zětikwett's</a> <a href="#" class="brand-logo">Zětikwett's</a>
<ul id="nav-mobile" class="right hide-on-med-and-down"> <ul id="nav-mobile" class="right hide-on-med-and-down">
<li> <li>
<a class="modal-trigger btn orange" href="#newproduct"> <a class="modal-trigger btn orange disabled" href="#newproduct">
<i class="material-icons left">add</i>Nouveau produit <i class="material-icons left">add</i>Nouveau produit
</a> </a>
</li> </li>
@@ -46,9 +58,10 @@ main {
</nav> </nav>
<main class="container row"> <main class="container row">
<p></p> <p></p>
<div class="col m6 offset-m3 s12"> <div class="col s4 offset-s4" id="signin-prompt" style="display: none"></div>
<a class="modal-trigger btn orange hide-on-large-only" href="#newproduct"><i class="material-icons left">add</i>Nouveau produit</a> <div class="col m10 offset-m1 s12">
<ul class="collapsible" id="appbody"> <a class="modal-trigger btn orange hide-on-large-only disabled" href="#newproduct"><i class="material-icons left">add</i>Nouveau produit</a>
<ul class="collapsible collection" id="appbody">
<div class="shimmer"> <div class="shimmer">
<li><div class="collapsible-header"><h6 class="faux-text short"></h6></div></li> <li><div class="collapsible-header"><h6 class="faux-text short"></h6></div></li>
<li><div class="collapsible-header"><h6 class="faux-text"></h6></div></li> <li><div class="collapsible-header"><h6 class="faux-text"></h6></div></li>
@@ -56,6 +69,15 @@ main {
</div> </div>
</ul> </ul>
</div> </div>
<div id="confirmdelete" class="modal confirm">
<div class="modal-content">
Supprimer le produit <b id="delete-title">blařg titre long de test sa mère</b>&nbsp;?
</div>
<div class="modal-footer">
<button class="modal-close waves-effect btn-flat" id="delete-cancel">annuler</button>
<button type="submit" class="modal-close waves-effect waves-green btn" id="delete-confirm">supprimer</button>
</div>
</div>
<!-- Modal Structure --> <!-- Modal Structure -->
<div id="newproduct" class="modal"> <div id="newproduct" class="modal">

View File

@@ -1,45 +0,0 @@
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>zetikettes 0.2</title>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<!-- Compiled and minified CSS -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css">
<!-- Compiled and minified JavaScript -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js"></script>
<script src="config.js"></script>
<script src="newtikette.js"></script>
<style>
body {
display: flex;
min-height: 100vh;
flex-direction: column;
}
main {
flex: 1 0 auto;
}
</style>
</head>
<body>
<header class="container center orange-text">
<h1>Zétikwett's</h1>
</header>
<main class="container row">
<div class="col m6 offset-m3 s12">
<div id="appbody" class="container"></div>
</div>
</main>
<footer class="page-footer">
<div class="container row">
<span class="col right">displayed with recycled electrons.</span>
</div>
</footer>
</body>
</html>

View File

@@ -1,125 +0,0 @@
const params = [
['dluo', 'DLUO', true],
['lot', 'Nº de lot', true],
['qty', 'Poids net (g)', true],
['teneur', 'Teneur en fruits (%)', false],
['fruit', 'Quantité de fruits pour 100g (g)', false],
]
function post() {
const lemot = $('input[type=password]').val();
const formdata = new FormData($('form')[0]);
formdata.append('lemotdepasse', lemot);
$.ajax({
url: backend_api + `newtikette`,
type: 'POST',
data: formdata,
contentType: false,
processData: false,
xhr: function () {
var myXhr = $.ajaxSettings.xhr();
if (myXhr.upload) {
// For handling the progress of the upload
myXhr.upload.addEventListener('progress', function (e) {
if (e.lengthComputable) {
$('progress').attr({
value: e.loaded,
max: e.total,
});
}
}, false);
}
return myXhr;
}
})
.then(data => {
console.log(data);
})
.catch(err => {
console.log(err);
if (err.status == 403) {
M.toast({html: 'access denied'});
} else if (err.status == 500) {
M.toast({html: 'server error'});
}
})
.always(() => {
$('.progress').hide();
$('.btn').removeClass('disabled');
});
}
$(document).ready(async () => {
const appbody = $("#appbody");
const block = $('<form class="section">');
block.append($(`<div class="input-field"><label class="active">Title</label>
<input type="text" name="title" value="Gelée de brandade aux cêpes biou">`));
block.append($(`<div class="input-field"><label class="active">
<input type="checkbox" name="landscape"><span>landscape</span></label>`));
const subst = $('<div class="section"><label class="active">Substitutions</label><p></p>');
for (let param of params) {
subst.append($(`<div><label class="active"><input type="checkbox" name="${param[0]}" ${param[2] ? 'checked': ''}>
<span>\${${param[0]}}: ${param[1]}</span></label>`));
}
block.append(subst);
block.append(`<div class="file-field input-field">
<div class="btn">
<span>File</span>
<input type="file" name="sticker" accept=".svg">
</div>
<div class="file-path-wrapper">
<input class="file-path validate" type="text" placeholder="Template .svg file">
</div>
`)
const loader = $('<div class="progress"><div class="determinate"></div></div>')
.hide();
let setup = false;
const action = $('<div class="section">')
.append($('<a class="btn">add newtikette<a>')
.click(() => {
if (!setup) {
setup = true;
$('input[type=password]').on('keydown', e => {
if (e.keyCode == 13 && !e.repeat) {
$('.modal').modal('close');
post();
}
});
$("#pushgo").click(() => { post(); });
}
$('.modal').modal('open');
$('input[type=password]').focus();
loader.show();
$('.btn').addClass("disabled");
$('.modal-close').removeClass("disabled");
})
.append(loader));
const lemotdepasse = $(`
<div id="modal1" class="modal">
<div class="modal-content">
<h4>A lemotdepasse is needed</h4>
<div class="input-field"><label class="active">lemotdepasse</label>
<input type="password">
</div>
<div class="modal-footer">
<a class="btn modal-close" id="pushgo">go</a>
</div>
`);
appbody
.append(lemotdepasse)
.append(block)
.append(action);
$(".modal").modal();
});

View File

@@ -8,6 +8,82 @@ const params = {
var tikats; var tikats;
function getCookie(name) {
const cookies = document.cookie.split(';');
for (let cookie of cookies) {
cookie = cookie.trim();
if (cookie.startsWith(name + '=')) {
return cookie.substring(name.length + 1);
}
}
return null;
}
function post(url, data) {
const csrf_token = getCookie('csrftoken');
return $.ajax({
url,
data: JSON.stringify(data),
method: 'POST',
xhrFields: { withCredentials: true },
headers: { 'X-CSRFToken': csrf_token },
});
}
function disableButtons() {
$('.btn').addClass("disabled");
}
function enableButtons() {
$('.btn').removeClass('disabled');
}
function resetEditModal(force = false) {
if ($("#new-add").text() === "Ajouter" && !force) {
return;
}
$("#new-name").val("");
$("#new-type").val("").formSelect();
$("#new-designation").val("");
$("#new-ingredients").val("");
$("#new-description").val("");
$("#new-color")[0].jscolor.fromString("#97a1cc");
$("#new-organic").prop("checked", false);
M.updateTextFields();
M.textareaAutoResize($('#new-ingredients'));
M.textareaAutoResize($('#new-description'));
$("#new-add").text("Ajouter").off('click').click(() => {
const req = getTiketteData();
post(backend_api + 'newtikette', req).then(() => {
resetEditModal(true);
reload();
});
});
}
function openEditModal(zett) {
M.Modal.getInstance($("#newproduct")).open();
$("#new-name").val(zett.title);
$("#new-type").val(zett.category_id).formSelect();
$("#new-designation").val(zett.designation);
$("#new-ingredients").val(zett.ingredients);
$("#new-description").val(zett.description);
$("#new-color")[0].jscolor.fromString(zett.color);
$("#new-organic").prop("checked", zett.ab === "inline");
M.updateTextFields();
M.textareaAutoResize($('#new-ingredients'));
M.textareaAutoResize($('#new-description'));
$("#new-add").text("Modifier").off('click').click(() => {
const req = getTiketteData();
req.id = zett.id;
resetEditModal();
post(backend_api + 'updatetikette', req).then(reload);
});
}
function addProduct(tikette) { function addProduct(tikette) {
const zett = tikette; const zett = tikette;
const appbody = $("#appbody"); const appbody = $("#appbody");
@@ -19,7 +95,7 @@ function addProduct(tikette) {
.hide(); .hide();
const action = $('<div class="section">') const action = $('<div class="section">')
.append($('<a class="btn">generate<a>') .append($('<a class="btn">générer<a>')
.click(() => { .click(() => {
const subs = block.find(':text') const subs = block.find(':text')
.toArray() .toArray()
@@ -36,11 +112,11 @@ function addProduct(tikette) {
}; };
loader.show(); loader.show();
$('.btn').addClass("disabled"); disableButtons();
$.post(backend_api + 'generate', JSON.stringify(req)) post(backend_api + 'generate', req)
.then(data => { .then(data => {
const pdfbtn = $(`<a class="btn" href="${backend_api}data/${data.file}" target="_blank">open pdf</a>`); const pdfbtn = $(`<a class="btn" href="${backend_api}data/${data.file}" target="_blank">ouvrir le pdf</a>`);
action.append(pdfbtn); action.append(pdfbtn);
}) })
.catch(err => { .catch(err => {
@@ -48,27 +124,37 @@ function addProduct(tikette) {
}) })
.always(() => { .always(() => {
loader.hide(); loader.hide();
$('.btn').removeClass('disabled'); enableButtons();
}); });
}) })
.append(loader)); .append(loader));
const deleteAction = $('<a class="btn-flat grey-text"><b class="material-icons">delete</b>'); const deleteAction = $('<b class="material-icons">delete</b>').click(() => {
deleteAction.click(() => { $("#delete-title").text(zett.title);
$("#delete-confirm").off('click').click(() => {
const req = { const req = {
id: zett.id, id: zett.id,
}; };
$.post(backend_api + 'deletetikette', JSON.stringify(req)).then(reload); post(backend_api + 'deletetikette', req).then(reload);
});
M.Modal.getInstance($("#confirmdelete")).open();
return false; return false;
}); });
const editAction = $('<b class="material-icons">edit</b>').click(() => {
openEditModal(zett);
return false;
});
appbody appbody
.append($('<li>') .append($('<li>')
.append($('<div class="collapsible-header valign-wrapper">') .append($('<div class="collapsible-header valign-wrapper">')
.append(`<h6 class="blue-text">${zett.title}</h6>`) .append(`<h6 class="blue-text">${zett.title}</h6>`)
.append($('<span class="badge">') .append($('<span class="actions grey-text">')
.append(deleteAction))) .append(editAction)
.append(deleteAction)
))
.append($('<div class="collapsible-body">') .append($('<div class="collapsible-body">')
.append(block) .append(block)
.append(action))); .append(action)));
@@ -81,6 +167,28 @@ function setCategories() {
for (let kat of tikats) { for (let kat of tikats) {
katsel.append($(`<option value="${kat.id}">${kat.name}</option>`)); katsel.append($(`<option value="${kat.id}">${kat.name}</option>`));
} }
$('select').formSelect();
}
function getTiketteData() {
const title = $("#new-name").val();
const category_id = $("#new-type").val();
const designation = $("#new-designation").val();
const ingredients = $("#new-ingredients").val();
const description = $("#new-description").val();
const color = $("#new-color").val().substring(1);
const ab = $("#new-organic").is(":checked") ? 'inline' : 'none';
return {
title,
category_id,
designation,
ingredients,
description,
color,
ab,
};
} }
function loadAll(zetikettes) { function loadAll(zetikettes) {
@@ -90,33 +198,6 @@ function loadAll(zetikettes) {
addProduct(zett); addProduct(zett);
} }
setCategories(); setCategories();
$('.collapsible').collapsible();
$('.modal').modal();
$('select').formSelect();
konami();
$('#new-add').off('click').click(() => {
const title = $("#new-name").val();
const category_id = $("#new-type").val();
const designation = $("#new-designation").val();
const ingredients = $("#new-ingredients").val();
const description = $("#new-description").val();
const color = $("#new-color").val().substring(1);
const ab = $("#new-organic").is(":checked") ? 'visible' : 'none';
const req = {
title,
category_id,
designation,
ingredients,
description,
color,
ab,
};
$.post(backend_api + 'newtikette', JSON.stringify(req)).then(reload);
});
} }
function konami() { function konami() {
@@ -134,22 +215,53 @@ function konami() {
}); });
} }
async function googleCred(creds) {
const token = creds.credential;
await post(backend_api + 'signin', {token});
$('#signin-prompt').hide();
reload();
}
async function reload() { async function reload() {
disableButtons();
try { try {
const resp = await $.ajax({ const resp = await $.ajax({
url: backend_api + 'list', url: backend_api + 'list',
timeout: 1000, timeout: 1000,
xhrFields: { withCredentials: true },
}); });
tikats = (await $.ajax({ tikats = (await $.ajax({
url: backend_api + 'categories', url: backend_api + 'categories',
timeout: 1000, timeout: 1000,
xhrFields: { withCredentials: true },
})).tikats.sort((a, b) => a.name > b.name ? 1 : -1); })).tikats.sort((a, b) => a.name > b.name ? 1 : -1);
loadAll(resp.tikettes.sort((a, b) => (a.title < b.title) ? -1 : 1)); loadAll(resp.tikettes.sort((a, b) => (a.title < b.title) ? -1 : 1));
enableButtons();
} catch(e) { } catch(e) {
if (e.status === 403) {
$("#signin-prompt").show();
google.accounts.id.prompt(); // also display the One Tap dialog
return;
}
const appbody = $("#appbody"); const appbody = $("#appbody");
appbody.append(`<li>Could not reach backend server`); appbody.append(`<li>Could not reach backend server`);
throw e;
} }
} }
$(document).ready(reload); $(document).ready(() => {
google.accounts.id.initialize({
client_id: google_oauth_client_id,
callback: googleCred,
});
google.accounts.id.renderButton(
document.getElementById("signin-prompt"),
{ theme: "outline", size: "large" } // customization attributes
);
konami();
$('.collapsible').collapsible();
$('.modal').modal({onOpenStart: resetEditModal});
reload();
});

View File

@@ -6,6 +6,10 @@ location /zetikettes/srv/static {
alias /var/lib/zetikettes/www_static; alias /var/lib/zetikettes/www_static;
} }
location /zetikettes/srv/data {
alias /var/lib/zetikettes/data;
}
location ^~ /zetikettes/srv { location ^~ /zetikettes/srv {
proxy_pass http://127.0.0.1:8000; proxy_pass http://127.0.0.1:8000;
proxy_set_header SCRIPT_NAME /zetikettes/srv; proxy_set_header SCRIPT_NAME /zetikettes/srv;

View File

@@ -0,0 +1,33 @@
aromate;Herbes de Provence;Thym*, Sarriette*, Origan*;Le classique des herbes aromatiques pour donner une touche méditerranéenne à vos plats.;6307a6;Aromate - Herbes de Provence - Thym Sarriette Origan.svg;True
aromate;Origan;Origan*;La fameuse herbe à pizza, mais qui peut aussi assaisonner vos soupes, plats mijotés, poissons ou charcuteries.;4800b9;Aromate - Origan.svg;True
aromate;Sarriette;Sarriette des montagnes*;Le pèbre d'aï s'utilise en cuisine dans les pommes de terre, champignons, ragoûts de légumes, riz...;00d40e;Aromate - Sarriette.svg;True
aromate;Sauge;Sauge officinale*;La plante qui sauve peut être utilisée en cuisine, dans vos viandes en sauce, légumes, vinaigrettes...;d00676;Aromate - Sauge.svg;True
aromate;Thym;Thym*;Le roi des aromates, à utiliser en branche ou effeuillé, pour agrémenter vos grillades, plats en sauce, poêlées...;0048ff;Aromate - Thym.svg;True
confiture;Confiture d'Abricot;Abricot*, Sucre de canne*;;ff5d00;Confiture - Abricot.svg;True
confiture;Confiture d'Abricot;Abricot, Sucre de canne*;;ff5d00;Confiture - Abricot - Non Bio.svg;False
confiture;Confiture d'Abricot Lavande;Abricot, Lavande*, Sucre de canne*;;9683ec;Confiture - Abricot Lavande - Non Bio.svg;False
confiture;Gelée Extra de Groseille;Groseille*, Sucre de canne*;;ec001a;Gelée Extra - Groseille.svg;True
confiture;Gelée Extra de Cassis;Cassis*, Sucre de canne*;;9b00ff;Gelée Extra - Cassis.svg;True
confiture;Marmelade d'Orange Amère; Oranges amères, Sucre de canne*, eau;;ff5d00;Marmelade - Orange Amère - Non Bio.svg;False
pesto;Pesto de Livèche;Huile d'olive*, Livèche* (34%), *amandes*, jus de citron*, ail*, sel;;119200;Pesto - Livèche.svg;True
pesto;Pesto d'Ail des Ours;Huile d'olive*, Ail des ours* (34%), *Amandes*, Jus de citron*, Sel;;196b00;Pesto - Ail des Ours.svg;True
sirop;Lavande;Sucre* (55%), Infusion de lavande* (42%), Jus de citron* (3%);;4200ff;Sirop - Lavande.svg;True
sirop;Thym;Sucre* (55%), Infusion de thym* (42%), Jus de citron* (3%);;0048ff;Sirop - Thym.svg;True
sirop;Mélisse;Sucre* (55%), Infusion de mélisse* (42%), Jus de citron* (3%);;d00676;Sirop - Mélisse.svg;True
sirop;Menthe Verte;Sucre* (55%), Infusion de menthe verte* (42%), Jus de citron* (3%);;007e49;Sirop - Menthe Verte.svg;True
sirop;Menthe et Mélisse;Sucre* (55%), Infusion de menthe verte* et mélisse* (42%), Jus de citron* (3%);;fabf12;Sirop - Menthe et Mélisse.svg;True
sirop;Sureau;Sucre* (55%), Infusion de sureau* (42%), Jus de citron* (3%);;8aa700;Sirop - Sureau.svg;True
sirop;Romarin;Sucre* (55%), Infusion de romarin* (42%), Jus de citron* (3%);;007d87;Sirop - Romarin.svg;True
tisane;J'ai Bien Mangé...; Thym*, Sauge*, Romarin*, Soucis*;;0c7c00;Tisane - Digestion - Thym Sauge Romarin Soucis.svg;True
tisane;Nuit Étoilée;Agastache*, Mélisse*, Aubépine*;;5b7aff;Tisane - Nuit Étoilée - Agastache Mélisse Aubépine.svg;True
tisane;Nuit Étoilée;Mélisse*, Lavande*, Aubépine*;;5b7aff;Tisane - Nuit Étoilée - Mélisse Lavande Aubépine.svg;True
tisane;Réconfort de la Gorge; Origan*, Agastache*, Thym*, Bleuet*;;00a07b;Tisane - Réconfort de la Gorge - Origan Agastache Thym Bleuet.svg;True
tisane;Équilibre Féminin;Achillée millefeuille*, Lavande vraie*, Cynorhodon*;;64139f;Tisane - Équilibre Féminin - Achillée millefeuille Lavande vraie Cynorhodon.svg;True
tisane;Équilibre Féminin;Achillée millefeuille*, Lavande vraie*, Ortie piquante*;;64139f;Tisane - Équilibre Féminin - Achillée millefeuille Lavande vraie Ortie piquante.svg;True
tisane;Équilibre Féminin;Achillée millefeuille*, Lavande vraie*, Pétales de Cynorhodon*;;64139f;Tisane - Équilibre Féminin - Achillée millefeuille Lavande vraie Pétales de Cynorhodon.svg;True
tisane;Équilibre Féminin;Achillée millefeuille*, Lavande vraie*, Sauge officinale*, Ortie piquante*;;64139f;Tisane - Équilibre Féminin - Achillée millefeuille Lavande vraie Sauge officinale Ortie piquante.svg;True
tisane;Joie de Vivre;Basilic sacré*, Sarriette des montagnes*, Lavande vraie*;;ff6d00;Tisane - Joie de Vivre - Basilic Sacré Sarriette des Montagnes Lavande Vraie.svg;True
sel;Grillades - Viande et Légumes;Sel de Camargue, Thym*, Origan*, Sarriette*, Mélisse*, Souci*;;c80003;Sel - Grillades.svg;True
sel;Herbes de Provence;Sel de Camargue, Thym*, Origan*, Sarriette*, Romarin*;;6307a6;Sel - Herbes de Provence.svg;True
sel;Poisson et Viande Blanche;Sel de Camargue, Thym*, Sauge*, Agastache*, Bleuet*;;5b7aff;Sel - Poisson et Viande Blanche.svg;True
chocolat;Menthe Poivrée;"Chocolat de couverture noir* (pâte de cacao*, sucre de canne*, beurre de cacao*; peut contenir : *lait), *crème entière* (crème de lait à 30% de matière grasse*, stabilisants : carraghénanes), menthe poivrée*";;007e49;Chocolat - Menthe Poivrée.svg;True
1 aromate Herbes de Provence Thym*, Sarriette*, Origan* Le classique des herbes aromatiques pour donner une touche méditerranéenne à vos plats. 6307a6 Aromate - Herbes de Provence - Thym Sarriette Origan.svg True
2 aromate Origan Origan* La fameuse herbe à pizza, mais qui peut aussi assaisonner vos soupes, plats mijotés, poissons ou charcuteries. 4800b9 Aromate - Origan.svg True
3 aromate Sarriette Sarriette des montagnes* Le pèbre d'aï s'utilise en cuisine dans les pommes de terre, champignons, ragoûts de légumes, riz... 00d40e Aromate - Sarriette.svg True
4 aromate Sauge Sauge officinale* La plante qui sauve peut être utilisée en cuisine, dans vos viandes en sauce, légumes, vinaigrettes... d00676 Aromate - Sauge.svg True
5 aromate Thym Thym* Le roi des aromates, à utiliser en branche ou effeuillé, pour agrémenter vos grillades, plats en sauce, poêlées... 0048ff Aromate - Thym.svg True
6 confiture Confiture d'Abricot Abricot*, Sucre de canne* ff5d00 Confiture - Abricot.svg True
7 confiture Confiture d'Abricot Abricot, Sucre de canne* ff5d00 Confiture - Abricot - Non Bio.svg False
8 confiture Confiture d'Abricot Lavande Abricot, Lavande*, Sucre de canne* 9683ec Confiture - Abricot Lavande - Non Bio.svg False
9 confiture Gelée Extra de Groseille Groseille*, Sucre de canne* ec001a Gelée Extra - Groseille.svg True
10 confiture Gelée Extra de Cassis Cassis*, Sucre de canne* 9b00ff Gelée Extra - Cassis.svg True
11 confiture Marmelade d'Orange Amère Oranges amères, Sucre de canne*, eau ff5d00 Marmelade - Orange Amère - Non Bio.svg False
12 pesto Pesto de Livèche Huile d'olive*, Livèche* (34%), *amandes*, jus de citron*, ail*, sel 119200 Pesto - Livèche.svg True
13 pesto Pesto d'Ail des Ours Huile d'olive*, Ail des ours* (34%), *Amandes*, Jus de citron*, Sel 196b00 Pesto - Ail des Ours.svg True
14 sirop Lavande Sucre* (55%), Infusion de lavande* (42%), Jus de citron* (3%) 4200ff Sirop - Lavande.svg True
15 sirop Thym Sucre* (55%), Infusion de thym* (42%), Jus de citron* (3%) 0048ff Sirop - Thym.svg True
16 sirop Mélisse Sucre* (55%), Infusion de mélisse* (42%), Jus de citron* (3%) d00676 Sirop - Mélisse.svg True
17 sirop Menthe Verte Sucre* (55%), Infusion de menthe verte* (42%), Jus de citron* (3%) 007e49 Sirop - Menthe Verte.svg True
18 sirop Menthe et Mélisse Sucre* (55%), Infusion de menthe verte* et mélisse* (42%), Jus de citron* (3%) fabf12 Sirop - Menthe et Mélisse.svg True
19 sirop Sureau Sucre* (55%), Infusion de sureau* (42%), Jus de citron* (3%) 8aa700 Sirop - Sureau.svg True
20 sirop Romarin Sucre* (55%), Infusion de romarin* (42%), Jus de citron* (3%) 007d87 Sirop - Romarin.svg True
21 tisane J'ai Bien Mangé... Thym*, Sauge*, Romarin*, Soucis* 0c7c00 Tisane - Digestion - Thym Sauge Romarin Soucis.svg True
22 tisane Nuit Étoilée Agastache*, Mélisse*, Aubépine* 5b7aff Tisane - Nuit Étoilée - Agastache Mélisse Aubépine.svg True
23 tisane Nuit Étoilée Mélisse*, Lavande*, Aubépine* 5b7aff Tisane - Nuit Étoilée - Mélisse Lavande Aubépine.svg True
24 tisane Réconfort de la Gorge Origan*, Agastache*, Thym*, Bleuet* 00a07b Tisane - Réconfort de la Gorge - Origan Agastache Thym Bleuet.svg True
25 tisane Équilibre Féminin Achillée millefeuille*, Lavande vraie*, Cynorhodon* 64139f Tisane - Équilibre Féminin - Achillée millefeuille Lavande vraie Cynorhodon.svg True
26 tisane Équilibre Féminin Achillée millefeuille*, Lavande vraie*, Ortie piquante* 64139f Tisane - Équilibre Féminin - Achillée millefeuille Lavande vraie Ortie piquante.svg True
27 tisane Équilibre Féminin Achillée millefeuille*, Lavande vraie*, Pétales de Cynorhodon* 64139f Tisane - Équilibre Féminin - Achillée millefeuille Lavande vraie Pétales de Cynorhodon.svg True
28 tisane Équilibre Féminin Achillée millefeuille*, Lavande vraie*, Sauge officinale*, Ortie piquante* 64139f Tisane - Équilibre Féminin - Achillée millefeuille Lavande vraie Sauge officinale Ortie piquante.svg True
29 tisane Joie de Vivre Basilic sacré*, Sarriette des montagnes*, Lavande vraie* ff6d00 Tisane - Joie de Vivre - Basilic Sacré Sarriette des Montagnes Lavande Vraie.svg True
30 sel Grillades - Viande et Légumes Sel de Camargue, Thym*, Origan*, Sarriette*, Mélisse*, Souci* c80003 Sel - Grillades.svg True
31 sel Herbes de Provence Sel de Camargue, Thym*, Origan*, Sarriette*, Romarin* 6307a6 Sel - Herbes de Provence.svg True
32 sel Poisson et Viande Blanche Sel de Camargue, Thym*, Sauge*, Agastache*, Bleuet* 5b7aff Sel - Poisson et Viande Blanche.svg True
33 chocolat Menthe Poivrée Chocolat de couverture noir* (pâte de cacao*, sucre de canne*, beurre de cacao*; peut contenir : *lait), *crème entière* (crème de lait à 30% de matière grasse*, stabilisants : carraghénanes), menthe poivrée* 007e49 Chocolat - Menthe Poivrée.svg True

47
scripts/provision.py Executable file
View File

@@ -0,0 +1,47 @@
#!/usr/bin/env python3
import csv
import dataclasses
import requests
backend = 'http://localhost:8000'
products_file = './product_definitions.csv'
@dataclasses.dataclass
class Tikette:
category: str
designation: str
ingredients: str
description: str
color: str
svg: str
ab: str
def newtikette_payload(self, katzids):
return {
'title': self.svg.split('.')[0],
'category_id': katzids[self.category],
'designation': self.designation,
'ingredients': self.ingredients,
'description': self.description,
'color': self.color,
'ab': 'inline' if self.ab is 'True' else 'none',
}
def get_tikettes():
with open('./product_definitions.csv') as f:
return [Tikette(*x) for x in csv.reader(f, delimiter=';')]
def populate_database():
katz = requests.get(backend + '/categories', json=True).json()['tikats']
katzids = {x['name'].lower(): x['id'] for x in katz}
for tk in get_tikettes():
requests.post(backend + '/newtikette', json=tk.newtikette_payload(katzids))
if __name__ == "__main__":
populate_database()

66
scripts/renew_lineup.py Executable file
View File

@@ -0,0 +1,66 @@
#!/usr/bin/env python3
import argparse
from string import Template
import sys
import os
import csv
from subber import Subber
TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), "..", "templates")
OUT_DIR = "."
DEFAULT_CSV = 'product_definitions.csv'
JAM_DESIGNATION_FONTSIZE_DEFAULT = 42.6667
JAM_DESIGNATION_FONTSIZE_SMALL = 36
templates = {
'aromate': f"{TEMPLATES_DIR}/Aromate.svg",
'chocolat': f"{TEMPLATES_DIR}/Chocolat.svg",
'confiture': f"{TEMPLATES_DIR}/Confiture.svg",
'pesto': f"{TEMPLATES_DIR}/Pesto.svg",
'sirop': f"{TEMPLATES_DIR}/Sirop.svg",
'tisane': f"{TEMPLATES_DIR}/Tisane.svg",
'sel': f"{TEMPLATES_DIR}/Sel.svg",
}
ALLERGEN_BEGIN_STYLE = '<tspan style="font-weight:bold">'
ALLERGEN_END_STYLE = '</tspan>'
def parse_args():
parser = argparse.ArgumentParser(description='Renew whole lineup from template and list of subs')
parser.add_argument('--list', default=DEFAULT_CSV, help='Lineup file')
return parser.parse_args()
def main():
args = parse_args()
with open(args.list) as csvfile:
reader = csv.reader(csvfile, delimiter=';', quotechar='"')
for row in reader:
template = templates[row[0]]
outfile = f"{OUT_DIR}/{row[5]}"
with open(outfile, 'w') as out:
# TODO Fix bold formatting with allergen and parenthesis
ingredients = [e.strip() for e in row[2].split(',')]
ingredients = [e if not e.startswith('*') else ALLERGEN_BEGIN_STYLE + e[1:] + ALLERGEN_END_STYLE for e in ingredients]
ingredients_sub = ", ".join(ingredients)
AB_logo_visibility = 'inline' if row[6] == 'True' else 'none'
subs = {
'designation': row[1].strip(),
'ingredients': ingredients_sub,
'description': row[3].strip(),
'color': row[4],
'AB': AB_logo_visibility,
'designation_fontsize': JAM_DESIGNATION_FONTSIZE_DEFAULT,
}
s = Subber(subs)
s.sub(template, out)
if __name__ == "__main__":
main()

14
scripts/subber.py Normal file
View File

@@ -0,0 +1,14 @@
from string import Template
class Subber():
def __init__(self, subs):
self.subs = subs
def sub(self, infile, outfile):
with open(infile) as template:
lines = template.readlines()
data = ''.join(lines)
outfile.write(Template(data).safe_substitute(self.subs))