Skip to content

Commit 404d7e2

Browse files
iscarelliclaude
andcommitted
feat(spools): excluir rolo na lista + cadastro em lote (v1.37.0)
- Botão "Excluir" (permanente, só admin) em cada linha da lista de rolos: apaga o rolo + histórico de pesagens + entrada na fila, com confirmação. Distinto de "Finalizar" (soft, active=0), que agora também está na lista. - Campo "Quantidade" (1–50) na tela de Novo Spool: cria N rolos idênticos com IDs distintos. 1 rolo mantém o fluxo antigo (detalhe + prompt da fila); N rolos voltam à lista e oferecem adicionar os N à fila (label_queue_add_all). - i18n (EN/ES), testes (cascade, RBAC admin-only, lote, render da UI). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 354286a commit 404d7e2

9 files changed

Lines changed: 250 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,22 @@ Versioning follows [SemVer](https://semver.org/): **MAJOR.MINOR.PATCH**
1010

1111
---
1212

13-
## [1.36.2] — 2026-06-11
13+
## [1.37.0] — 2026-07-10
14+
15+
### Added
16+
- **Excluir rolo (permanente) na lista.** Cada linha da lista de rolos ganhou um
17+
botão de exclusão definitiva (lixeira, vermelho). Diferente de "Finalizar" (que
18+
só marca `active=0` e mantém o histórico), a exclusão apaga o rolo e todo o
19+
histórico de pesagens do banco — irreversível, com confirmação. É **restrita a
20+
admin** (`/spools/<id>/delete`, `admin_required`); o botão só aparece para admins.
21+
- **Finalizar direto da lista.** O botão "Finalizar" (que só existia na tela de
22+
edição) agora também está em cada linha da lista, para quem tem permissão de
23+
escrita.
24+
- **Cadastrar vários rolos iguais de uma vez.** A tela de "Novo Spool" ganhou um
25+
campo **Quantidade** (1–50): cria N rolos idênticos com IDs distintos numa só
26+
submissão. Ao cadastrar 1, o fluxo é o de sempre (vai ao detalhe e oferece a
27+
fila). Ao cadastrar vários, volta à lista e oferece adicionar **os N** à fila de
28+
impressão de uma vez (uma etiqueta/QR por rolo), reusando o `label_queue_add_all`.
1429

1530
### Fixed
1631
- **Card "Ver novidades" some quando a API do GitHub falha (fica só o link).** A

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.36.2
1+
1.37.0

app.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -629,6 +629,7 @@ def inject_globals():
629629
"_": i18n.get_translator(lang),
630630
"update_available": is_update_available() if is_admin else False,
631631
"can_write": can_write,
632+
"is_admin": is_admin,
632633
"backup_alert": backup_alert,
633634
"nonce": getattr(g, "_nonce", ""),
634635
"demo_mode": DEMO_MODE,

database.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -821,6 +821,19 @@ def deactivate_spool(spool_id):
821821
db.commit()
822822

823823

824+
def delete_spool(spool_id):
825+
"""Apaga o rolo DE VEZ, junto com o histórico de pesagens e a entrada na fila.
826+
827+
As FKs (weight_readings/label_queue → spools) não têm ON DELETE CASCADE e o
828+
PRAGMA foreign_keys pode estar desligado, então removemos os filhos
829+
explicitamente — tudo numa transação para não deixar órfãos."""
830+
with closing(get_db()) as db:
831+
db.execute("DELETE FROM label_queue WHERE spool_id=?", (spool_id,))
832+
db.execute("DELETE FROM weight_readings WHERE spool_id=?", (spool_id,))
833+
db.execute("DELETE FROM spools WHERE id=?", (spool_id,))
834+
db.commit()
835+
836+
824837
# ── Weight Readings ────────────────────────────────────────────────────────
825838

826839
def add_weight_reading(spool_id, gross_weight_g, tare_weight_g, recorded_by="", notes=""):

routes/spools.py

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
import labels as lbl
1010
import niimbot_registry as reg
1111
import logger as log_cfg
12-
from app import app, login_required, write_required, t, _parse_price, public_base_url, _label_spool, _currency_meta
12+
from app import (app, login_required, write_required, admin_required, t, _parse_price,
13+
public_base_url, _label_spool, _currency_meta)
1314

1415
log = log_cfg.get_logger()
1516

@@ -40,8 +41,10 @@ def spools_list():
4041
active_only = True
4142
else:
4243
spools = db.list_spools(active_only=active_only)
44+
# IDs recém-cadastrados em lote (?created=1,2,3) — a lista oferece a fila p/ todos.
45+
created_ids = [int(x) for x in request.args.get("created", "").split(",") if x.isdigit()]
4346
return render_template("spools/list.html", spools=spools, active_only=active_only,
44-
q=q, color=color, queue_ids=db.queue_ids())
47+
q=q, color=color, queue_ids=db.queue_ids(), created_ids=created_ids)
4548

