Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.33.3
1.34.0
1 change: 1 addition & 0 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": ","},
}


Expand Down
43 changes: 41 additions & 2 deletions backup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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-<N>.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)
Expand Down
3 changes: 2 additions & 1 deletion database.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 6 additions & 3 deletions deploy/spool-backup.timer
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions deploy/update-lxc.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
8 changes: 8 additions & 0 deletions routes/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
)


Expand All @@ -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:
Expand Down
8 changes: 8 additions & 0 deletions templates/admin/backup.html
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,14 @@ <h6 class="text-muted text-uppercase fw-semibold mb-3" style="font-size:.75rem">

<hr class="my-3">
<form method="post" action="{{ url_for('admin_backup_config') }}">
<label class="form-label fw-semibold" for="backup_hour">{{ _('Horário do backup automático') }}</label>
<select name="backup_hour" id="backup_hour" class="form-select" style="max-width:160px">
{% for h in range(24) %}
<option value="{{ h }}" {% if h == backup_hour %}selected{% endif %}>{{ '%02d:00' | format(h) }}</option>
{% endfor %}
</select>
<div class="form-text mb-3">{{ _('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.') }}</div>

<label class="form-label fw-semibold">{{ _('Pasta de backup externa') }} <span class="text-muted fw-normal">({{ _('opcional') }})</span></label>
<div class="input-group">
<input type="text" name="backup_external_dir" class="form-control"
Expand Down
1 change: 1 addition & 0 deletions templates/admin/settings.html
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ <h6 class="text-muted text-uppercase fw-semibold mb-3" style="font-size:.75rem">
<option value="BRL" {% if settings.currency == 'BRL' or not settings.currency %}selected{% endif %}>BRL — R$ &nbsp;(1.234,56)</option>
<option value="USD" {% if settings.currency == 'USD' %}selected{% endif %}>USD — $ &nbsp;(1,234.56)</option>
<option value="EUR" {% if settings.currency == 'EUR' %}selected{% endif %}>EUR — € &nbsp;(1.234,56)</option>
<option value="CAD" {% if settings.currency == 'CAD' %}selected{% endif %}>CAD — C$ &nbsp;(1,234.56)</option>
</select>
<div class="form-text">{{ _('Símbolo e formato usados nos preços. Independente do idioma da interface.') }}</div>
</div>
Expand Down
67 changes: 62 additions & 5 deletions tests/test_backup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -54,14 +54,59 @@ 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):
import backup
_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", "") == ""
Expand All @@ -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()
Expand Down Expand Up @@ -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"]
Expand Down Expand Up @@ -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
6 changes: 6 additions & 0 deletions translations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:"
Expand All @@ -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:"
Expand Down