bff6d70b17
Aplicatie Flask + SQLite pentru productie alimentara: cantarirea componentelor unui produs finit si tiparirea etichetei prin QZ Tray. - produse finite (retete) formate din componente cantarite - designer vizual de etichete (mm/px, zona sigura, campuri variabile, cod QR cu continut din mai multe bucati) - legare produs finit -> sablon de eticheta - navigare pe doua taburi: Cantarire / Setari - administrare: componente, produse finite, sabloane, campuri, istoric - backup, resetare baza de date, checklist de pregatire, /healthz - CLI de administrare si serviciu systemd pentru productie - vendor/syncpulse-scale-bridge (GPL-3.0-or-later) pentru citirea cantarului
408 lines
13 KiB
Python
408 lines
13 KiB
Python
"""Backup, reset si verificarea starii pentru punerea in productie.
|
||
|
||
Toate functiile se apeleaza in contextul aplicatiei Flask, deoarece folosesc
|
||
conexiunea SQLite tinuta de `g` (vezi `db.get_db`).
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import sqlite3
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from flask import current_app
|
||
|
||
from .config import DEV_SECRET_KEY
|
||
from .db import DEFAULT_SETTINGS, SCHEMA_VERSION, _table_names, get_db, init_db
|
||
|
||
BACKUP_PREFIX = "cantarire-"
|
||
BACKUP_SUFFIX = ".db"
|
||
KEEP_BACKUPS = 20
|
||
|
||
# Tabele golite la reset, in ordinea impusa de cheile straine.
|
||
DATA_TABLES = (
|
||
"package_items",
|
||
"packages",
|
||
"recipe_components",
|
||
"recipes",
|
||
"components",
|
||
"label_fields",
|
||
"label_templates",
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Backup
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def backup_dir() -> Path:
|
||
path = Path(current_app.config["INSTANCE_DIR"]) / "backups"
|
||
path.mkdir(parents=True, exist_ok=True)
|
||
return path
|
||
|
||
|
||
def backup_database(reason: str = "") -> Path:
|
||
"""Copiaza baza intr-un fisier consistent (API-ul de backup SQLite, WAL-safe)."""
|
||
source = Path(current_app.config["DATABASE"])
|
||
if not source.exists():
|
||
raise FileNotFoundError(f"Baza de date nu exista: {source}")
|
||
|
||
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||
label = f"-{reason}" if reason else ""
|
||
target = backup_dir() / f"{BACKUP_PREFIX}{stamp}{label}{BACKUP_SUFFIX}"
|
||
counter = 2
|
||
while target.exists():
|
||
target = backup_dir() / f"{BACKUP_PREFIX}{stamp}-{counter}{label}{BACKUP_SUFFIX}"
|
||
counter += 1
|
||
|
||
destination = sqlite3.connect(target)
|
||
try:
|
||
get_db().backup(destination)
|
||
destination.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
||
finally:
|
||
destination.close()
|
||
return target
|
||
|
||
|
||
def list_backups() -> list[dict[str, Any]]:
|
||
"""Backup-urile existente, cele mai noi primele."""
|
||
items: list[dict[str, Any]] = []
|
||
for path in backup_dir().glob(f"{BACKUP_PREFIX}*{BACKUP_SUFFIX}"):
|
||
stat = path.stat()
|
||
items.append({
|
||
"name": path.name,
|
||
"path": str(path),
|
||
"size": stat.st_size,
|
||
"size_human": _human_size(stat.st_size),
|
||
"created_at": datetime.fromtimestamp(stat.st_mtime),
|
||
})
|
||
return sorted(items, key=lambda item: item["created_at"], reverse=True)
|
||
|
||
|
||
def prune_backups(keep: int = KEEP_BACKUPS) -> int:
|
||
"""Sterge backup-urile peste limita; intoarce cate au fost sterse."""
|
||
backups = list_backups()
|
||
removed = 0
|
||
for item in backups[keep:]:
|
||
try:
|
||
Path(item["path"]).unlink()
|
||
removed += 1
|
||
except OSError:
|
||
pass
|
||
return removed
|
||
|
||
|
||
def _human_size(size: int) -> str:
|
||
value = float(size)
|
||
for unit in ("B", "kB", "MB", "GB"):
|
||
if value < 1024 or unit == "GB":
|
||
return f"{value:.0f} {unit}" if unit == "B" else f"{value:.1f} {unit}"
|
||
value /= 1024
|
||
return f"{value:.1f} GB"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Starea bazei
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _safe_count(table: str) -> int:
|
||
db = get_db()
|
||
if table not in _table_names(db):
|
||
return 0
|
||
return int(db.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0])
|
||
|
||
|
||
def database_status() -> dict[str, Any]:
|
||
path = Path(current_app.config["DATABASE"])
|
||
db = get_db()
|
||
try:
|
||
integrity = db.execute("PRAGMA quick_check").fetchone()[0]
|
||
except sqlite3.DatabaseError as exc: # pragma: no cover - doar la fisiere corupte
|
||
integrity = f"eroare: {exc}"
|
||
size = path.stat().st_size if path.exists() else 0
|
||
return {
|
||
"path": str(path),
|
||
"exists": path.exists(),
|
||
"size": size,
|
||
"size_human": _human_size(size),
|
||
"integrity": integrity,
|
||
"schema_version": SCHEMA_VERSION,
|
||
"counts": {
|
||
"components": _safe_count("components"),
|
||
"recipes": _safe_count("recipes"),
|
||
"packages": _safe_count("packages"),
|
||
"package_items": _safe_count("package_items"),
|
||
"templates": _safe_count("label_templates"),
|
||
"fields": _safe_count("label_fields"),
|
||
},
|
||
"backups": len(list_backups()),
|
||
"backup_dir": str(backup_dir()),
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Verificare de pregatire pentru productie
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def readiness() -> list[dict[str, Any]]:
|
||
"""Checklist pentru prima punere in functiune.
|
||
|
||
Fiecare element are `ok`, un mesaj scurt si (optional) link-ul unde se rezolva.
|
||
"""
|
||
from .repositories import list_components, list_recipes, list_templates
|
||
from .db import all_settings
|
||
from .scale import get_scale
|
||
|
||
settings = all_settings()
|
||
scale = get_scale(current_app)
|
||
profiles = scale.profiles()
|
||
components = list_components()
|
||
recipes = list_recipes()
|
||
templates = list_templates()
|
||
|
||
company = (settings.get("company_name") or "").strip()
|
||
company_ok = bool(company) and company != DEFAULT_SETTINGS["company_name"]
|
||
|
||
active_profile = (settings.get("active_scale_profile") or "").strip()
|
||
profile_ok = bool(profiles) and active_profile in {p["name"] for p in profiles}
|
||
|
||
secret = str(current_app.config.get("SECRET_KEY") or "")
|
||
secret_ok = bool(secret) and secret != DEV_SECRET_KEY
|
||
|
||
ready_recipes = [r for r in recipes if (r.get("component_count") or 0) > 0]
|
||
|
||
return [
|
||
{
|
||
"key": "database",
|
||
"label": "Baza de date",
|
||
"ok": True,
|
||
"message": database_status()["path"],
|
||
},
|
||
{
|
||
"key": "company",
|
||
"label": "Datele firmei",
|
||
"ok": company_ok,
|
||
"message": company if company_ok else "Completează denumirea firmei pe etichetă.",
|
||
"url": "/setari",
|
||
"action": "Deschide Setări",
|
||
},
|
||
{
|
||
"key": "components",
|
||
"label": "Componente",
|
||
"ok": bool(components),
|
||
"message": (f"{len(components)} active" if components
|
||
else "Adaugă ce se cântărește (ex. fasole, carne)."),
|
||
"url": "/componente/nou",
|
||
"action": "Adaugă component",
|
||
},
|
||
{
|
||
"key": "recipes",
|
||
"label": "Produse finite",
|
||
"ok": bool(ready_recipes),
|
||
"message": (f"{len(ready_recipes)} cu componente" if ready_recipes
|
||
else "Creează un produs finit cu 1–2 componente."),
|
||
"url": "/retete/nou",
|
||
"action": "Adaugă produs finit",
|
||
},
|
||
{
|
||
"key": "template",
|
||
"label": "Șablon de etichetă",
|
||
"ok": bool(templates),
|
||
"message": (templates[0]["name"] if templates
|
||
else "Fără șablon, eticheta nu poate fi tipărită."),
|
||
"url": "/sabloane",
|
||
"action": "Deschide șabloane",
|
||
},
|
||
{
|
||
"key": "company_contact",
|
||
"label": "Contact pe etichetă",
|
||
"ok": bool((settings.get("company_phone") or "").strip()),
|
||
"message": (settings.get("company_phone") or "Opțional: telefonul firmei."),
|
||
"url": "/setari",
|
||
"action": "Completează",
|
||
"optional": True,
|
||
},
|
||
{
|
||
"key": "scale",
|
||
"label": "Cântar",
|
||
"ok": profile_ok,
|
||
"message": (active_profile if profile_ok
|
||
else "Fără profil activ se cântărește manual."),
|
||
"url": "/setari",
|
||
"action": "Alege profilul",
|
||
"optional": True,
|
||
},
|
||
{
|
||
"key": "secret",
|
||
"label": "Cheie de sesiune",
|
||
"ok": secret_ok,
|
||
"message": ("Generată" if secret_ok
|
||
else "Setează CANTARIRE_SECRET_KEY înainte de producție."),
|
||
"optional": True,
|
||
},
|
||
]
|
||
|
||
|
||
def readiness_summary() -> dict[str, Any]:
|
||
items = readiness()
|
||
required = [item for item in items if not item.get("optional")]
|
||
missing = [item for item in required if not item["ok"]]
|
||
return {
|
||
"checks": items,
|
||
"missing": missing,
|
||
"ready": not missing,
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Reset
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def reset_database(*, keep_backup: bool = True) -> dict[str, Any]:
|
||
"""Goleste datele de productie si readuce setarile/etalonul la valorile initiale.
|
||
|
||
Pastreaza un backup inainte, ca operatia sa poata fi anulata manual.
|
||
"""
|
||
backup_path = backup_database("reset") if keep_backup else None
|
||
|
||
db = get_db()
|
||
db.execute("PRAGMA foreign_keys = OFF")
|
||
removed: dict[str, int] = {}
|
||
try:
|
||
for table in DATA_TABLES:
|
||
if table in _table_names(db):
|
||
removed[table] = int(db.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0])
|
||
db.execute(f"DELETE FROM {table}")
|
||
db.execute("DELETE FROM settings")
|
||
db.commit()
|
||
finally:
|
||
db.execute("PRAGMA foreign_keys = ON")
|
||
|
||
# `init_db` reseaza setarile implicite si sablonul de pornire.
|
||
init_db()
|
||
# Recupereaza spatiul fisierului dupa stergere.
|
||
get_db().execute("VACUUM")
|
||
|
||
return {
|
||
"removed": removed,
|
||
"backup": str(backup_path) if backup_path else None,
|
||
"status": database_status(),
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Date demo (pentru instruire / testare)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
DEMO_COMPONENTS = [
|
||
("FAS", "Fasole", "g"),
|
||
("CAR", "Carne", "g"),
|
||
("GAR", "Garnitură", "g"),
|
||
]
|
||
|
||
DEMO_RECIPES = [
|
||
{
|
||
"code": "PF-001",
|
||
"name": "Fasole cu carne",
|
||
"target_weight": 450.0,
|
||
"components": [("FAS", 250.0), ("CAR", 200.0)],
|
||
},
|
||
{
|
||
"code": "PF-002",
|
||
"name": "Ciorbă de legume",
|
||
"target_weight": 350.0,
|
||
"components": [("GAR", 350.0)],
|
||
},
|
||
]
|
||
|
||
|
||
def seed_demo() -> dict[str, Any]:
|
||
"""Recreaza datele demo (componente, produse finite, firma, operator)."""
|
||
from . import designer
|
||
from .db import all_settings, set_setting
|
||
from .repositories import (
|
||
create_component,
|
||
create_recipe,
|
||
get_component_by_code,
|
||
list_components,
|
||
list_recipes,
|
||
list_templates,
|
||
save_template_design,
|
||
)
|
||
|
||
created = {"components": 0, "recipes": 0}
|
||
for code, name, unit in DEMO_COMPONENTS:
|
||
if get_component_by_code(code) is None:
|
||
create_component({"code": code, "name": name, "unit": unit, "active": "on"})
|
||
created["components"] += 1
|
||
|
||
existing_codes = {recipe["code"] for recipe in list_recipes()}
|
||
for spec in DEMO_RECIPES:
|
||
if spec["code"] in existing_codes:
|
||
continue
|
||
data: dict[str, Any] = {
|
||
"code": spec["code"],
|
||
"name": spec["name"],
|
||
"unit": "g",
|
||
"target_weight": spec["target_weight"],
|
||
"active": "on",
|
||
}
|
||
for slot, (code, target) in enumerate(spec["components"], start=1):
|
||
component = get_component_by_code(code)
|
||
if component is None:
|
||
continue
|
||
data[f"component_{slot}_id"] = str(component["id"])
|
||
data[f"component_{slot}_target"] = str(target)
|
||
create_recipe(data)
|
||
created["recipes"] += 1
|
||
|
||
settings = all_settings()
|
||
if not (settings.get("company_name") or "").strip() or \
|
||
settings.get("company_name") == DEFAULT_SETTINGS["company_name"]:
|
||
set_setting("company_name", "Moto Adv SRL")
|
||
if not (settings.get("company_phone") or "").strip():
|
||
set_setting("company_phone", "0700 000 000")
|
||
if not (settings.get("default_operator") or "").strip():
|
||
set_setting("default_operator", "Maria")
|
||
|
||
templates = list_templates()
|
||
if templates and not (templates[0].get("design_json") or "").strip():
|
||
template = templates[0]
|
||
save_template_design(
|
||
template["id"],
|
||
designer.dump_design(designer.default_design(template["width_mm"], template["height_mm"])),
|
||
)
|
||
|
||
return {
|
||
"created": created,
|
||
"recipes": [recipe["code"] for recipe in list_recipes()],
|
||
"components": [row["code"] for row in list_components(include_inactive=True)],
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Utilitare pentru afisare
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def reset_report(result: dict[str, Any]) -> str:
|
||
"""Rezumat lizibil pentru CLI."""
|
||
lines = ["Date sterse:"]
|
||
for table, count in result["removed"].items():
|
||
lines.append(f" {table:<20} {count}")
|
||
if result["backup"]:
|
||
lines.append(f"Backup: {result['backup']}")
|
||
else:
|
||
lines.append("Backup: (nu s-a facut)")
|
||
status = result["status"]
|
||
lines.append(
|
||
f"Baza: {status['path']} · {status['size_human']} · schema v{status['schema_version']}"
|
||
)
|
||
return "\n".join(lines)
|
||
|
||
|
||
def ensure_instance_dirs() -> None:
|
||
"""Creeaza folderele de rulare daca lipsesc (util la prima pornire)."""
|
||
root = Path(current_app.config["INSTANCE_DIR"])
|
||
(root / "backups").mkdir(parents=True, exist_ok=True)
|
||
(root / "captures").mkdir(parents=True, exist_ok=True)
|