Compare commits

...

20 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
d706ae1645 Fix planche generation issues 2025-08-05 20:58:51 +02:00
6a05e46946 Moving fast and breaking things
This is a first commit in preparation for 2.0

- now using prototempaltes™
- updated README
- makefile for some nice shortcuts
- add new tikettes
- remove tikettes

Things that don't work (yet)
- authentication on the main frontend (there is none)
- generating PDFs
2025-08-05 15:14:25 +02:00
f6ad73ef85 Remove old backend. Byebye. 2025-08-05 15:14:12 +02:00
e222962eee backend: add missing db migration 2025-08-04 16:33:43 +02:00
66 changed files with 11388 additions and 1437 deletions

View File

@ -1,15 +1,15 @@
FROM alpine
FROM alpine:3.18
RUN apk --no-cache add python3 inkscape bash imagemagick ghostscript ttf-opensans
RUN apk --no-cache add py3-pip && pip3 install --break-system-packages django tzdata gunicorn
RUN apk --no-cache add python3 inkscape bash imagemagick ghostscript font-noto py3-pip
RUN pip3 install --break-system-packages django tzdata gunicorn google-api-python-client
ADD backend /root/zetikettes
ADD backend /zetikettes
RUN mkdir -p /usr/share/fonts/TTF \
&& cp /root/zetikettes/fonts/*.ttf /usr/share/fonts/TTF/ \
&& cp /zetikettes/fonts/*.ttf /usr/share/fonts/TTF/ \
&& fc-cache -fv
# the script will look for templates in /data
WORKDIR /root/zetikettes/zetikettes
WORKDIR /zetikettes/zetikettes
CMD /usr/bin/gunicorn zetikettes.wsgi -b 0.0.0.0:8000 --timeout 600 --forwarded-allow-ips="*"

48
Makefile Normal file
View File

@ -0,0 +1,48 @@
LIBPREFIX ?= /var/lib/zetikettes
image-name = pol/zetikettes
uuid = $(shell id -u):$(shell id -g)
.PHONY: image
image: ## build the docker images
docker compose build
.PHONY: initial-db
initial-db: image ## create and populate a database
mkdir -p $(LIBPREFIX)/data
docker run --rm \
--user $(uuid) \
-v $(LIBPREFIX)/data:/data \
$(image-name) \
/zetikettes/zetikettes/manage.py migrate
docker run --rm \
--user $(uuid) \
-v $(LIBPREFIX)/data:/data \
$(image-name) \
/zetikettes/zetikettes/manage.py loaddata initial_db
cp templates/*.svg $(LIBPREFIX)/data/
.PHONY: superuser
superuser: image ## create a superuser in the django admin
docker run --rm \
--user $(uuid) \
-v $(LIBPREFIX)/data:/data \
-it \
$(image-name) \
/zetikettes/zetikettes/manage.py createsuperuser
.PHONY: staticfiles
staticfiles: image ## install all static files
cp -r frontend $(LIBPREFIX)/
docker run --rm \
--user $(uuid) \
-v $(LIBPREFIX):/libdir \
-w /libdir \
$(image-name) \
/zetikettes/zetikettes/manage.py collectstatic --noinput
.PHONY: help
help: ## Show this help
@echo Noteworthy targets:
@egrep '^[a-zA-Z_-]+:.*?## .*$$' $(firstword $(MAKEFILE_LIST)) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
.DEFAULT_GOAL := help

View File

@ -1,64 +1,53 @@
zetikettes
==========
zetikettes 2.0
==============
ouaich.
ouaich. tavu.
**NOTE**: release 2.0 broke compatibility with previous "releases". Maxime has all the recent data to repopulate a new database.
Initial setup
-------------
### Database setup and population
Populate an empty database (if none already exists):
```
sudo make initial-db
```
This will setup an initial database under `/var/lib/zetikettes/data`
Optionally, you may need credentials to access the admin page:
```
make superuser
```
### System service
```
docker build -t zetikettes .
sudo mkdir -p /etc/docker/compose/zetikettes
sudo cp docker-compose.yml /etc/docker/compose/zetikettes/
sudo cp compose.yml /etc/docker/compose/zetikettes/
sudo systemctl enable --now docker-compose@zetikettes
```
If not already present in `/etc/systemd/system/`, the `docker-compose@` service file is provided.
### www static files
```
make staticfiles
```
This will install frontend and django admin static files under `/var/lib/zetikettes`
### Nginx
Example configuration is provided in `nginx_locations`.
Nginx is configured to:
- redirect /zetikettes/srv/ to localhost:8000
- redirect /zetikettes/ to /var/lib/zetikettes/static
Conf is in /etc/nginx/sites-available/default
Test
----
```
docker run --rm -it -v $PWD/templates:/data zetikettes /bin/bash /root/zetikettes/old/mkjam.sh
```
This should produce a .pdf in `templates/`. Open it to check that
layout & fonts are correct.
Run
---
```
docker-compose up
```
Notes for deploying
-------------------
.h3 Initialize empty database
```
python manage.py migrate
```
.h3 Prepare static files
```
python manage.py collectstatic
```
The files will be in `www_static/` and need to be moved to `/var/lib/zetikettes/www_static`
.h3 Change host settings
If not deploying on `aerith.ponteilla.net`, you'll need to edit `backend/zetikettes/zetikettes/settings.py` to change a couple things in there.
Change available templates
--------------------------
1. go to /zetikettes/admin
1. add the newtikette
1. still no need to restart the container (magic!)
2. profit.
- redirect `/zetikettes/srv/` to `localhost:8000`
- redirect `/zetikettes/` to `/var/lib/zetikettes/frontend`
- redirect `/zetikettes/srv/static` to `/var/lib/zetikettes/www_static`

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1 +0,0 @@
[]

View File

@ -1,60 +0,0 @@
#!/usr/bin/env python3
import argparse
from pathlib import Path
from string import Template
import sys
DEFAULT_SHEET_TEMPLATE = Path(__file__).parent / 'planche.svg.in'
def parse_args():
parser = argparse.ArgumentParser(
description='Make a sheet with 12 stickers')
parser.add_argument('--template',
'-t',
default=DEFAULT_SHEET_TEMPLATE,
help='path to the sheet template')
parser.add_argument('--out',
'-o',
default=sys.stdout,
type=argparse.FileType('w'),
help='output path (default: stdout)')
parser.add_argument('sticker',
type=argparse.FileType('r'),
default=sys.stdin,
nargs='?',
help='path to the sticker SVG (default: stdin)')
return parser.parse_args()
def makeplanche(sticker, out, template=DEFAULT_SHEET_TEMPLATE):
with open(template) as tpl:
tpl_data = tpl.read()
lines = sticker.readlines()
if lines[0].startswith('<?xml'):
lines = lines[1:]
sticker_data = ''.join(lines)
subs = {
'left0': sticker_data,
'left1': sticker_data,
'left2': sticker_data,
'left3': sticker_data,
'left4': sticker_data,
'left5': sticker_data,
'right0': sticker_data,
'right1': sticker_data,
'right2': sticker_data,
'right3': sticker_data,
'right4': sticker_data,
'right5': sticker_data,
}
out.write(Template(tpl_data).substitute(subs))
if __name__ == "__main__":
makeplanche(**vars(parse_args()))

View File

@ -1,58 +0,0 @@
#!/usr/bin/env python3
import argparse
from string import Template
import sys
import typing
def parse_args():
parser = argparse.ArgumentParser(description='Fill in sticker details')
parser.add_argument('--out',
'-o',
default=sys.stdout,
type=argparse.FileType('w'),
help='output path (default: stdout)')
parser.add_argument('--dluo',
'-d',
required=True,
help='Date Limite d\'Utilisation Optimale')
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('--teneur', '-t', required=False, help='Teneur en sucre')
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('--landscape',
action='store_true',
help='input sticker is in landscape orientation')
parser.add_argument('sticker', help='path to the sticker template')
return parser.parse_args()
def makesticker(sticker: str, out: typing.IO, subs: dict, landscape=False):
with open(sticker) as fin:
lines = fin.readlines()
if lines[0].startswith('<?xml'):
lines = lines[1:]
sticker_data = ''.join(lines)
if landscape:
# Rotate the sticker 90 degrees
sticker_data = "<g transform=\"rotate(-90,0,0) translate(-102,0)\">{}</g>".format(sticker_data)
out.write(Template(sticker_data).substitute(subs))
if __name__ == "__main__":
args = vars(parse_args())
subs = {
'dluo': args.pop('dluo'),
'lot': args.pop('lot'),
'teneur': args.pop('teneur'),
'fruit': args.pop('fruit'),
'qty': args.pop('quantite'),
'size': args.pop('size'),
}
args['subs'] = subs
makesticker(**args)

View File

@ -1,25 +0,0 @@
#!/bin/bash
DEFAULT_DPI=300
LARGE='370' # Large container (370g)
SMALL='220' # Small container (220g)
SIZE=$LARGE
DLUO='décembre 2023'
LOT='0722-2'
TENEUR='50' # %
FRUIT='80' # g
STICKER='Gelée Extra - Cassis.svg'
DATADIR=/data
PDF="`basename \"${STICKER}\" .svg` - ${LOT} (${SIZE}g).pdf"
here=$(dirname $(readlink -f $0))
cd $DATADIR
$here/makesticker.py --landscape --dluo "$DLUO" --lot "$LOT" --teneur "$TENEUR" --fruit "$FRUIT" --quantite "$SIZE" -o out.svg "$STICKER" && \
$here/makeplanche.py -o pout.svg -t $here/planche.svg.in out.svg && \
rm out.svg && \
inkscape --export-type="png" --export-dpi=$DEFAULT_DPI pout.svg && \
rm pout.svg && \
convert pout.png "$PDF"

View File

@ -1,24 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 210 297"
height="297mm"
width="210mm">
<g transform="translate(3, 4.5)">
<g transform="translate(-42,144) rotate(-90,144,0)">
<g transform="translate( 0,0)">$right0</g>
<g transform="translate( 48,0)">$right1</g>
<g transform="translate( 96,0)">$right2</g>
<g transform="translate(144,0)">$right3</g>
<g transform="translate(192,0)">$right4</g>
<g transform="translate(240,0)">$right5</g>
</g>
<g transform="translate(-42,144) rotate(90,144,0)">
<g transform="translate( 0,0)">$left0</g>
<g transform="translate( 48,0)">$left1</g>
<g transform="translate( 96,0)">$left2</g>
<g transform="translate(144,0)">$left3</g>
<g transform="translate(192,0)">$left4</g>
<g transform="translate(240,0)">$left5</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 896 B

View File

@ -1,269 +0,0 @@
import argparse
from http.server import BaseHTTPRequestHandler, HTTPServer
import io
import json
import logging
import os
import re
import string
import subprocess
import tempfile
import time
import traceback
from makesticker import makesticker
from makeplanche import makeplanche
# defaults
PORT = 8000
OUT_DIR = tempfile.gettempdir()
DATA_DIR = '/data'
TEMPLATE_DIR = DATA_DIR
DEFAULT_DPI = 300
LEMOTDEPASSE = 'je veux ajouter une étiquette'
TIKETTES = os.path.join(DATA_DIR, 'tikettes.json')
config = {}
def tikettes_path():
return config.get('tikettes', TIKETTES)
def template_dir():
return config.get('template_dir', TEMPLATE_DIR)
def inkscapize(svg_in, pdf_out):
png_out = tempfile.mktemp(suffix='.png')
try:
cmd = ['inkscape', '--export-type=png', f'--export-filename={png_out}',
f'--export-dpi={DEFAULT_DPI}', svg_in]
subprocess.check_call(cmd)
cmd = ['convert', png_out, pdf_out]
subprocess.check_call(cmd)
finally:
if os.path.exists(png_out):
os.unlink(png_out)
def handle_newtikette(formdata):
with open(tikettes_path()) as f:
tikettes = json.load(f)
sticker = formdata['title'] + '.svg'
with open(os.path.join(template_dir(), sticker), 'w') as f:
f.write(formdata['sticker'])
known_subs = {
"dluo": "germinal 9999",
"fruit": "80",
"teneur": "50",
"lot": "0000-0",
"qty": "370",
"vol": "50",
}
newtikette = {
'title': formdata['title'],
'sticker': sticker,
'landscape': 'landscape' in formdata,
'subs': {k: v for k, v in known_subs.items() if k in formdata},
}
logging.info(f'adding newtikette: {newtikette}')
tikettes.append(newtikette)
with open(tikettes_path(), 'w') as f:
json.dump(tikettes, f, indent=2)
return json.dumps({'status': 'ok', 'message': 'newtikette added'})
def handle_generate(request):
request = json.loads(request)
# fill in sticker details
sticker_out = tempfile.mktemp(suffix='.svg')
try:
with open(sticker_out, 'w') as stickout:
landscape = request.get('landscape', False)
print(f'landscape: {landscape}')
makesticker(os.path.join(template_dir(), request['sticker']), stickout,
request['subs'], landscape=landscape)
# make sticker sheet
planche_out = tempfile.mktemp(suffix='.svg')
with open(sticker_out, 'r') as stickin:
with open(planche_out, 'w') as planchout:
makeplanche(stickin, planchout)
# process to printable pdf
pdf_out = tempfile.mktemp(dir=OUT_DIR, suffix='.pdf')
inkscapize(planche_out, pdf_out)
response = {'status': 'ok', 'file': os.path.basename(pdf_out),
'message': 'this is the way'}
return json.dumps(response)
finally:
if os.path.exists(sticker_out):
os.unlink(sticker_out)
if 'planche_out' in locals() and os.path.exists(planche_out):
os.unlink(planche_out)
def parse_multipart(data, boundary):
logging.debug(f'parsing data with boundary: {boundary}')
f = io.StringIO(data)
out = []
if boundary not in f.readline():
return out
while True:
headers = []
while True:
h = f.readline().strip()
try:
k, v = re.findall(r'^([a-zA-Z-]+): (.*)$', h)[0]
except IndexError:
break
headers.append((k, v))
metadata = None
otherheaders = []
logging.debug(headers)
for k, v in headers:
if k == 'Content-Disposition':
parts = v.split('; ')
if parts[0] != 'form-data':
continue
d = dict(re.findall(r'([^=]+)="(.*)"$', p)[0] for p in parts[1:])
metadata = d
else:
otherheaders.append((k, v))
value = io.StringIO()
while True:
l = f.readline()
if not l:
break
if boundary in l:
# value.write(l.split(boundary)[0])
break
value.write(l)
if metadata is None:
break
logging.debug(f'({metadata}, {value.getvalue()[:-2]}, {otherheaders})')
out.append((metadata, value.getvalue()[:-2], otherheaders))
return out
class MyServer(BaseHTTPRequestHandler):
def do_GET(self):
logging.info(f'GET {self.path}')
if self.path == '/list':
self.send_response(200)
self.send_header("Content-type", "application/json")
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
with open(tikettes_path()) as f:
tikettes = json.load(f)
resp = {'status': 'ok', 'message': 'this is the way',
'tikettes': tikettes}
self.wfile.write(json.dumps(resp).encode())
return
match = re.match(r'/data/(\w+\.pdf)', self.path)
if match is not None:
pdf_path = os.path.join(OUT_DIR, match.groups()[0])
if not os.path.exists(pdf_path):
self.send_response(404)
self.end_headers()
return
self.send_response(200)
self.send_header("Content-type", "application/pdf")
self.end_headers()
with open(pdf_path, 'rb') as f:
self.wfile.write(f.read())
return
self.send_response(200)
self.send_header("Content-type", "text/html; charset=UTF-8")
self.end_headers()
self.wfile.write(b"<html><head><title>Zetikettes</title></head>")
self.wfile.write(b"<body>")
self.wfile.write("<p>This is not the way.</p>".encode())
self.wfile.write(b"</body></html>")
def do_OPTIONS(self):
self.send_response(200)
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
def do_POST(self):
logging.info(f'POST {self.path}')
try:
length = int(self.headers['content-length'])
req = self.rfile.read(length).decode()
if self.path == '/newtikette':
boundary = re.findall(r'boundary=(.*)$', self.headers['content-type'])[0]
formdata = {p['name']: v for p, v, _ in parse_multipart(req, boundary)}
logging.info({k: v[:100] for k, v in formdata.items()})
if formdata.get('lemotdepasse', None) != LEMOTDEPASSE:
logging.warning(f'wrong lemotdepasse')
self.send_response(403)
self.send_header("Content-type", "application/json")
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(json.dumps({'status': 'notok', 'message': 'bad lemotdepasse'}).encode())
return
resp = handle_newtikette(formdata).encode()
else:
resp = handle_generate(req).encode()
except:
self.send_response(500)
self.send_header("Content-type", "text/plain")
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(traceback.format_exc().encode())
raise
self.send_response(200)
self.send_header("Content-type", "application/json")
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(resp)
def parse_args():
parser = argparse.ArgumentParser(description='Zetikette backend')
parser.add_argument('--port', type=int, default=PORT, help=f'default: {PORT}')
parser.add_argument('--data-dir', default=DATA_DIR, help=f'default: {DATA_DIR}')
return parser.parse_args()
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
args = parse_args()
config.update({
'tikettes': os.path.join(args.data_dir, 'tikettes.json'),
'template_dir': args.data_dir,
})
webServer = HTTPServer(('', args.port), MyServer)
print(f"Server started on port {args.port}")
try:
webServer.serve_forever()
except KeyboardInterrupt:
pass
webServer.server_close()
print("Server stopped.")

View File

@ -0,0 +1,152 @@
[
{
"model": "tikette.tisub",
"pk": 2,
"fields": {
"name": "dluo",
"descritpion": "DLUO",
"default": "décembre 1992",
"type": "YM"
}
},
{
"model": "tikette.tisub",
"pk": 3,
"fields": {
"name": "lot",
"descritpion": "№ de lot",
"default": "1234-5",
"type": "ST"
}
},
{
"model": "tikette.tisub",
"pk": 4,
"fields": {
"name": "q",
"descritpion": "Poids net (g)",
"default": "370",
"type": "ST"
}
},
{
"model": "tikette.tisub",
"pk": 5,
"fields": {
"name": "teneur",
"descritpion": "Teneur en fruits (%)",
"default": "50",
"type": "ST"
}
},
{
"model": "tikette.tisub",
"pk": 6,
"fields": {
"name": "f",
"descritpion": "Quantité de fruits pour 100g (g)",
"default": "60",
"type": "ST"
}
},
{
"model": "tikette.tikategory",
"pk": 1,
"fields": {
"name": "Pesto",
"landscape": true,
"prototempalte": "Pesto.svg",
"subs": [
2,
3,
4
]
}
},
{
"model": "tikette.tikategory",
"pk": 2,
"fields": {
"name": "Confiture",
"landscape": true,
"prototempalte": "Confiture.svg",
"subs": [
2,
3,
4,
5,
6
]
}
},
{
"model": "tikette.tikategory",
"pk": 3,
"fields": {
"name": "Chocolat",
"landscape": false,
"prototempalte": "Chocolat.svg",
"subs": [
2,
3,
4
]
}
},
{
"model": "tikette.tikategory",
"pk": 4,
"fields": {
"name": "Aromate",
"landscape": false,
"prototempalte": "Aromate.svg",
"subs": [
2,
3,
4
]
}
},
{
"model": "tikette.tikategory",
"pk": 5,
"fields": {
"name": "Sel",
"landscape": true,
"prototempalte": "Sel.svg",
"subs": [
2,
3,
4
]
}
},
{
"model": "tikette.tikategory",
"pk": 6,
"fields": {
"name": "Sirop",
"landscape": false,
"prototempalte": "Sirop.svg",
"subs": [
2,
3,
4
]
}
},
{
"model": "tikette.tikategory",
"pk": 7,
"fields": {
"name": "Tisane",
"landscape": false,
"prototempalte": "Tisane.svg",
"subs": [
2,
3,
4
]
}
}
]

View File

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

View File

@ -1,8 +1,9 @@
from django.contrib import admin
from .models import Tikette, Tikategory, Tisub
from .models import Tikette, Tizer, Tikategory, Tisub
admin.site.register(Tikette)
admin.site.register(Tizer)
admin.site.register(Tikategory)
admin.site.register(Tisub)

View File

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

View File

@ -1,61 +0,0 @@
import os
import subprocess
import tempfile
from .makesticker import makesticker
from .makeplanche import makeplanche
OUT_DIR = tempfile.gettempdir()
DEFAULT_DPI = 300
def inkscapize(svg_in, pdf_out):
png_out = tempfile.mktemp(suffix='.png')
try:
cmd = ['inkscape', '--export-type=png', f'--export-filename={png_out}',
f'--export-dpi={DEFAULT_DPI}', svg_in]
subprocess.check_call(cmd)
cmd = ['convert', png_out, pdf_out]
subprocess.check_call(cmd)
finally:
if os.path.exists(png_out):
os.unlink(png_out)
def generate(request, out_dir):
""" Generate a sticker sheet.
request: dict-like with the following fields:
sticker: mandatory. filename for the sticker
subs: mandatory. dict-like with key-value template subtitution fields
landscape: optional. defaults to False
out_dir: you get it
"""
# fill in sticker details
sticker_out = tempfile.mktemp(suffix='.svg')
try:
with open(sticker_out, 'w') as stickout:
landscape = request.get('landscape', False)
makesticker(os.path.join(out_dir, request['sticker']), stickout,
request['subs'], landscape=landscape)
# make sticker sheet
planche_out = tempfile.mktemp(suffix='.svg')
with open(sticker_out, 'r') as stickin:
with open(planche_out, 'w') as planchout:
makeplanche(stickin, planchout)
# process to printable pdf
pdf_out = tempfile.mktemp(dir=out_dir, suffix='.pdf')
inkscapize(planche_out, pdf_out)
return os.path.basename(pdf_out)
finally:
if os.path.exists(sticker_out):
os.unlink(sticker_out)
if 'planche_out' in locals() and os.path.exists(planche_out):
os.unlink(planche_out)

View File

@ -1,60 +0,0 @@
#!/usr/bin/env python3
import argparse
from pathlib import Path
from string import Template
import sys
DEFAULT_SHEET_TEMPLATE = Path(__file__).parent / 'planche.svg.in'
def parse_args():
parser = argparse.ArgumentParser(
description='Make a sheet with 12 stickers')
parser.add_argument('--template',
'-t',
default=DEFAULT_SHEET_TEMPLATE,
help='path to the sheet template')
parser.add_argument('--out',
'-o',
default=sys.stdout,
type=argparse.FileType('w'),
help='output path (default: stdout)')
parser.add_argument('sticker',
type=argparse.FileType('r'),
default=sys.stdin,
nargs='?',
help='path to the sticker SVG (default: stdin)')
return parser.parse_args()
def makeplanche(sticker, out, template=DEFAULT_SHEET_TEMPLATE):
with open(template) as tpl:
tpl_data = tpl.read()
lines = sticker.readlines()
if lines[0].startswith('<?xml'):
lines = lines[1:]
sticker_data = ''.join(lines)
subs = {
'left0': sticker_data,
'left1': sticker_data,
'left2': sticker_data,
'left3': sticker_data,
'left4': sticker_data,
'left5': sticker_data,
'right0': sticker_data,
'right1': sticker_data,
'right2': sticker_data,
'right3': sticker_data,
'right4': sticker_data,
'right5': sticker_data,
}
out.write(Template(tpl_data).substitute(subs))
if __name__ == "__main__":
makeplanche(**vars(parse_args()))

View File

@ -1,58 +0,0 @@
#!/usr/bin/env python3
import argparse
from string import Template
import sys
import typing
def parse_args():
parser = argparse.ArgumentParser(description='Fill in sticker details')
parser.add_argument('--out',
'-o',
default=sys.stdout,
type=argparse.FileType('w'),
help='output path (default: stdout)')
parser.add_argument('--dluo',
'-d',
required=True,
help='Date Limite d\'Utilisation Optimale')
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('--teneur', '-t', required=False, help='Teneur en sucre')
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('--landscape',
action='store_true',
help='input sticker is in landscape orientation')
parser.add_argument('sticker', help='path to the sticker template')
return parser.parse_args()
def makesticker(sticker: str, out: typing.IO, subs: dict, landscape=False):
with open(sticker) as fin:
lines = fin.readlines()
if lines[0].startswith('<?xml'):
lines = lines[1:]
sticker_data = ''.join(lines)
if landscape:
# Rotate the sticker 90 degrees
sticker_data = "<g transform=\"rotate(-90,0,0) translate(-102,0)\">{}</g>".format(sticker_data)
out.write(Template(sticker_data).substitute(subs))
if __name__ == "__main__":
args = vars(parse_args())
subs = {
'dluo': args.pop('dluo'),
'lot': args.pop('lot'),
'teneur': args.pop('teneur'),
'fruit': args.pop('fruit'),
'qty': args.pop('quantite'),
'size': args.pop('size'),
}
args['subs'] = subs
makesticker(**args)

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

@ -1,51 +1,127 @@
# Generated by Django 4.2.2 on 2023-07-03 14:37
# Generated by Django 5.2.4 on 2025-08-05 12:57
from django.db import migrations, models
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
dependencies = []
operations = [
migrations.CreateModel(
name='Tikategory',
name="Tikategory",
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50)),
('landscape', models.BooleanField()),
(
"id",
models.BigAutoField(
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={
'verbose_name_plural': 'tikategoriez',
"verbose_name_plural": "tikategoriez",
},
),
migrations.CreateModel(
name='Tisub',
name="Tisub",
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50)),
('descritpion', models.TextField()),
('default', models.TextField()),
(
"id",
models.BigAutoField(
auto_created=True,
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={
'verbose_name_plural': 'tisubz',
"verbose_name_plural": "tisubz",
},
),
migrations.CreateModel(
name='Tikette',
name="Tizer",
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=100)),
('svg', models.FileField(upload_to='')),
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tikette.tikategory')),
('subs', models.ManyToManyField(to='tikette.tisub')),
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("email", models.CharField()),
],
options={
'verbose_name_plural': 'tikettz',
"verbose_name_plural": "tizerz",
},
),
migrations.CreateModel(
name="Tikette",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("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={
"verbose_name_plural": "tikettz",
},
),
migrations.AddField(
model_name="tikategory",
name="subs",
field=models.ManyToManyField(to="tikette.tisub"),
),
]

View File

@ -1,9 +1,19 @@
from django.db import models
class Tisub(models.Model):
class Type(models.TextChoices):
SHORT_TEXT = "ST"
LONG_TEXT = "LT"
COLOR = "C"
YEAR_MONTH = "YM"
BOOLEAN = "B"
name = models.CharField(max_length=50)
descritpion = models.TextField()
default = models.TextField()
# everything is text. type is for UX (e.g. color picker)
type = models.CharField(max_length=2, choices=Type, default=Type.SHORT_TEXT)
def __str__(self):
return self.name
@ -16,6 +26,10 @@ class Tikategory(models.Model):
name = models.CharField(max_length=50)
landscape = models.BooleanField()
subs = models.ManyToManyField(Tisub)
# For now we'll hardcode the following:xi
# [designation, ingredients, description, color, AB, designation_fontsize]
# protosubs = models.ManyToManyField(Tisub, related_name="protosubs")
prototempalte = models.FileField()
def __str__(self):
return self.name
@ -25,9 +39,19 @@ class Tikategory(models.Model):
class Tikette(models.Model):
class AbVisibility(models.TextChoices):
VISIBLE = "inline"
INVISIBLE = "none"
title = models.CharField(max_length=100)
category = models.ForeignKey(Tikategory, on_delete=models.CASCADE)
svg = models.FileField()
designation = models.CharField(max_length=100)
ingredients = models.TextField()
description = models.TextField()
color = models.CharField(max_length=6)
ab = models.CharField(max_length=7, choices=AbVisibility)
# designation_fontsize is hardcoded to 42.6667 in planche/generate.py
def __str__(self):
return self.title
@ -35,4 +59,12 @@ class Tikette(models.Model):
class Meta:
verbose_name_plural = "tikettz"
# Create your models here.
class Tizer(models.Model):
email = models.CharField()
def __str__(self):
return self.email
class Meta:
verbose_name_plural = "tizerz"

View File

@ -1,24 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 210 297"
height="297mm"
width="210mm">
<g transform="translate(3, 4.5)">
<g transform="translate(-42,144) rotate(-90,144,0)">
<g transform="translate( 0,0)">$right0</g>
<g transform="translate( 48,0)">$right1</g>
<g transform="translate( 96,0)">$right2</g>
<g transform="translate(144,0)">$right3</g>
<g transform="translate(192,0)">$right4</g>
<g transform="translate(240,0)">$right5</g>
</g>
<g transform="translate(-42,144) rotate(90,144,0)">
<g transform="translate( 0,0)">$left0</g>
<g transform="translate( 48,0)">$left1</g>
<g transform="translate( 96,0)">$left2</g>
<g transform="translate(144,0)">$left3</g>
<g transform="translate(192,0)">$left4</g>
<g transform="translate(240,0)">$left5</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 896 B

View File

@ -0,0 +1,69 @@
import os
import subprocess
import tempfile
from .makesticker import makesticker
from .makeplanche import makeplanche
OUT_DIR = tempfile.gettempdir()
DEFAULT_DPI = 300
DESIGNATION_FONTSIZE = 42.6667
def inkscapize(svg_in, pdf_out):
png_out = tempfile.mktemp(suffix=".png")
try:
cmd = [
"inkscape",
"--export-type=png",
f"--export-filename={png_out}",
f"--export-dpi={DEFAULT_DPI}",
svg_in,
]
subprocess.check_call(cmd)
cmd = ["convert", png_out, pdf_out]
subprocess.check_call(cmd)
finally:
if os.path.exists(png_out):
os.unlink(png_out)
def generate(template, subs, out_dir, landscape=False):
"""Generate a sticker sheet.
template: file name for the sticker template
subs: dict-like with key-value template subtitution fields
out_dir: you get it
landscape: optional. defaults to False
"""
# default designation font size
subs["designation_fontsize"] = subs.get(
"designation_fontsize", DESIGNATION_FONTSIZE
)
# fill in sticker details
sticker_out = tempfile.mktemp(suffix=".svg")
try:
with open(sticker_out, "w") as stickout:
makesticker(os.path.join(out_dir, template), stickout, subs)
# make sticker sheet
planche_out = tempfile.mktemp(suffix=".svg")
with open(sticker_out, "r") as stickin:
with open(planche_out, "w") as planchout:
makeplanche(stickin, planchout, landscape=landscape)
# process to printable pdf
pdf_out = tempfile.mktemp(dir=out_dir, suffix=".pdf")
inkscapize(planche_out, pdf_out)
return os.path.basename(pdf_out)
finally:
if os.path.exists(sticker_out):
os.unlink(sticker_out)
if "planche_out" in locals() and os.path.exists(planche_out):
os.unlink(planche_out)

View File

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

View File

@ -0,0 +1,54 @@
#!/usr/bin/env python3
import argparse
from string import Template
import sys
import typing
def parse_args():
parser = argparse.ArgumentParser(description="Fill in sticker details")
parser.add_argument(
"--out",
"-o",
default=sys.stdout,
type=argparse.FileType("w"),
help="output path (default: stdout)",
)
parser.add_argument(
"--dluo", "-d", required=True, help="Date Limite d'Utilisation Optimale"
)
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("--teneur", "-t", required=False, help="Teneur en sucre")
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()
def makesticker(sticker: str, out: typing.IO, subs: dict):
with open(sticker) as fin:
lines = fin.readlines()
if lines[0].startswith("<?xml"):
lines = lines[1:]
sticker_data = "".join(lines)
out.write(Template(sticker_data).substitute(subs))
if __name__ == "__main__":
args = vars(parse_args())
subs = {
"dluo": args.pop("dluo"),
"lot": args.pop("lot"),
"teneur": args.pop("teneur"),
"f": args.pop("fruit"),
"q": args.pop("quantite"),
"size": args.pop("size"),
}
args["subs"] = subs
makesticker(**args)

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 210 297"
height="297mm"
width="210mm">
<g transform="translate(3, 4.5)">
<g transform="translate(0, 0) ${rotate} scale(0.26458)">${sticker}</g>
<g transform="translate(0, 48) ${rotate} scale(0.26458)">${sticker}</g>
<g transform="translate(0, 96) ${rotate} scale(0.26458)">${sticker}</g>
<g transform="translate(0, 144) ${rotate} scale(0.26458)">${sticker}</g>
<g transform="translate(0, 192) ${rotate} scale(0.26458)">${sticker}</g>
<g transform="translate(0, 240) ${rotate} scale(0.26458)">${sticker}</g>
<g transform="rotate(180, 102, 144)">
<g transform="translate(0, 0) ${rotate} scale(0.26458)">${sticker}</g>
<g transform="translate(0, 48) ${rotate} scale(0.26458)">${sticker}</g>
<g transform="translate(0, 96) ${rotate} scale(0.26458)">${sticker}</g>
<g transform="translate(0, 144) ${rotate} scale(0.26458)">${sticker}</g>
<g transform="translate(0, 192) ${rotate} scale(0.26458)">${sticker}</g>
<g transform="translate(0, 240) ${rotate} scale(0.26458)">${sticker}</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -1,31 +1,164 @@
import json
import sys
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.http import JsonResponse
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 . import generate as stickersheet
from .models import Tikette, Tikategory
from .planche import generate as stickersheet
from .models import Tikette, Tikategory, Tizer
CORS={'access-control-allow-origin': '*'}
def index(request):
tikettes = [{
'title': x.title,
'category': x.category.name,
'sticker': x.svg.name,
'landscape': x.category.landscape,
'subs': {x.name: x.default for x in x.category.subs.all()},
} for x in Tikette.objects.all()]
return JsonResponse({'status': 'ok', 'tikettes': tikettes}, headers=CORS)
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):
out = []
for ing in (x.strip() for x in ingredients.split(",")):
if ing.startswith("*"):
out.append(f'<tspan style="font-weight:bold">{ing[1:]}</tspan>')
else:
out.append(ing)
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):
tikettes = [
{
"id": x.id,
"title": x.title,
"category": x.category.name,
"category_id": x.category.id,
"prototempalte": x.category.prototempalte.name,
"landscape": x.category.landscape,
"designation": x.designation,
"ingredients": x.ingredients,
"description": x.description,
"ab": x.ab,
"color": x.color,
"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):
tikats = [
{
"id": x.id,
"name": x.name,
}
for x in Tikategory.objects.all()
]
return ok({"tikats": tikats})
@auth_only
@post_please
def generate(request):
payload = json.loads(request.body)
subs = dict(payload["subs"])
for key in ("designation", "ingredients", "description", "color", "AB"):
subs[key] = payload[key]
subs["ingredients"] = quirk_bold_allergens(subs["ingredients"])
pdfpath = stickersheet.generate(
template=payload["template"],
subs=subs,
out_dir=settings.TIKETTE_OUT_DIR,
landscape=payload["landscape"],
)
return ok({"file": pdfpath})
@auth_only
@post_please
def newtikette(request):
payload = json.loads(request.body)
tikette = Tikette(**payload)
tikette.save()
return ok()
@auth_only
@post_please
def deletetikette(request):
payload = json.loads(request.body)
Tikette.objects.get(id=payload["id"]).delete()
return ok()
@csrf_exempt
def generate(request):
if request.method != "POST":
return JsonResponse({'status': 'notok', 'message': 'this isn\'t the way'})
@post_please
def signin(request):
payload = json.loads(request.body)
pdfpath = stickersheet.generate(payload, out_dir=settings.TIKETTE_OUT_DIR)
token = payload["token"]
return JsonResponse({'status': 'ok', 'file': pdfpath}, headers=CORS)
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
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'zetikettes.settings')
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "zetikettes.settings")
application = get_asgi_application()

View File

@ -15,74 +15,75 @@ from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
#TIKETTE_OUT_DIR = BASE_DIR / 'data'
TIKETTE_OUT_DIR = Path('/data')
# TIKETTE_OUT_DIR = BASE_DIR / 'data'
TIKETTE_OUT_DIR = Path("/data")
MEDIA_ROOT = TIKETTE_OUT_DIR
MEDIA_URL = '/data/'
MEDIA_URL = "/data/"
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/
# 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!
DEBUG = True
DEBUG = False
ALLOWED_HOSTS = ['*']
CSRF_TRUSTED_ORIGINS = ['https://*.ponteilla.net']
ALLOWED_HOSTS = ["*"]
CSRF_TRUSTED_ORIGINS = ["https://*.ponteilla.net"]
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'tikette',
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"tikette",
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"tikette.middleware.cors_everywhere",
]
ROOT_URLCONF = 'zetikettes.urls'
ROOT_URLCONF = "zetikettes.urls"
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = 'zetikettes.wsgi.application'
WSGI_APPLICATION = "zetikettes.wsgi.application"
# Database
# https://docs.djangoproject.com/en/4.2/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': TIKETTE_OUT_DIR / 'db.sqlite3',
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": TIKETTE_OUT_DIR / "db.sqlite3",
}
}
@ -92,16 +93,16 @@ DATABASES = {
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
# 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
@ -121,11 +122,16 @@ USE_TZ = True
# Static files (CSS, JavaScript, Images)
# 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
# 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
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
@ -23,7 +24,16 @@ from django.views.generic.base import TemplateView
import tikette.views
urlpatterns = [
path('admin/', admin.site.urls),
path('list', tikette.views.index),
path('', tikette.views.generate),
path("admin/", admin.site.urls),
path("list", tikette.views.get_list),
path("categories", tikette.views.get_categories),
path("generate", tikette.views.generate),
path("newtikette", tikette.views.newtikette),
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)
handler403 = "tikette.views.handler403"
handler404 = "tikette.views.handler404"

View File

@ -11,6 +11,6 @@ import os
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()

View File

@ -3,7 +3,8 @@ version: '3.1'
services:
zetikettes:
image: zetikettes
build: .
image: pol/zetikettes
restart: always
ports:
- 127.0.0.1:8000:8000

14
docker-compose@.service Normal file
View File

@ -0,0 +1,14 @@
[Unit]
Description=%i service with docker compose
PartOf=docker.service
After=docker.service
[Service]
Type=oneshot
RemainAfterExit=true
WorkingDirectory=/etc/docker/compose/%i
ExecStart=/usr/bin/docker-compose up -d --remove-orphans
ExecStop=/usr/bin/docker-compose down
[Install]
WantedBy=multi-user.target

View File

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

View File

@ -3,40 +3,121 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>zetikettes 0.2</title>
<title>Zětikwett's</title>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<!--Import Google Icon Font-->
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<!-- Compiled and minified CSS -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css">
<link rel="stylesheet" href="placeholder.css">
<!-- Compiled and minified JavaScript -->
<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="config.js"></script>
<script src="zetikettes.js"></script>
<style>
/* for the footer */
body {
display: flex;
min-height: 100vh;
flex-direction: column;
}
main {
flex: 1 0 auto;
}
.actions {
margin-left: auto;
white-space: nowrap;
}
.confirm {
width: 35%;
min-width: 20em;
max-width: 85%;
}
</style>
</head>
<body>
<header class="container center orange-text">
<h1>Zétikwett's</h1>
</header>
<nav class="light-blue">
<div class="nav-wrapper container">
<a href="#" class="brand-logo">Zětikwett's</a>
<ul id="nav-mobile" class="right hide-on-med-and-down">
<li>
<a class="modal-trigger btn orange disabled" href="#newproduct">
<i class="material-icons left">add</i>Nouveau produit
</a>
</li>
</ul>
</div>
</nav>
<main class="container row">
<div class="col m6 offset-m3 s12">
<ul class="collapsible" id="appbody"></ul>
<p></p>
<div class="col s4 offset-s4" id="signin-prompt" style="display: none"></div>
<div class="col m10 offset-m1 s12">
<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">
<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 shorter"></h6></div></li>
</div>
</ul>
</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 -->
<div id="newproduct" class="modal">
<div class="modal-content">
<div class="input-field">
<input id="new-name" type="text">
<label>Nom interne du produit</label>
</div>
<div class="input-field">
<select id="new-type">
</select>
<label>Type de produit</label>
</div>
<div class="input-field">
<input id="new-designation" type="text">
<label>Désignation</label>
</div>
<div class="input-field">
<textarea id="new-ingredients" class="materialize-textarea"></textarea>
<label>Ingrédients</label>
</div>
<div class="input-field">
<textarea id="new-description" class="materialize-textarea"></textarea>
<label>Description</label>
</div>
<div class="input-field">
<input value="#97A1CC" data-jscolor="{previewSize: 0}" id="new-color" type="text">
<label>Couleur</label>
</div>
<label>
<input type="checkbox" id="new-organic">
<span>Bio</span>
</label>
<div class="modal-footer">
<button type="submit" class="modal-close waves-effect waves-green btn" id="new-add">Ajouter</button>
</div>
</div>
</div>
</main>
<footer class="page-footer">
<footer class="page-footer orange">
<div class="container row">
<span class="col right">displayed with recycled electrons.</span>
</div>

