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
17 changes: 16 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>/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
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.36.2
1.37.0
1 change: 1 addition & 0 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
13 changes: 13 additions & 0 deletions database.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=""):
Expand Down
38 changes: 33 additions & 5 deletions routes/spools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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"])
Expand All @@ -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,
Expand All @@ -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")
Expand Down Expand Up @@ -170,6 +185,19 @@ def spools_deactivate(spool_id):
return redirect(url_for("spools_list"))


@app.route("/spools/<int:spool_id>/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/<int:spool_id>/label.pdf")
@login_required
def spool_label_pdf(spool_id):
Expand Down
7 changes: 7 additions & 0 deletions templates/spools/form.html
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,13 @@ <h4 class="mb-0 fw-bold">{% if spool %}{{ _('Editar') }} SP-{{ '%04d'|format(spo
<label class="form-label fw-semibold">{{ _('Notas') }}</label>
<textarea name="notes" class="form-control" rows="2">{{ spool.notes if spool else '' }}</textarea>
</div>
{% if not spool %}
<div class="col-md-6">
<label class="form-label fw-semibold">{{ _('Quantidade') }}</label>
<input type="number" name="quantity" class="form-control" step="1" min="1" max="50" value="1">
<div class="form-text">{{ _('Crie vários rolos idênticos de uma vez (IDs distintos).') }}</div>
</div>
{% endif %}
</div>

<div class="mt-4 d-flex gap-2 align-items-center">
Expand Down
40 changes: 40 additions & 0 deletions templates/spools/list.html
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,17 @@ <h4 class="mb-0 fw-bold">
{% endif %}
{% if can_write %}
<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>
<form method="post" action="{{ url_for('spools_deactivate', spool_id=s.id) }}" class="d-inline"
data-sc-confirm="{{ _('Marcar spool como finalizado?') }}">
<button class="btn btn-sm btn-outline-secondary" title="{{ _('Finalizar') }}"><i class="bi bi-check2-circle"></i></button>
</form>
{% endif %}
{% endif %}
{% if is_admin %}
<form method="post" action="{{ url_for('spools_delete', spool_id=s.id) }}" class="d-inline"
data-sc-confirm="{{ _('Excluir este rolo permanentemente? Esta ação não pode ser desfeita.') }}">
<button class="btn btn-sm btn-outline-danger" title="{{ _('Excluir') }}"><i class="bi bi-trash"></i></button>
</form>
{% endif %}
</td>
</tr>
Expand Down Expand Up @@ -135,6 +145,31 @@ <h4 class="mb-0 fw-bold">
</div>
</div>
</div>

{% if created_ids %}
{# Após cadastrar N rolos: oferece adicionar todos à fila de impressão (add-all). #}
<div class="modal fade" id="createdQueueModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-printer me-2"></i>{{ _('Fila de Impressão') }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
{{ _('Deseja adicionar os {n} rolos recém-criados à fila de impressão de etiquetas?').replace('{n}', created_ids|length|string) }}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{{ _('Não') }}</button>
<form method="post" action="{{ url_for('label_queue_add_all') }}">
<input type="hidden" name="next" value="{{ url_for('spools_list') }}">
{% for cid in created_ids %}<input type="hidden" name="spool_ids" value="{{ cid }}">{% endfor %}
<button type="submit" class="btn btn-dark">{{ _('Sim, adicionar') }}</button>
</form>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}

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

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 %}
</script>
{% endblock %}
117 changes: 117 additions & 0 deletions tests/test_spool_delete_bulk.py
Original file line number Diff line number Diff line change
@@ -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/<id>/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
22 changes: 22 additions & 0 deletions translations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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.",
Expand Down