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 ( 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/', 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/') def serve_upload(filename): return send_from_directory(UPLOAD_DIR, filename) @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/', 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() 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:9756") print(f" Telefon (samme WiFi): http://{local_ip}:9756\n") app.run(host='0.0.0.0', port=9756, debug=False)