1
frontend/jscolor.min.js vendored Normal file

File diff suppressed because one or more lines are too long

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();
});

47
frontend/placeholder.css Normal file
View File

@ -0,0 +1,47 @@
/*
* Read the blog post here:
* https://letsbuildui.dev/articles/how-to-build-a-skeleton-loading-placeholder
*/
.faux-text {
background: #dddddd;
border-radius: 4px;
height: 20px;
margin-bottom: 5px;
width: 100%;
}
.faux-text.short {
width: 75%;
}
.faux-text.shorter {
width: 55%;
}
.shimmer {
overflow: hidden;
position: relative;
}
.shimmer::before {
content: "";
position: absolute;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.4) 50%,
rgba(255, 255, 255, 0) 100%
);
height: 100%;
width: 100%;
z-index: 1;
animation: shimmer 1s infinite;
}
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}

View File

@ -1,64 +1,203 @@
const params = {
'dluo': 'DLUO',
'lot': 'Nº de lot',
'qty': 'Poids net (g)',
'vol': 'Volume net (cL)',
'q': 'Poids net (g)',
'teneur': 'Teneur en fruits (%)',
'fruit': 'Quantité de fruits pour 100g (g)',
'f': 'Quantité de fruits pour 100g (g)',
}
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) {
const zett = tikette;
const appbody = $("#appbody");
const block = $('<div class="section">');
for (let sub in zett.subs) {
block.append($(`<div class="input-field"><label class="active">${params[sub]}</label><input type="text" name="${sub}" value="${zett.subs[sub]}">`));
}
const loader = $('<div class="progress"><div class="indeterminate"></div></div>')
.hide();
const action = $('<div class="section">')
.append($('<a class="btn">générer<a>')
.click(() => {
const subs = block.find(':text')
.toArray()
.reduce((obj, el) => ({...obj, [el.name]: el.value}), {});
const req = {
template: zett.prototempalte,
designation: zett.designation,
description: zett.description,
ingredients: zett.ingredients,
color: zett.color,
AB: zett.ab, // mind the capitalization here
subs,
landscape: zett.landscape,
};
loader.show();
disableButtons();
post(backend_api + 'generate', req)
.then(data => {
const pdfbtn = $(`<a class="btn" href="${backend_api}data/${data.file}" target="_blank">ouvrir le pdf</a>`);
action.append(pdfbtn);
})
.catch(err => {
console.error(err);
})
.always(() => {
loader.hide();
enableButtons();
});
})
.append(loader));
const deleteAction = $('<b class="material-icons">delete</b>').click(() => {
$("#delete-title").text(zett.title);
$("#delete-confirm").off('click').click(() => {
const req = {
id: zett.id,
};
post(backend_api + 'deletetikette', req).then(reload);
});
M.Modal.getInstance($("#confirmdelete")).open();
return false;
});
const editAction = $('<b class="material-icons">edit</b>').click(() => {
openEditModal(zett);
return false;
});
appbody
.append($('<li>')
.append($('<div class="collapsible-header valign-wrapper">')
.append(`<h6 class="blue-text">${zett.title}</h6>`)
.append($('<span class="actions grey-text">')
.append(editAction)
.append(deleteAction)
))
.append($('<div class="collapsible-body">')
.append(block)
.append(action)));
}
function setCategories() {
const katsel = $('#new-type');
katsel.empty();
for (let kat of tikats) {
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) {
const appbody = $("#appbody");
appbody.empty();
for (let zett of zetikettes) {
const block = $('<div class="section">');
for (let sub in zett.subs) {
block.append($(`<div class="input-field"><label class="active">${params[sub]}</label><input type="text" name="${sub}" value="${zett.subs[sub]}">`));
}
const loader = $('<div class="progress"><div class="indeterminate"></div></div>')
.hide();
const action = $('<div class="section">')
.append($('<a class="btn">generate<a>')
.click(() => {
const subs = block.find(':text')
.toArray()
.reduce((obj, el) => ({...obj, [el.name]: el.value}), {});
const req = {
sticker: zett.sticker,
subs,
landscape: zett.landscape,
};
loader.show();
$('.btn').addClass("disabled");
$.post(backend_api, JSON.stringify(req))
.then(data => {
console.log(data);
const pdfbtn = $(`<a class="btn" href="${backend_api}data/${data.file}" target="_blank">open pdf</a>`);
action.append(pdfbtn);
})
.catch(err => {
console.log(err);
})
.always(() => {
loader.hide();
$('.btn').removeClass('disabled');
});
})
.append(loader));
appbody
.append($('<li>')
.append($(`<div class="collapsible-header"><h6 class="blue-text">${zett.title}</h6></div>`))
.append($('<div class="collapsible-body">')
.append(block)
.append(action)));
addProduct(zett);
}
$('.collapsible').collapsible();
konami();
setCategories();
}
function konami() {
@ -76,16 +215,53 @@ function konami() {
});
}
$(document).ready(async () => {
async function googleCred(creds) {
const token = creds.credential;
await post(backend_api + 'signin', {token});
$('#signin-prompt').hide();
reload();
}
async function reload() {
disableButtons();
try {
const resp = await $.ajax({
url: backend_api + 'list',
timeout: 1000,
xhrFields: { withCredentials: true },
});
tikats = (await $.ajax({
url: backend_api + 'categories',
timeout: 1000,
xhrFields: { withCredentials: true },
})).tikats.sort((a, b) => a.name > b.name ? 1 : -1);
loadAll(resp.tikettes.sort((a, b) => (a.title < b.title) ? -1 : 1));
enableButtons();
} catch(e) {
if (e.status === 403) {
$("#signin-prompt").show();
google.accounts.id.prompt(); // also display the One Tap dialog
return;
}
const appbody = $("#appbody");
appbody.append(`<li>Could not reach backend server`);
throw e;
}
}
$(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;
}
location /zetikettes/srv/data {
alias /var/lib/zetikettes/data;
}
location ^~ /zetikettes/srv {
proxy_pass http://127.0.0.1:8000;
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))

1373
templates/Aromate.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.1 MiB

1291
templates/Chocolat.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.1 MiB

1440
templates/Confiture.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.3 MiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.3 MiB

After

Width:  |  Height:  |  Size: 5.3 MiB

1431
templates/Sel.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.2 MiB

2249
templates/Sirop.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.2 MiB

1392
templates/Tisane.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@ -1,16 +0,0 @@
[Unit]
Description=Zetikettes backend service
After=docker.service
Requires=docker.service
[Service]
ExecStart=/usr/bin/docker run --rm --name %n \
-p 127.0.0.1:8000:8000 \
-v /var/lib/zetikettes/data:/data \
zetikettes
Restart=on-failure
ExecStartPre=-/usr/bin/docker exec %n stop
ExecStartPre=-/usr/bin/docker rm %n
[Install]
WantedBy=multi-user.target