From ab93f9d4406f8566e9b4f58dd75e697b3e4dd42b Mon Sep 17 00:00:00 2001 From: iscarelli Date: Wed, 10 Jun 2026 10:43:07 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20d=C3=B3lar=20canadense=20e=20hor=C3=A1r?= =?UTF-8?q?io=20configur=C3=A1vel=20do=20backup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adiciona CAD (C$, formato 1,234.56) às moedas — orientado pelo registro _CURRENCY_META, sem mudanças em format/parse. Torna o horário do backup automático configurável em /admin/backup (setting backup_hour, 0..23 local, default 03:00). O timer passa a acordar de hora em hora e o backup.py decide a hora certa, rodando no máx. 1x/dia com catch-up e retry — mantém o horário no app, sem exigir root/daemon-reload. Deploy re-arma o timer após daemon-reload. i18n EN/ES e testes (gate de agendamento + rota de config). Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 10 ++++++ VERSION | 2 +- app.py | 1 + backup.py | 43 ++++++++++++++++++++-- database.py | 3 +- deploy/spool-backup.timer | 9 +++-- deploy/update-lxc.sh | 3 ++ routes/admin.py | 8 +++++ templates/admin/backup.html | 8 +++++ templates/admin/settings.html | 1 + tests/test_backup.py | 67 ++++++++++++++++++++++++++++++++--- translations.py | 6 ++++ 12 files changed, 149 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7d077a..c0dae54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,16 @@ Versioning follows [SemVer](https://semver.org/): **MAJOR.MINOR.PATCH** --- +## [1.34.0] — 2026-06-10 + +### Added +- **Dólar canadense (CAD) nas moedas.** Nova opção em Configurações (símbolo `C$`, formato `1,234.56`). Como o formato é orientado pelo registro de moedas, formatação e parsing de preços já funcionam sem mudanças adicionais. +- **Horário do backup automático configurável.** Em `/admin/backup` é possível escolher a hora local (00:00–23:00) em que o backup diário é gerado. A rotação continua sendo um arquivo por dia da semana — só muda o instante da gravação. + +### Changed +- **Timer de backup agora acorda de hora em hora** (`OnCalendar=*-*-* *:00:00`); o `backup.py` decide se é a hora certa (setting `backup_hour`, default 03:00) e roda no máximo uma vez por dia, com catch-up se a máquina estava desligada na hora marcada e retry horário em caso de falha. Mantém o horário no app (não no systemd), evitando exigir root/`daemon-reload` para trocá-lo. Instalações existentes seguem em 03:00 por padrão. +- Deploy re-arma o `spool-backup.timer` (`systemctl restart`) após o `daemon-reload`, garantindo o re-cálculo do próximo disparo quando o agendamento muda. Nenhuma ação manual é necessária ao atualizar. + ## [1.33.3] — 2026-06-10 ### Fixed diff --git a/VERSION b/VERSION index c7f962f..2b17ffd 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.33.3 +1.34.0 diff --git a/app.py b/app.py index 1a9797c..774654b 100644 --- a/app.py +++ b/app.py @@ -517,6 +517,7 @@ def get_ordered_materials(): "BRL": {"symbol": "R$", "decimal": ",", "thousands": "."}, "USD": {"symbol": "$", "decimal": ".", "thousands": ","}, "EUR": {"symbol": "€", "decimal": ",", "thousands": "."}, + "CAD": {"symbol": "C$", "decimal": ".", "thousands": ","}, } diff --git a/backup.py b/backup.py index d94949a..70cccfe 100644 --- a/backup.py +++ b/backup.py @@ -125,10 +125,49 @@ def slot_filename(slot): return f"spool-backup-{slot}.zip" -def run_scheduled_backup(): +def backup_hour(): + """Hora LOCAL (0..23) configurada p/ o backup diário. Default 3 (03:00). + Valor inválido cai no default — nunca derruba o backup.""" + try: + h = int(db.get_setting("backup_hour", "3")) + except (TypeError, ValueError): + return 3 + return h if 0 <= h <= 23 else 3 + + +def _ran_ok_today(): + """True se já houve um backup BEM-SUCEDIDO hoje (data LOCAL). Torna o disparo + horário idempotente e o catch-up do `Persistent=` inofensivo; um backup que + FALHOU não conta, então a próxima hora tenta de novo até dar certo.""" + if db.get_setting("backup_last_result", "") != "ok": + return False + last = db.get_setting("backup_last_run", "") + if not last: + return False + try: # backup_last_run é UTC (db.now_iso) — converte p/ data local antes de comparar. + when = datetime.strptime(last, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc) + except ValueError: + return False + return when.astimezone().date() == datetime.now().date() + + +def due_now(): + """O timer acorda de hora em hora; só rodamos a partir da hora configurada e + enquanto não houve sucesso hoje. Usar `>=` (não `==`) preserva o catch-up: se a + máquina estava desligada na hora marcada e liga depois no mesmo dia, ainda roda.""" + return datetime.now().hour >= backup_hour() and not _ran_ok_today() + + +def run_scheduled_backup(force=False): """Backup diário rotativo: grava sempre local em data/backups/spool-backup-.zip (N = dia da semana). Se `backup_external_dir` estiver configurado, copia também. - Persiste o status em settings p/ a UI alertar. Retorna o caminho local ou None.""" + Persiste o status em settings p/ a UI alertar. Retorna o caminho local ou None. + + O timer dispara de hora em hora; sem `force`, só executa quando `due_now()` — + nas demais horas é um tick silencioso (não mexe no status). `force=True` ignora + o agendamento (backup manual).""" + if not force and not due_now(): + return None now = db.now_iso() slot = slot_today() name = slot_filename(slot) diff --git a/database.py b/database.py index 9af38af..92746b7 100644 --- a/database.py +++ b/database.py @@ -188,7 +188,8 @@ def init_db(): ('low_stock_pct', '20'), ('label_width_mm', '60'), ('label_height_mm', '40'), - ('currency', 'BRL'); + ('currency', 'BRL'), + ('backup_hour', '3'); """) # app_base_url é semeado a partir do ambiente (APP_BASE_URL, definido na # instalação) — cada usuário roda no próprio servidor, então o QR precisa diff --git a/deploy/spool-backup.timer b/deploy/spool-backup.timer index 77c99d3..fe4b96b 100644 --- a/deploy/spool-backup.timer +++ b/deploy/spool-backup.timer @@ -2,9 +2,12 @@ Description=Spool Control daily backup timer [Timer] -# Diário às 03:00 (hora local da máquina). Persistent=true recupera uma execução -# perdida se a máquina estava desligada no horário. -OnCalendar=*-*-* 03:00:00 +# Acorda de hora em hora; o backup.py decide se é a hora certa (setting +# `backup_hour`, configurável na UI) e roda no máximo uma vez por dia. Manter o +# horário no app (e não aqui) evita exigir root/daemon-reload pra trocar a hora. +# Persistent=true + a lógica de catch-up do backup.py recuperam uma execução +# perdida se a máquina estava desligada na hora marcada. +OnCalendar=*-*-* *:00:00 Persistent=true RandomizedDelaySec=300 diff --git a/deploy/update-lxc.sh b/deploy/update-lxc.sh index 3a8c2e7..e6ab5bb 100644 --- a/deploy/update-lxc.sh +++ b/deploy/update-lxc.sh @@ -182,6 +182,9 @@ EOF || warn "Nao foi possivel habilitar spool-update.path (update via web pode usar fallback)." systemctl enable --now spool-backup.timer 2>/dev/null \ || warn "Nao foi possivel habilitar spool-backup.timer (backup automatico indisponivel)." + # Re-arma o timer com o OnCalendar atual (o daemon-reload acima ja releu a unit; + # o restart garante o re-calculo do proximo disparo apos mudanca de agendamento). + systemctl restart spool-backup.timer 2>/dev/null || true systemctl restart spool-control sleep 2 systemctl is-active spool-control || error "Servico nao iniciou. Veja: journalctl -u spool-control -n 30" diff --git a/routes/admin.py b/routes/admin.py index ba5a568..042d6b2 100644 --- a/routes/admin.py +++ b/routes/admin.py @@ -289,6 +289,7 @@ def admin_backup(): last_result=db.get_setting("backup_last_result", ""), last_error=db.get_setting("backup_last_error", ""), external_error=db.get_setting("backup_external_error", ""), + backup_hour=backup.backup_hour(), ) @@ -300,6 +301,13 @@ def admin_backup_config(): avisar já; o backup diário sempre grava local independente disto.""" ext_dir = request.form.get("backup_external_dir", "").strip() db.set_setting("backup_external_dir", ext_dir) + # Hora do backup automático (0..23, local). Valor inválido mantém o atual. + try: + hour = int(request.form.get("backup_hour", "")) + if 0 <= hour <= 23: + db.set_setting("backup_hour", str(hour)) + except (TypeError, ValueError): + pass if ext_dir: werr = backup.test_external_writable(ext_dir) if werr: diff --git a/templates/admin/backup.html b/templates/admin/backup.html index ef4b71c..36447d3 100644 --- a/templates/admin/backup.html +++ b/templates/admin/backup.html @@ -75,6 +75,14 @@

+ + +
{{ _('Hora local do servidor em que o backup diário é gerado. A rotação continua sendo um arquivo por dia da semana — só muda o instante da gravação.') }}
+
+
{{ _('Símbolo e formato usados nos preços. Independente do idioma da interface.') }}
diff --git a/tests/test_backup.py b/tests/test_backup.py index d56d5fc..83c2884 100644 --- a/tests/test_backup.py +++ b/tests/test_backup.py @@ -31,7 +31,7 @@ def test_make_backup_file_is_valid_and_restorable(app_module, db, tmp_path): def test_scheduled_backup_uses_weekday_number(app_module, db): import backup _make_data(db) - result = backup.run_scheduled_backup() + result = backup.run_scheduled_backup(force=True) slot = datetime.now().isoweekday() expected = backup.BACKUPS_DIR / f"spool-backup-{slot}.zip" assert result == expected @@ -44,7 +44,7 @@ def test_scheduled_backup_uses_weekday_number(app_module, db): def test_list_local_backups_reports_real_date(app_module, db): import backup _make_data(db) - backup.run_scheduled_backup() + backup.run_scheduled_backup(force=True) rows = backup.list_local_backups() assert len(rows) == 7 slot = datetime.now().isoweekday() @@ -54,6 +54,51 @@ def test_list_local_backups_reports_real_date(app_module, db): assert row["size_kb"] >= 0 +# ── Gate de agendamento (timer horário + hora configurável) ────────────────── + +def test_backup_hour_clamps_invalid(app_module, db): + import backup + db.set_setting("backup_hour", "0"); assert backup.backup_hour() == 0 + db.set_setting("backup_hour", "23"); assert backup.backup_hour() == 23 + db.set_setting("backup_hour", "99"); assert backup.backup_hour() == 3 + db.set_setting("backup_hour", "abc"); assert backup.backup_hour() == 3 + + +def test_scheduled_backup_idempotent_same_day(app_module, db): + """Hora 0 → sempre 'na hora'; roda uma vez e os ticks horários seguintes do + mesmo dia são silenciosos (não reescrevem nem o arquivo nem o status).""" + import backup + _make_data(db) + db.set_setting("backup_hour", "0") + first = backup.run_scheduled_backup() + assert first is not None + assert backup.due_now() is False # já houve sucesso hoje + assert backup.run_scheduled_backup() is None + + +def test_scheduled_backup_skips_before_configured_hour(app_module, db): + """Antes da hora marcada não roda e NÃO toca no status (tick ocioso).""" + import backup + from datetime import datetime + h = datetime.now().hour + if h >= 23: + return # 23:xx: não há hora futura no mesmo dia p/ testar o 'antes' + _make_data(db) + db.set_setting("backup_hour", str(h + 1)) + assert backup.due_now() is False + assert backup.run_scheduled_backup() is None + assert db.get_setting("backup_last_result", "") == "" + + +def test_force_runs_even_when_not_due(app_module, db): + """Backup manual ignora o gate de horário.""" + import backup + _make_data(db) + db.set_setting("backup_hour", "23") + assert backup.run_scheduled_backup(force=True) is not None + assert db.get_setting("backup_last_result") == "ok" + + # ── Cópia externa + alerta ─────────────────────────────────────────────────── def test_external_copy_success(app_module, db, tmp_path): @@ -61,7 +106,7 @@ def test_external_copy_success(app_module, db, tmp_path): _make_data(db) ext = tmp_path / "ext" db.set_setting("backup_external_dir", str(ext)) - backup.run_scheduled_backup() + backup.run_scheduled_backup(force=True) slot = datetime.now().isoweekday() assert (ext / f"spool-backup-{slot}.zip").exists() assert db.get_setting("backup_external_error", "") == "" @@ -74,7 +119,7 @@ def test_external_copy_failure_sets_alert_but_keeps_local(app_module, db, tmp_pa blocker = tmp_path / "afile" blocker.write_text("x", encoding="utf-8") db.set_setting("backup_external_dir", str(blocker / "sub")) - backup.run_scheduled_backup() + backup.run_scheduled_backup(force=True) slot = datetime.now().isoweekday() # Local OK mesmo com a externa falhando. assert (backup.BACKUPS_DIR / f"spool-backup-{slot}.zip").exists() @@ -107,7 +152,7 @@ def test_manual_download_keeps_timestamped_name(auth_client): def test_restore_local_roundtrip(auth_client, db): import backup _make_data(db) - backup.run_scheduled_backup() + backup.run_scheduled_backup(force=True) slot = datetime.now().isoweekday() # Apaga o filamento e restaura o backup local — deve voltar. fid = db.list_filaments()[0]["id"] @@ -137,3 +182,15 @@ def test_backup_config_saves_external_dir(auth_client, db, tmp_path): assert resp.status_code == 302 assert "/admin/backup" in resp.headers["Location"] assert db.get_setting("backup_external_dir") == ext + + +def test_backup_config_saves_hour(auth_client, db): + resp = auth_client.post("/admin/backup/config", data={"backup_hour": "5"}) + assert resp.status_code == 302 + assert db.get_setting("backup_hour") == "5" + + +def test_backup_config_rejects_invalid_hour(auth_client, db): + db.set_setting("backup_hour", "5") + auth_client.post("/admin/backup/config", data={"backup_hour": "99"}) + assert db.get_setting("backup_hour") == "5" # inválido → mantém o atual diff --git a/translations.py b/translations.py index 5c4acdc..3b39a2f 100644 --- a/translations.py +++ b/translations.py @@ -968,6 +968,9 @@ "Path on the server (e.g. a mounted disk/network share). The daily backup always writes locally; if this folder is set, a copy is also written here. If writing fails, an alert appears on this page." _EN["Pasta de backup externa não é gravável: {e}"] = "External backup folder is not writable: {e}" _EN["Backups automáticos (diários)"] = "Automatic backups (daily)" +_EN["Horário do backup automático"] = "Automatic backup time" +_EN["Hora local do servidor em que o backup diário é gerado. A rotação continua sendo um arquivo por dia da semana — só muda o instante da gravação."] = \ + "Server local time at which the daily backup is generated. The rotation is still one file per weekday — only the moment it is written changes." _EN["O sistema mantém um backup por dia da semana (7 no total), sobrescrevendo o do mesmo dia a cada semana."] = \ "The system keeps one backup per weekday (7 total), overwriting the same weekday each week." _EN["Cópia externa em:"] = "External copy at:" @@ -986,6 +989,9 @@ "Ruta en el servidor (p. ej. un disco/red montado). El backup diario siempre se guarda localmente; si esta carpeta está definida, también se guarda una copia aquí. Si no se puede escribir, aparece una alerta en esta página." _ES["Pasta de backup externa não é gravável: {e}"] = "La carpeta de backup externa no es escribible: {e}" _ES["Backups automáticos (diários)"] = "Backups automáticos (diarios)" +_ES["Horário do backup automático"] = "Hora del backup automático" +_ES["Hora local do servidor em que o backup diário é gerado. A rotação continua sendo um arquivo por dia da semana — só muda o instante da gravação."] = \ + "Hora local del servidor en que se genera el backup diario. La rotación sigue siendo un archivo por día de la semana — solo cambia el momento de la escritura." _ES["O sistema mantém um backup por dia da semana (7 no total), sobrescrevendo o do mesmo dia a cada semana."] = \ "El sistema mantiene un backup por día de la semana (7 en total), sobrescribiendo el del mismo día cada semana." _ES["Cópia externa em:"] = "Copia externa en:"