2026-06-06 00:54:07 +02:00
|
|
|
|
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": "350–500g · 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": "100–111 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": "800–1000g", "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 (
|
2026-06-06 01:18:04 +02:00
|
|
|
|
key TEXT PRIMARY KEY, price REAL, checked INT DEFAULT 0, qty TEXT, updated TEXT)""")
|
2026-06-06 00:54:07 +02:00
|
|
|
|
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
|
2026-06-06 01:18:04 +02:00
|
|
|
|
try:
|
|
|
|
|
|
c.execute("ALTER TABLE prices ADD COLUMN qty TEXT")
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
2026-06-06 00:54:07 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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))
|
2026-06-06 01:18:04 +02:00
|
|
|
|
qty = d.get('qty') or None
|
2026-06-06 00:54:07 +02:00
|
|
|
|
if not key:
|
|
|
|
|
|
return jsonify(error='missing key'), 400
|
|
|
|
|
|
with get_db() as c:
|
2026-06-06 01:18:04 +02:00
|
|
|
|
c.execute("""INSERT INTO prices(key,price,checked,qty,updated) VALUES(?,?,?,?,?)
|
2026-06-06 00:54:07 +02:00
|
|
|
|
ON CONFLICT(key) DO UPDATE SET price=excluded.price,
|
2026-06-06 01:18:04 +02:00
|
|
|
|
checked=excluded.checked, qty=excluded.qty, updated=excluded.updated""",
|
|
|
|
|
|
(key, price, checked, qty, datetime.now().isoformat()))
|
2026-06-06 00:54:07 +02:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-06 01:04:29 +02:00
|
|
|
|
@app.route('/health')
|
|
|
|
|
|
def health():
|
|
|
|
|
|
return jsonify({'status': 'healthy', 'service': 'citti'})
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-06 00:54:07 +02:00
|
|
|
|
@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()
|
2026-06-06 01:04:29 +02:00
|
|
|
|
port = int(os.environ.get('PORT', 9756))
|
2026-06-06 00:54:07 +02:00
|
|
|
|
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"
|
2026-06-06 01:04:29 +02:00
|
|
|
|
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)
|