4649

4750
@app.route("/spools/new", methods=["GET", "POST"])
@@ -50,7 +53,13 @@ def spools_new():
5053
filament_id = request.args.get("filament_id", type=int)
5154
if request.method == "POST":
5255
try:
53-
spool_id = db.create_spool(
56+
# Quantidade: cria N rolos idênticos de uma vez (clamp 1..50).
57+
try:
58+
quantity = int(request.form.get("quantity") or 1)
59+
except ValueError:
60+
quantity = 1
61+
quantity = max(1, min(quantity, 50))
62+
common = dict(
5463
filament_id=int(request.form["filament_id"]),
5564
spool_model_id=request.form.get("spool_model_id") or None,
5665
custom_tare_g=request.form.get("custom_tare_g") or None,
@@ -60,8 +69,14 @@ def spools_new():
6069
purchase_price=_parse_price(request.form.get("purchase_price", "")),
6170
notes=request.form.get("notes", "").strip(),
6271
)
63-
flash(t("Rolo cadastrado com sucesso"), "success")
64-
return redirect(url_for("spools_detail", spool_id=spool_id, queue_prompt="1"))
72+
ids = [db.create_spool(**common) for _ in range(quantity)]
73+
if quantity == 1:
74+
flash(t("Rolo cadastrado com sucesso"), "success")
75+
return redirect(url_for("spools_detail", spool_id=ids[0], queue_prompt="1"))
76+
# Vários: volta à lista, que oferece a fila de impressão p/ os N (add-all).
77+
flash(t("{n} rolos cadastrados (SP-{first} a SP-{last})").format(
78+
n=quantity, first=f"{ids[0]:04d}", last=f"{ids[-1]:04d}"), "success")
79+
return redirect(url_for("spools_list", created=",".join(str(i) for i in ids)))
6580
except Exception:
6681
log.error("spool.create_failed", exc_info=True)
6782
flash(t("Erro ao processar. Tente novamente."), "danger")
@@ -170,6 +185,19 @@ def spools_deactivate(spool_id):
170185
return redirect(url_for("spools_list"))
171186

172187

188+
@app.route("/spools/<int:spool_id>/delete", methods=["POST"])
189+
@admin_required
190+
def spools_delete(spool_id):
191+
"""Exclusão permanente (só admin) — apaga o rolo e todo o histórico."""
192+
spool = db.get_spool(spool_id)
193+
if not spool:
194+
abort(404)
195+
db.delete_spool(spool_id)
196+
log.info("spool.deleted", spool_id=spool_id, by=session.get("username", ""))
197+
flash(t("Rolo SP-{code} excluído").format(code=f"{spool_id:04d}"), "success")
198+
return redirect(url_for("spools_list"))
199+
200+
173201
@app.route("/spools/<int:spool_id>/label.pdf")
174202
@login_required
175203
def spool_label_pdf(spool_id):

templates/spools/form.html

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,13 @@ <h4 class="mb-0 fw-bold">{% if spool %}{{ _('Editar') }} SP-{{ '%04d'|format(spo
8989
<label class="form-label fw-semibold">{{ _('Notas') }}</label>
9090
<textarea name="notes" class="form-control" rows="2">{{ spool.notes if spool else '' }}</textarea>
9191
</div>
92+
{% if not spool %}
93+
<div class="col-md-6">
94+
<label class="form-label fw-semibold">{{ _('Quantidade') }}</label>
95+
<input type="number" name="quantity" class="form-control" step="1" min="1" max="50" value="1">
96+
<div class="form-text">{{ _('Crie vários rolos idênticos de uma vez (IDs distintos).') }}</div>
97+
</div>
98+
{% endif %}
9299
</div>
93100

94101
<div class="mt-4 d-flex gap-2 align-items-center">

templates/spools/list.html

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,17 @@ <h4 class="mb-0 fw-bold">
9999
{% endif %}
100100
{% if can_write %}
101101
<a href="{{ url_for('spools_edit', spool_id=s.id) }}" class="btn btn-sm btn-outline-secondary" title="{{ _('Editar') }}"><i class="bi bi-pencil"></i></a>
102+
<form method="post" action="{{ url_for('spools_deactivate', spool_id=s.id) }}" class="d-inline"
103+
data-sc-confirm="{{ _('Marcar spool como finalizado?') }}">
104+
<button class="btn btn-sm btn-outline-secondary" title="{{ _('Finalizar') }}"><i class="bi bi-check2-circle"></i></button>
105+
</form>
106+
{% endif %}
102107
{% endif %}
108+
{% if is_admin %}
109+
<form method="post" action="{{ url_for('spools_delete', spool_id=s.id) }}" class="d-inline"
110+
data-sc-confirm="{{ _('Excluir este rolo permanentemente? Esta ação não pode ser desfeita.') }}">
111+
<button class="btn btn-sm btn-outline-danger" title="{{ _('Excluir') }}"><i class="bi bi-trash"></i></button>
112+
</form>
103113
{% endif %}
104114
</td>
105115
</tr>
@@ -135,6 +145,31 @@ <h4 class="mb-0 fw-bold">
135145
</div>
136146
</div>
137147
</div>
148+
149+
{% if created_ids %}
150+
{# Após cadastrar N rolos: oferece adicionar todos à fila de impressão (add-all). #}
151+
<div class="modal fade" id="createdQueueModal" tabindex="-1">
152+
<div class="modal-dialog modal-dialog-centered">
153+
<div class="modal-content">
154+
<div class="modal-header">
155+
<h5 class="modal-title"><i class="bi bi-printer me-2"></i>{{ _('Fila de Impressão') }}</h5>
156+
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
157+
</div>
158+
<div class="modal-body">
159+
{{ _('Deseja adicionar os {n} rolos recém-criados à fila de impressão de etiquetas?').replace('{n}', created_ids|length|string) }}
160+
</div>
161+
<div class="modal-footer">
162+
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{{ _('Não') }}</button>
163+
<form method="post" action="{{ url_for('label_queue_add_all') }}">
164+
<input type="hidden" name="next" value="{{ url_for('spools_list') }}">
165+
{% for cid in created_ids %}<input type="hidden" name="spool_ids" value="{{ cid }}">{% endfor %}
166+
<button type="submit" class="btn btn-dark">{{ _('Sim, adicionar') }}</button>
167+
</form>
168+
</div>
169+
</div>
170+
</div>
171+
</div>
172+
{% endif %}
138173
{% endblock %}
139174

140175
{% block scripts %}
@@ -256,5 +291,10 @@ <h4 class="mb-0 fw-bold">
256291

257292
btn.disabled = false; btn.textContent = '{{ _('Registrar') }}';
258293
});
294+
295+
{% if created_ids %}
296+
/* ── Cadastro em lote: abre o modal da fila de impressão ─────────────────── */
297+
new bootstrap.Modal(document.getElementById('createdQueueModal')).show();
298+
{% endif %}
259299
</script>
260300
{% endblock %}

tests/test_spool_delete_bulk.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
"""Testes de v1.37.0:
2+
3+
- Exclusão permanente de rolo (db.delete_spool + rota admin-only).
4+
- Cadastro em lote (campo Quantidade cria N rolos idênticos).
5+
"""
6+
from conftest import ADMIN_USER, ADMIN_PASS
7+
8+
9+
def _make_filament(db):
10+
return db.create_filament(brand="Acme", material="PLA", family="PLA",
11+
color_hex="#ffffff")
12+
13+
14+
def _make_spool(db, fid, nominal=1000.0):
15+
return db.create_spool(filament_id=fid, spool_model_id=None, custom_tare_g=200.0,
16+
nominal_weight_g=nominal, location="", purchase_date="",
17+
purchase_price=None, notes="")
18+
19+
20+
# ── Exclusão permanente (cascade) ────────────────────────────────────────────
21+
22+
def test_delete_spool_cascades(db):
23+
"""delete_spool apaga o rolo, o histórico de pesagens e a entrada na fila."""
24+
fid = _make_filament(db)
25+
sid = _make_spool(db, fid)
26+
db.add_weight_reading(sid, gross_weight_g=800.0, tare_weight_g=200.0)
27+
db.queue_add(sid)
28+
assert db.get_spool(sid) is not None
29+
assert db.list_weight_readings(spool_id=sid)
30+
assert db.is_in_queue(sid)
31+
32+
db.delete_spool(sid)
33+
34+
assert db.get_spool(sid) is None
35+
assert db.list_weight_readings(spool_id=sid) == []
36+
assert not db.is_in_queue(sid)
37+
38+
39+
def test_delete_route_admin_only(viewer_client, auth_client, db):
40+
"""POST /spools/<id>/delete é 403 p/ viewer e remove o rolo p/ admin."""
41+
fid = _make_filament(db)
42+
sid = _make_spool(db, fid)
43+
44+
assert viewer_client.post(f"/spools/{sid}/delete", data={}).status_code == 403
45+
assert db.get_spool(sid) is not None # viewer não conseguiu apagar
46+
47+
resp = auth_client.post(f"/spools/{sid}/delete", data={})
48+
assert resp.status_code == 302
49+
assert db.get_spool(sid) is None
50+
51+
52+
def test_delete_unknown_spool_404(auth_client):
53+
assert auth_client.post("/spools/999999/delete", data={}).status_code == 404
54+
55+
56+
# ── Cadastro em lote (Quantidade) ────────────────────────────────────────────
57+
58+
def test_bulk_create_makes_n_spools(auth_client, db):
59+
fid = _make_filament(db)
60+
resp = auth_client.post("/spools/new", data={
61+
"filament_id": str(fid), "nominal_weight_g": "1000", "quantity": "3",
62+
})
63+
assert resp.status_code == 302
64+
assert "/spools?created=" in resp.headers["Location"]
65+
assert len(db.list_spools()) == 3
66+
67+
68+
def test_single_create_keeps_detail_flow(auth_client, db):
69+
"""Quantidade 1 (default) mantém o fluxo antigo: vai ao detalhe c/ queue_prompt."""
70+
fid = _make_filament(db)
71+
resp = auth_client.post("/spools/new", data={
72+
"filament_id": str(fid), "nominal_weight_g": "1000", "quantity": "1",
73+
})
74+
assert resp.status_code == 302
75+
loc = resp.headers["Location"]
76+
sid = db.list_spools()[0]["id"]
77+
assert f"/spools/{sid}" in loc and "queue_prompt=1" in loc
78+
79+
80+
def test_bulk_create_clamps_quantity(auth_client, db):
81+
"""Quantidade acima do teto (50) é limitada; entrada inválida vira 1."""
82+
fid = _make_filament(db)
83+
auth_client.post("/spools/new", data={
84+
"filament_id": str(fid), "nominal_weight_g": "1000", "quantity": "999",
85+
})
86+
assert len(db.list_spools()) == 50
87+
88+
89+
# ── Renderização da UI ───────────────────────────────────────────────────────
90+
91+
def test_list_shows_delete_and_finish_for_admin(auth_client, db):
92+
fid = _make_filament(db)
93+
sid = _make_spool(db, fid)
94+
html = auth_client.get("/spools").get_data(as_text=True)
95+
assert f"/spools/{sid}/delete" in html # botão excluir (admin)
96+
assert f"/spools/{sid}/deactivate" in html # botão finalizar (escrita)
97+
98+
99+
def test_list_hides_delete_for_viewer(viewer_client, auth_client, db):
100+
fid = _make_filament(db)
101+
sid = _make_spool(db, fid)
102+
html = viewer_client.get("/spools").get_data(as_text=True)
103+
assert f"/spools/{sid}/delete" not in html # excluir é só admin
104+
assert f"/spools/{sid}/deactivate" not in html # finalizar exige escrita
105+
106+
107+
def test_new_form_has_quantity_field(auth_client):
108+
html = auth_client.get("/spools/new").get_data(as_text=True)
109+
assert 'name="quantity"' in html
110+
111+
112+
def test_created_modal_renders(auth_client, db):
113+
fid = _make_filament(db)
114+
a, b = _make_spool(db, fid), _make_spool(db, fid)
115+
html = auth_client.get(f"/spools?created={a},{b}").get_data(as_text=True)
116+
assert "createdQueueModal" in html
117+
assert html.count('name="spool_ids"') >= 2

translations.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,16 @@
149149
"Novo Spool": "New Spool",
150150
"Editar Spool": "Edit Spool",
151151
"Finalizar Spool": "Finish Spool",
152+
"Finalizar": "Finish",
153+
"Excluir": "Delete",
152154
"Marcar spool como finalizado?": "Mark spool as finished?",
155+
"Excluir este rolo permanentemente? Esta ação não pode ser desfeita.":
156+
"Delete this spool permanently? This action cannot be undone.",
157+
"Quantidade": "Quantity",
158+
"Crie vários rolos idênticos de uma vez (IDs distintos).":
159+
"Create several identical spools at once (distinct IDs).",
160+
"Deseja adicionar os {n} rolos recém-criados à fila de impressão de etiquetas?":
161+
"Add the {n} newly created spools to the label print queue?",
153162
"Nenhum spool encontrado": "No spools found",
154163
"Nenhum spool cadastrado": "No spools registered",
155164
"Filtrar por Spool": "Filter by Spool",
@@ -360,6 +369,8 @@
360369
"Modelo removido": "Model removed",
361370
"Modelo em uso por spools existentes": "Model in use by existing spools",
362371
"Rolo cadastrado com sucesso": "Spool registered successfully",
372+
"{n} rolos cadastrados (SP-{first} a SP-{last})": "{n} spools registered (SP-{first} to SP-{last})",
373+
"Rolo SP-{code} excluído": "Spool SP-{code} deleted",
363374
"Rolo atualizado": "Spool updated",
364375
"Rolo marcado como finalizado": "Spool marked as finished",
365376
"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 @@
601612
"Novo Spool": "Nueva Bobina",
602613
"Editar Spool": "Editar Bobina",
603614
"Finalizar Spool": "Finalizar Bobina",
615+
"Finalizar": "Finalizar",
616+
"Excluir": "Eliminar",
604617
"Marcar spool como finalizado?": "¿Marcar la bobina como finalizada?",
618+
"Excluir este rolo permanentemente? Esta ação não pode ser desfeita.":
619+
"¿Eliminar esta bobina permanentemente? Esta acción no se puede deshacer.",
620+
"Quantidade": "Cantidad",
621+
"Crie vários rolos idênticos de uma vez (IDs distintos).":
622+
"Crea varias bobinas idénticas a la vez (IDs distintos).",
623+
"Deseja adicionar os {n} rolos recém-criados à fila de impressão de etiquetas?":
624+
"¿Agregar las {n} bobinas recién creadas a la cola de impresión de etiquetas?",
605625
"Nenhum spool encontrado": "Ninguna bobina encontrada",
606626
"Nenhum spool cadastrado": "Ninguna bobina registrada",
607627
"Filtrar por Spool": "Filtrar por Bobina",
@@ -812,6 +832,8 @@
812832
"Modelo removido": "Modelo eliminado",
813833
"Modelo em uso por spools existentes": "Modelo en uso por bobinas existentes",
814834
"Rolo cadastrado com sucesso": "Bobina registrada con éxito",
835+
"{n} rolos cadastrados (SP-{first} a SP-{last})": "{n} bobinas registradas (SP-{first} a SP-{last})",
836+
"Rolo SP-{code} excluído": "Bobina SP-{code} eliminada",
815837
"Rolo atualizado": "Bobina actualizada",
816838
"Rolo marcado como finalizado": "Bobina marcada como finalizada",
817839
"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.",

0 commit comments

Comments
 (0)