Files
cantarire-etichetare/cantarire/maintenance.py
T
ske087 bff6d70b17 Initial commit: aplicatie de cantarire si etichetare
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
2026-10-18 23:38:45 +03:00

408 lines
13 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.
"""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)