"""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)