diff --git a/CHANGELOG.md b/CHANGELOG.md index e1a85fd..7ca1fc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,22 @@ Versioning follows [SemVer](https://semver.org/): **MAJOR.MINOR.PATCH** --- -## [1.36.2] — 2026-06-11 +## [1.37.0] — 2026-07-10 + +### Added +- **Excluir rolo (permanente) na lista.** Cada linha da lista de rolos ganhou um + botão de exclusão definitiva (lixeira, vermelho). Diferente de "Finalizar" (que + só marca `active=0` e mantém o histórico), a exclusão apaga o rolo e todo o + histórico de pesagens do banco — irreversível, com confirmação. É **restrita a + admin** (`/spools//delete`, `admin_required`); o botão só aparece para admins. +- **Finalizar direto da lista.** O botão "Finalizar" (que só existia na tela de + edição) agora também está em cada linha da lista, para quem tem permissão de + escrita. +- **Cadastrar vários rolos iguais de uma vez.** A tela de "Novo Spool" ganhou um + campo **Quantidade** (1–50): cria N rolos idênticos com IDs distintos numa só + submissão. Ao cadastrar 1, o fluxo é o de sempre (vai ao detalhe e oferece a + fila). Ao cadastrar vários, volta à lista e oferece adicionar **os N** à fila de + impressão de uma vez (uma etiqueta/QR por rolo), reusando o `label_queue_add_all`. ### Fixed - **Card "Ver novidades" some quando a API do GitHub falha (fica só o link).** A diff --git a/VERSION b/VERSION index c6a567b..bf50e91 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.36.2 +1.37.0 diff --git a/app.py b/app.py index 801a74c..d759938 100644 --- a/app.py +++ b/app.py @@ -629,6 +629,7 @@ def inject_globals(): "_": i18n.get_translator(lang), "update_available": is_update_available() if is_admin else False, "can_write": can_write, + "is_admin": is_admin, "backup_alert": backup_alert, "nonce": getattr(g, "_nonce", ""), "demo_mode": DEMO_MODE, diff --git a/database.py b/database.py index 5df1eaa..ba5c2ca 100644 --- a/database.py +++ b/database.py @@ -821,6 +821,19 @@ def deactivate_spool(spool_id): db.commit() +def delete_spool(spool_id): + """Apaga o rolo DE VEZ, junto com o histórico de pesagens e a entrada na fila. + + As FKs (weight_readings/label_queue → spools) não têm ON DELETE CASCADE e o + PRAGMA foreign_keys pode estar desligado, então removemos os filhos + explicitamente — tudo numa transação para não deixar órfãos.""" + with closing(get_db()) as db: + db.execute("DELETE FROM label_queue WHERE spool_id=?", (spool_id,)) + db.execute("DELETE FROM weight_readings WHERE spool_id=?", (spool_id,)) + db.execute("DELETE FROM spools WHERE id=?", (spool_id,)) + db.commit() + + # ── Weight Readings ──────────────────────────────────────────────────────── def add_weight_reading(spool_id, gross_weight_g, tare_weight_g, recorded_by="", notes=""): diff --git a/routes/spools.py b/routes/spools.py index 718bc60..da4f98d 100644 --- a/routes/spools.py +++ b/routes/spools.py @@ -9,7 +9,8 @@ import labels as lbl import niimbot_registry as reg import logger as log_cfg -from app import app, login_required, write_required, t, _parse_price, public_base_url, _label_spool, _currency_meta +from app import (app, login_required, write_required, admin_required, t, _parse_price, + public_base_url, _label_spool, _currency_meta) log = log_cfg.get_logger() @@ -40,8 +41,10 @@ def spools_list(): active_only = True else: spools = db.list_spools(active_only=active_only) + # IDs recém-cadastrados em lote (?created=1,2,3) — a lista oferece a fila p/ todos. + created_ids = [int(x) for x in request.args.get("created", "").split(",") if x.isdigit()] return render_template("spools/list.html", spools=spools, active_only=active_only, - q=q, color=color, queue_ids=db.queue_ids()) + q=q, color=color, queue_ids=db.queue_ids(), created_ids=created_ids) @app.route("/spools/new", methods=["GET", "POST"]) @@ -50,7 +53,13 @@ def spools_new(): filament_id = request.args.get("filament_id", type=int) if request.method == "POST": try: - spool_id = db.create_spool( + # Quantidade: cria N rolos idênticos de uma vez (clamp 1..50). + try: + quantity = int(request.form.get("quantity") or 1) + except ValueError: + quantity = 1 + quantity = max(1, min(quantity, 50)) + common = dict( filament_id=int(request.form["filament_id"]), spool_model_id=request.form.get("spool_model_id") or None, custom_tare_g=request.form.get("custom_tare_g") or None, @@ -60,8 +69,14 @@ def spools_new(): purchase_price=_parse_price(request.form.get("purchase_price", "")), notes=request.form.get("notes", "").strip(), ) - flash(t("Rolo cadastrado com sucesso"), "success") - return redirect(url_for("spools_detail", spool_id=spool_id, queue_prompt="1")) + ids = [db.create_spool(**common) for _ in range(quantity)] + if quantity == 1: + flash(t("Rolo cadastrado com sucesso"), "success") + return redirect(url_for("spools_detail", spool_id=ids[0], queue_prompt="1")) + # Vários: volta à lista, que oferece a fila de impressão p/ os N (add-all). + flash(t("{n} rolos cadastrados (SP-{first} a SP-{last})").format( + n=quantity, first=f"{ids[0]:04d}", last=f"{ids[-1]:04d}"), "success") + return redirect(url_for("spools_list", created=",".join(str(i) for i in ids))) except Exception: log.error("spool.create_failed", exc_info=True) flash(t("Erro ao processar. Tente novamente."), "danger") @@ -170,6 +185,19 @@ def spools_deactivate(spool_id): return redirect(url_for("spools_list")) +@app.route("/spools//delete", methods=["POST"]) +@admin_required +def spools_delete(spool_id): + """Exclusão permanente (só admin) — apaga o rolo e todo o histórico.""" + spool = db.get_spool(spool_id) + if not spool: + abort(404) + db.delete_spool(spool_id) + log.info("spool.deleted", spool_id=spool_id, by=session.get("username", "")) + flash(t("Rolo SP-{code} excluído").format(code=f"{spool_id:04d}"), "success") + return redirect(url_for("spools_list")) + + @app.route("/spools//label.pdf") @login_required def spool_label_pdf(spool_id): diff --git a/templates/spools/form.html b/templates/spools/form.html index f049eb9..d309399 100644 --- a/templates/spools/form.html +++ b/templates/spools/form.html @@ -89,6 +89,13 @@

{% if spool %}{{ _('Editar') }} SP-{{ '%04d'|format(spo + {% if not spool %} +
+ + +
{{ _('Crie vários rolos idênticos de uma vez (IDs distintos).') }}
+
+ {% endif %}
diff --git a/templates/spools/list.html b/templates/spools/list.html index c42eaf9..609004b 100644 --- a/templates/spools/list.html +++ b/templates/spools/list.html @@ -99,7 +99,17 @@

{% endif %} {% if can_write %} +
+ +
+ {% endif %} {% endif %} + {% if is_admin %} +
+ +
{% endif %} @@ -135,6 +145,31 @@

+ +{% if created_ids %} +{# Após cadastrar N rolos: oferece adicionar todos à fila de impressão (add-all). #} + +{% endif %} {% endblock %} {% block scripts %} @@ -256,5 +291,10 @@

btn.disabled = false; btn.textContent = '{{ _('Registrar') }}'; }); + +{% if created_ids %} +/* ── Cadastro em lote: abre o modal da fila de impressão ─────────────────── */ +new bootstrap.Modal(document.getElementById('createdQueueModal')).show(); +{% endif %} {% endblock %} diff --git a/tests/test_spool_delete_bulk.py b/tests/test_spool_delete_bulk.py new file mode 100644 index 0000000..ed5f406 --- /dev/null +++ b/tests/test_spool_delete_bulk.py @@ -0,0 +1,117 @@ +"""Testes de v1.37.0: + + - Exclusão permanente de rolo (db.delete_spool + rota admin-only). + - Cadastro em lote (campo Quantidade cria N rolos idênticos). +""" +from conftest import ADMIN_USER, ADMIN_PASS + + +def _make_filament(db): + return db.create_filament(brand="Acme", material="PLA", family="PLA", + color_hex="#ffffff") + + +def _make_spool(db, fid, nominal=1000.0): + return db.create_spool(filament_id=fid, spool_model_id=None, custom_tare_g=200.0, + nominal_weight_g=nominal, location="", purchase_date="", + purchase_price=None, notes="") + + +# ── Exclusão permanente (cascade) ──────────────────────────────────────────── + +def test_delete_spool_cascades(db): + """delete_spool apaga o rolo, o histórico de pesagens e a entrada na fila.""" + fid = _make_filament(db) + sid = _make_spool(db, fid) + db.add_weight_reading(sid, gross_weight_g=800.0, tare_weight_g=200.0) + db.queue_add(sid) + assert db.get_spool(sid) is not None + assert db.list_weight_readings(spool_id=sid) + assert db.is_in_queue(sid) + + db.delete_spool(sid) + + assert db.get_spool(sid) is None + assert db.list_weight_readings(spool_id=sid) == [] + assert not db.is_in_queue(sid) + + +def test_delete_route_admin_only(viewer_client, auth_client, db): + """POST /spools//delete é 403 p/ viewer e remove o rolo p/ admin.""" + fid = _make_filament(db) + sid = _make_spool(db, fid) + + assert viewer_client.post(f"/spools/{sid}/delete", data={}).status_code == 403 + assert db.get_spool(sid) is not None # viewer não conseguiu apagar + + resp = auth_client.post(f"/spools/{sid}/delete", data={}) + assert resp.status_code == 302 + assert db.get_spool(sid) is None + + +def test_delete_unknown_spool_404(auth_client): + assert auth_client.post("/spools/999999/delete", data={}).status_code == 404 + + +# ── Cadastro em lote (Quantidade) ──────────────────────────────────────────── + +def test_bulk_create_makes_n_spools(auth_client, db): + fid = _make_filament(db) + resp = auth_client.post("/spools/new", data={ + "filament_id": str(fid), "nominal_weight_g": "1000", "quantity": "3", + }) + assert resp.status_code == 302 + assert "/spools?created=" in resp.headers["Location"] + assert len(db.list_spools()) == 3 + + +def test_single_create_keeps_detail_flow(auth_client, db): + """Quantidade 1 (default) mantém o fluxo antigo: vai ao detalhe c/ queue_prompt.""" + fid = _make_filament(db) + resp = auth_client.post("/spools/new", data={ + "filament_id": str(fid), "nominal_weight_g": "1000", "quantity": "1", + }) + assert resp.status_code == 302 + loc = resp.headers["Location"] + sid = db.list_spools()[0]["id"] + assert f"/spools/{sid}" in loc and "queue_prompt=1" in loc + + +def test_bulk_create_clamps_quantity(auth_client, db): + """Quantidade acima do teto (50) é limitada; entrada inválida vira 1.""" + fid = _make_filament(db) + auth_client.post("/spools/new", data={ + "filament_id": str(fid), "nominal_weight_g": "1000", "quantity": "999", + }) + assert len(db.list_spools()) == 50 + + +# ── Renderização da UI ─────────────────────────────────────────────────────── + +def test_list_shows_delete_and_finish_for_admin(auth_client, db): + fid = _make_filament(db) + sid = _make_spool(db, fid) + html = auth_client.get("/spools").get_data(as_text=True) + assert f"/spools/{sid}/delete" in html # botão excluir (admin) + assert f"/spools/{sid}/deactivate" in html # botão finalizar (escrita) + + +def test_list_hides_delete_for_viewer(viewer_client, auth_client, db): + fid = _make_filament(db) + sid = _make_spool(db, fid) + html = viewer_client.get("/spools").get_data(as_text=True) + assert f"/spools/{sid}/delete" not in html # excluir é só admin + assert f"/spools/{sid}/deactivate" not in html # finalizar exige escrita + + +def test_new_form_has_quantity_field(auth_client): + html = auth_client.get("/spools/new").get_data(as_text=True) + assert 'name="quantity"' in html + + +def test_created_modal_renders(auth_client, db): + fid = _make_filament(db) + a, b = _make_spool(db, fid), _make_spool(db, fid) + html = auth_client.get(f"/spools?created={a},{b}").get_data(as_text=True) + assert "createdQueueModal" in html + assert html.count('name="spool_ids"') >= 2 diff --git a/translations.py b/translations.py index 826cae4..6f08e80 100644 --- a/translations.py +++ b/translations.py @@ -149,7 +149,16 @@ "Novo Spool": "New Spool", "Editar Spool": "Edit Spool", "Finalizar Spool": "Finish Spool", + "Finalizar": "Finish", + "Excluir": "Delete", "Marcar spool como finalizado?": "Mark spool as finished?", + "Excluir este rolo permanentemente? Esta ação não pode ser desfeita.": + "Delete this spool permanently? This action cannot be undone.", + "Quantidade": "Quantity", + "Crie vários rolos idênticos de uma vez (IDs distintos).": + "Create several identical spools at once (distinct IDs).", + "Deseja adicionar os {n} rolos recém-criados à fila de impressão de etiquetas?": + "Add the {n} newly created spools to the label print queue?", "Nenhum spool encontrado": "No spools found", "Nenhum spool cadastrado": "No spools registered", "Filtrar por Spool": "Filter by Spool", @@ -360,6 +369,8 @@ "Modelo removido": "Model removed", "Modelo em uso por spools existentes": "Model in use by existing spools", "Rolo cadastrado com sucesso": "Spool registered successfully", + "{n} rolos cadastrados (SP-{first} a SP-{last})": "{n} spools registered (SP-{first} to SP-{last})", + "Rolo SP-{code} excluído": "Spool SP-{code} deleted", "Rolo atualizado": "Spool updated", "Rolo marcado como finalizado": "Spool marked as finished", "Peso bruto ({g:.0f}g) menor que tara ({t:.0f}g). Verifique.": "Gross weight ({g:.0f}g) lower than tare ({t:.0f}g). Please check.", @@ -601,7 +612,16 @@ "Novo Spool": "Nueva Bobina", "Editar Spool": "Editar Bobina", "Finalizar Spool": "Finalizar Bobina", + "Finalizar": "Finalizar", + "Excluir": "Eliminar", "Marcar spool como finalizado?": "¿Marcar la bobina como finalizada?", + "Excluir este rolo permanentemente? Esta ação não pode ser desfeita.": + "¿Eliminar esta bobina permanentemente? Esta acción no se puede deshacer.", + "Quantidade": "Cantidad", + "Crie vários rolos idênticos de uma vez (IDs distintos).": + "Crea varias bobinas idénticas a la vez (IDs distintos).", + "Deseja adicionar os {n} rolos recém-criados à fila de impressão de etiquetas?": + "¿Agregar las {n} bobinas recién creadas a la cola de impresión de etiquetas?", "Nenhum spool encontrado": "Ninguna bobina encontrada", "Nenhum spool cadastrado": "Ninguna bobina registrada", "Filtrar por Spool": "Filtrar por Bobina", @@ -812,6 +832,8 @@ "Modelo removido": "Modelo eliminado", "Modelo em uso por spools existentes": "Modelo en uso por bobinas existentes", "Rolo cadastrado com sucesso": "Bobina registrada con éxito", + "{n} rolos cadastrados (SP-{first} a SP-{last})": "{n} bobinas registradas (SP-{first} a SP-{last})", + "Rolo SP-{code} excluído": "Bobina SP-{code} eliminada", "Rolo atualizado": "Bobina actualizada", "Rolo marcado como finalizado": "Bobina marcada como finalizada", "Peso bruto ({g:.0f}g) menor que tara ({t:.0f}g). Verifique.": "Peso bruto ({g:.0f}g) menor que la tara ({t:.0f}g). Verifica.",