Files
ti/app.py
Henrik Jess Nielsen d7ab8e02a1
Some checks failed
Build and Deploy citti / build-and-deploy (push) Has been cancelled
Fix Dockerfile static copy, dynamic port, health endpoint
- Remove COPY static/ (runtime uploads live in DATA_DIR/uploads)
- Add /health endpoint
- Read PORT from env (NOMAD_PORT_http in production, 9756 local)
- citti.nomad: dynamic port, canary deploy, Consul/Traefik, health check

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 01:04:29 +02:00

274 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import sqlite3
import os
import re
import unicodedata
import socket
from pathlib import Path
from datetime import datetime
from flask import Flask, render_template, request, jsonify, send_from_directory
app = Flask(__name__)
DATA_DIR = Path(os.environ.get("DATA_DIR", "."))
UPLOAD_DIR = DATA_DIR / "uploads"
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
DB = str(DATA_DIR / "data.db")
SECTIONS = [
{"id": "koed", "emoji": "🥩", "title": "Kød & fisk", "est": 1580, "varer": [
{"key": "entrecote", "name": "Entrecote ½ kg", "note": "Frisk sydamerikansk", "qty": "6 × ½ kg", "est": 605.94, "badges": ["card"]},
{"key": "nakkekoteletter", "name": "Nakkekoteletter ½ kg", "note": "Med/uden krydderi", "qty": "6 × ½ kg", "est": 191.94},
{"key": "kyllingebryst", "name": "Kyllingebryst ½ kg", "note": "Texas-marinade", "qty": "6 × ½ kg", "est": 359.94},
{"key": "hakket-oksekoed", "name": "Hakket oksekød", "note": "Køddisk — spørg personalet", "qty": "3 kg", "est": 240},
{"key": "rumpsteak", "name": "Rumpsteak ½ kg", "note": "Modnet, tyske kvier", "qty": "1 × ½ kg", "est": 167.99},
{"key": "lakseside", "name": "Lakseside premium", "note": "15,99/100g · tag ~700g", "qty": "~700g", "est": 112},
{"key": "grillpoelser", "name": "Gerstand grillpølser", "note": "10 stk. à 100g", "qty": "1 kg", "est": 74.99},
{"key": "bacon", "name": "Steinemann bacon i skiver","note": "Røget · fryseegnet", "qty": "1 kg", "est": 74.99},
]},
{"id": "frost", "emoji": "🧊", "title": "Frostgrønt · 3 kg poser", "est": 100, "varer": [
{"key": "wok-mix", "name": "Wok-mix blandede grøntsager", "qty": "3 kg", "est": 40},
{"key": "aerter", "name": "Ærter", "qty": "3 kg", "est": 30},
{"key": "broccoli", "name": "Broccoli", "qty": "3 kg", "est": 35},
{"key": "frost-andet", "name": "Hvad der ellers frister", "note": "Tjek frysedisken", "qty": "", "est": 0},
]},
{"id": "mejeri", "emoji": "🧀", "title": "Mejeri, ost & frost", "est": 185, "varer": [
{"key": "feta", "name": "Greco Schaffeta feta 150g", "note": "70% fåremælk", "qty": "3 stk.", "est": 44.97},
{"key": "frutti-mare", "name": "Frutti di Mare", "note": "Rejer, muslinger, blæksprutte", "qty": "1 kg", "est": 59.99},
{"key": "tigerrejer", "name": "Sorte tigerrejer frost", "note": "Rå, pillede · wok/grill", "qty": "1 kg", "est": 79.99},
]},
{"id": "pasta", "emoji": "🍝", "title": "Pasta frisk køl · Gut & Günstig ~800g", "est": 160, "varer": [
{"key": "tortelloni-skinke", "name": "Tortelloni Skinke", "qty": "2 stk.", "est": 55},
{"key": "tortelloni-spinat", "name": "Tortelloni Spinat & ricotta", "qty": "2 stk.", "est": 55},
{"key": "tortelloni-valg", "name": "Tortelloni valgfri variant", "note": "Svampe, tomat-mozzarella o.l.", "qty": "2 stk.", "est": 55},
]},
{"id": "groent", "emoji": "🥗", "title": "Grønt, olier & tørvarer", "est": 245, "varer": [
{"key": "avocado", "name": "Avocado Hass", "note": "Fra Peru, SanLucar", "qty": "5 stk.", "est": 74.95},
{"key": "olivenolie", "name": "Bertolli olivenolie extra vergine", "qty": "1L", "est": 99.99},
{"key": "mutti", "name": "Mutti passata", "note": "Natur eller basilikum","qty": "3 × 700g", "est": 44.97},
{"key": "rustichella", "name": "Rustichella d'Abruzzo pasta", "qty": "4 × 500g", "est": 75.96},
]},
{"id": "konserves", "emoji": "🥫", "title": "Konserves — tomat & basis", "est": 185, "varer": [
{"key": "hakkede-tomater", "name": "Hakkede tomater 400g", "qty": "8 dåser", "est": 50},
{"key": "flaede-tomater", "name": "Flåede tomater 400g", "qty": "4 dåser", "est": 25},
{"key": "tomatpure", "name": "Tomatpuré / koncentrat", "qty": "4 stk.", "est": 35},
{"key": "tun", "name": "Tun i olie 80g", "qty": "6 dåser", "est": 47},
{"key": "majs", "name": "Majs 340g", "qty": "4 dåser", "est": 28},
]},
{"id": "indisk", "emoji": "🌶️", "title": "Indisk & karry", "est": 160, "varer": [
{"key": "tikka-masala", "name": "Tikka masala sauce", "note": "350500g · Taste of India", "qty": "5 stk.", "est": 70},
{"key": "karrypasta-roed", "name": "Karrypasta rød 50g", "qty": "2 stk.", "est": 20},
{"key": "karrypasta-groen", "name": "Karrypasta grøn 50g", "qty": "2 stk.", "est": 20},
{"key": "karrypasta-gul", "name": "Karrypasta gul/mild", "qty": "2 stk.", "est": 20},
{"key": "mango-chutney", "name": "Mango chutney 300g", "qty": "2 stk.", "est": 24},
{"key": "basmatiris", "name": "Basmatiris 1 kg", "qty": "2 stk.", "est": 28},
]},
{"id": "mex", "emoji": "🌮", "title": "Mexicansk & asiatisk", "est": 185, "varer": [
{"key": "tacokrydderi", "name": "Tacokrydderi pakke", "qty": "6 stk.", "est": 33},
{"key": "jalapenos", "name": "Jalapeños på glas 200g", "qty": "2 stk.", "est": 16},
{"key": "salsa", "name": "Salsa / tomatdip 300g", "qty": "2 stk.", "est": 19},
{"key": "tacoshells", "name": "Tacoshells / tortilla wraps","qty": "2 stk.", "est": 24},
{"key": "kokosmalk", "name": "Kokosmælk 400ml", "qty": "6 dåser", "est": 47},
{"key": "sojasauce", "name": "Sojasauce 250ml", "qty": "1 stk.", "est": 10},
{"key": "bambusskud", "name": "Bambusskud / vandkastanjer","qty": "2 dåser", "est": 14},
]},
{"id": "drikke", "emoji": "🍺", "title": "Drikkevarer", "est": 530, "varer": [
{"key": "carlsberg", "name": "Carlsberg / Carls Special 0,33L", "qty": "24 stk.", "est": 79, "badges": ["kasse"]},
{"key": "pepsi-max", "name": "Pepsi Max 1,5L", "qty": "6 stk.", "est": 49.99, "badges": ["kasse"]},
{"key": "faxe-kondi", "name": "Faxe Kondi Appelsin 0,33L", "qty": "24 stk.", "est": 54.99, "badges": ["kasse"]},
{"key": "kuehling-vin", "name": "Kühling hvid-/rosévin 0,75L", "qty": "4 stk.", "est": 159.96, "badges": ["card"]},
{"key": "grauburgunder", "name": "Grauburgunder hvidvin 1,5L", "qty": "1 stk.", "est": 59.99, "badges": ["card"]},
{"key": "kaffe", "name": "Eduscho Caffè Crema hele bønner", "qty": "1 kg", "est": 104.99},
]},
{"id": "hus", "emoji": "🏠", "title": "Husholdning & skafferi", "est": 535, "varer": [
{"key": "ariel", "name": "Ariel vaskemiddel", "note": "100111 vaske", "qty": "1 stk.", "est": 199.90},
{"key": "finish", "name": "Finish opvasketabs", "note": "64 stk.", "qty": "1 stk.", "est": 59.99},
{"key": "cashew", "name": "BASE Culinar cashewnødder", "qty": "1 kg", "est": 79.99},
{"key": "nutella", "name": "Nutella 1 kg glas", "qty": "1 stk.", "est": 49.99, "badges": ["card"]},
{"key": "biscoff", "name": "Lotus Biscoff smørepålæg 1,6 kg", "qty": "1 stk.", "est": 99.99, "badges": ["card"]},
{"key": "haribo", "name": "Haribo vingummi / lakrids", "note": "8001000g", "qty": "1 stk.", "est": 44.99},
]},
]
def slugify(text):
text = unicodedata.normalize('NFKD', text).encode('ascii', 'ignore').decode()
return re.sub(r'[-\s]+', '-', re.sub(r'[^\w\s-]', '', text.lower())).strip('-')
def get_db():
conn = sqlite3.connect(DB)
conn.row_factory = sqlite3.Row
return conn
def init_db():
with get_db() as c:
c.execute("""CREATE TABLE IF NOT EXISTS prices (
key TEXT PRIMARY KEY, price REAL, checked INT DEFAULT 0, updated TEXT)""")
c.execute("""CREATE TABLE IF NOT EXISTS extras (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT, section_key TEXT, price REAL,
photo TEXT, note TEXT, created TEXT)""")
c.execute("""CREATE TABLE IF NOT EXISTS custom_sections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
emoji TEXT DEFAULT '📦', title TEXT, created TEXT)""")
try:
c.execute("ALTER TABLE extras ADD COLUMN section_key TEXT")
except Exception:
pass
@app.route('/')
def index():
with get_db() as c:
saved = {r['key']: dict(r) for r in c.execute("SELECT * FROM prices")}
extras = [dict(r) for r in c.execute("SELECT * FROM extras ORDER BY created DESC")]
custom_secs = [dict(r) for r in c.execute("SELECT * FROM custom_sections ORDER BY created")]
by_sec = {}
for e in extras:
by_sec.setdefault(e.get('section_key') or '', []).append(e)
return render_template('index.html', sections=SECTIONS, saved=saved,
by_sec=by_sec, custom_secs=custom_secs)
@app.route('/api/price', methods=['POST'])
def save_price():
d = request.json
key = d.get('key')
price = d.get('price')
checked = int(d.get('checked', 0))
if not key:
return jsonify(error='missing key'), 400
with get_db() as c:
c.execute("""INSERT INTO prices(key,price,checked,updated) VALUES(?,?,?,?)
ON CONFLICT(key) DO UPDATE SET price=excluded.price,
checked=excluded.checked, updated=excluded.updated""",
(key, price, checked, datetime.now().isoformat()))
return jsonify(ok=True)
@app.route('/api/item', methods=['POST'])
def add_item():
name = request.form.get('name', '').strip()
if not name:
return jsonify(error='name required'), 400
price_s = request.form.get('price', '').strip().replace(',', '.')
price = float(price_s) if price_s else None
section_key = request.form.get('section_key', '').strip() or None
note = request.form.get('note', '').strip()
photo = None
f = request.files.get('photo')
if f and f.filename:
ext = (os.path.splitext(f.filename)[1] or '.jpg').lower()
fname = f"{slugify(name)}-{int(datetime.now().timestamp())}{ext}"
f.save(UPLOAD_DIR / fname)
photo = fname
with get_db() as c:
row = c.execute(
"INSERT INTO extras(name,section_key,price,photo,note,created) VALUES(?,?,?,?,?,?)",
(name, section_key, price, photo, note, datetime.now().isoformat()))
item_id = row.lastrowid
return jsonify(ok=True, id=item_id, photo=photo,
name=name, section_key=section_key, price=price, note=note)
@app.route('/api/item/<int:item_id>', methods=['DELETE'])
def delete_item(item_id):
with get_db() as c:
row = c.execute("SELECT photo FROM extras WHERE id=?", (item_id,)).fetchone()
if row and row['photo']:
p = UPLOAD_DIR / row['photo']
if p.exists():
p.unlink()
c.execute("DELETE FROM extras WHERE id=?", (item_id,))
return jsonify(ok=True)
@app.route('/uploads/<path:filename>')
def serve_upload(filename):
return send_from_directory(UPLOAD_DIR, filename)
@app.route('/health')
def health():
return jsonify({'status': 'healthy', 'service': 'citti'})
@app.route('/api/category', methods=['POST'])
def add_category():
d = request.json
emoji = (d.get('emoji') or '📦').strip() or '📦'
title = (d.get('title') or '').strip()
if not title:
return jsonify(error='title required'), 400
with get_db() as c:
row = c.execute("INSERT INTO custom_sections(emoji,title,created) VALUES(?,?,?)",
(emoji, title, datetime.now().isoformat()))
cat_id = row.lastrowid
return jsonify(ok=True, id=cat_id, key=f"custom-{cat_id}", emoji=emoji, title=title)
@app.route('/api/category/<int:cat_id>', methods=['DELETE'])
def delete_category(cat_id):
key = f"custom-{cat_id}"
with get_db() as c:
for r in c.execute("SELECT photo FROM extras WHERE section_key=?", (key,)).fetchall():
if r['photo']:
p = UPLOAD_DIR / r['photo']
if p.exists():
p.unlink()
c.execute("DELETE FROM extras WHERE section_key=?", (key,))
c.execute("DELETE FROM custom_sections WHERE id=?", (cat_id,))
return jsonify(ok=True)
@app.route('/analyse')
def analyse():
with get_db() as c:
saved = {r['key']: dict(r) for r in c.execute("SELECT * FROM prices")}
extras = [dict(r) for r in c.execute("SELECT * FROM extras ORDER BY price DESC NULLS LAST")]
custom_secs = [dict(r) for r in c.execute("SELECT * FROM custom_sections ORDER BY created")]
rows = []
total_est = total_actual = 0
for sec in SECTIONS:
sec_est = sec_actual = 0
for item in sec['varer']:
s = saved.get(item['key'], {})
actual = s.get('price')
sec_est += item['est']
if actual:
sec_actual += actual
rows.append({
'title': sec['title'],
'emoji': sec['emoji'],
'est': sec_est,
'actual': sec_actual or None,
'diff': (sec_actual - sec_est) if sec_actual else None,
})
total_est += sec_est
if sec_actual:
total_actual += sec_actual
return render_template('analyse.html', sections=SECTIONS, saved=saved,
extras=extras, rows=rows, custom_secs=custom_secs,
total_est=total_est,
total_actual=total_actual or None)
if __name__ == '__main__':
init_db()
port = int(os.environ.get('PORT', 9756))
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
local_ip = s.getsockname()[0]
s.close()
except Exception:
local_ip = "127.0.0.1"
print(f"\n Computer: http://localhost:{port}")
print(f" Telefon (samme WiFi): http://{local_ip}:{port}\n")
app.run(host='0.0.0.0', port=port, debug=False)