Просмотр исходного кода

feat(print-log): per-row delete on Print Log page (#1687 part 1)

  Reporter noted the existing "Also remove this print from Quick Stats"
  toggle at archive delete is one-shot: if you kept stats then, there was
  no later way to drop the row; and rows without a backing archive
  (errors, aborts, manual entries) had no delete affordance at all.

  Backend: DELETE /print-log/{entry_id} mirrors delete_archive's
  ownership flow via require_ownership_permission(ARCHIVES_DELETE_ALL,
  ARCHIVES_DELETE_OWN). Owners drop their own rows; admins drop any row;
  missing IDs return 404 rather than 200-silently. /archives/stats
  aggregates over PrintLogEntry, so the filament / time / cost / count
  contribution drops out of Quick Stats in the same response cycle. The
  linked archive (if any) is untouched - the log row is a sibling, not a
  child.

  Frontend: trash icon next to the filament cell on every row, gated on
  the same permission shape as the archive trash. Confirm modal -> row
  gone. Mutation invalidates both print-log and archives-stats query
  keys so the totals re-render without a manual refresh.

  #1687 also asks for per-row tagging (already covered by
  EditArchiveModal's tags field) and per-row filament-usage-history
  edits (deferred - "restore deducted grams" is only consistent for the
  most recent usage row per spool; needs a separate design call).
maziggy 2 месяцев назад
Родитель
Сommit
40729da013

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
CHANGELOG.md


+ 41 - 1
backend/app/api/routes/print_log.py

@@ -6,7 +6,11 @@ from fastapi.responses import FileResponse
 from sqlalchemy import delete, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.core.auth import RequireCameraStreamTokenIfAuthEnabled, RequirePermissionIfAuthEnabled
+from backend.app.core.auth import (
+    RequireCameraStreamTokenIfAuthEnabled,
+    RequirePermissionIfAuthEnabled,
+    require_ownership_permission,
+)
 from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
@@ -137,3 +141,39 @@ async def clear_print_log(
 
     logger.info("Print log cleared: %d entries deleted", deleted)
     return {"deleted": deleted}
+
+
+@router.delete("/{entry_id}")
+async def delete_print_log_entry(
+    entry_id: int,
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_DELETE_ALL,
+            Permission.ARCHIVES_DELETE_OWN,
+        )
+    ),
+):
+    """Delete a single print-log entry (#1687).
+
+    Removes the row entirely. Because /archives/stats aggregates over
+    PrintLogEntry, the deleted row's filament / cost / duration / count
+    contributions drop out of the totals in the same response cycle.
+    The linked archive (if any) is untouched — the FK on the archive row
+    is from PrintLogEntry, not the other way around.
+    """
+    user, can_modify_all = auth_result
+
+    entry = await db.get(PrintLogEntry, entry_id)
+    if not entry:
+        raise HTTPException(404, "Print log entry not found")
+
+    if not can_modify_all:
+        if entry.created_by_id is None or (user is not None and entry.created_by_id != user.id):
+            raise HTTPException(403, "You can only delete your own print log entries")
+
+    await db.delete(entry)
+    await db.commit()
+
+    logger.info("Print log entry %d deleted", entry_id)
+    return {"status": "deleted", "id": entry_id}

+ 72 - 0
backend/tests/integration/test_archives_api.py

@@ -534,6 +534,78 @@ class TestArchivesAPI:
         assert "successful_prints" in result
 
 
+class TestPrintLogEntryDelete:
+    """#1687: per-row delete on the Print Log page.
+
+    Pin the route's three contracts: (1) deleting a row drops its filament
+    / cost / count contribution from /archives/stats in the same response
+    cycle; (2) the matching archive (if any) is untouched; (3) missing IDs
+    return 404 rather than 200-silently.
+    """
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_print_log_entry_drops_from_stats(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        from sqlalchemy import select
+
+        from backend.app.models.print_log import PrintLogEntry
+
+        printer = await printer_factory()
+        keep = await archive_factory(printer.id, status="completed", filament_used_grams=50.0)
+        drop = await archive_factory(printer.id, status="completed", filament_used_grams=125.0)
+
+        drop_run = (
+            await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == drop.id))
+        ).scalar_one()
+
+        resp = await async_client.delete(f"/api/v1/print-log/{drop_run.id}")
+        assert resp.status_code == 200
+        assert resp.json()["status"] == "deleted"
+        assert resp.json()["id"] == drop_run.id
+
+        # The linked archive survives — the row was a stats row, not the archive.
+        listing = (await async_client.get("/api/v1/archives/")).json()
+        assert {a["id"] for a in listing} == {keep.id, drop.id}
+
+        # /stats no longer counts the dropped run's filament contribution.
+        stats = (await async_client.get("/api/v1/archives/stats")).json()
+        assert stats["total_prints"] == 1
+        assert stats["total_filament_grams"] == 50.0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_print_log_entry_404_when_missing(self, async_client: AsyncClient):
+        resp = await async_client.delete("/api/v1/print-log/999999")
+        assert resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_print_log_entry_does_not_clear_others(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """Deleting one row must not touch siblings — guard against an accidental
+        ``delete(PrintLogEntry)`` without a ``where`` clause (cf. clear_print_log
+        which intentionally drops everything)."""
+        from sqlalchemy import select
+
+        from backend.app.models.print_log import PrintLogEntry
+
+        printer = await printer_factory()
+        a = await archive_factory(printer.id, status="completed", filament_used_grams=10.0)
+        b = await archive_factory(printer.id, status="completed", filament_used_grams=20.0)
+        c = await archive_factory(printer.id, status="completed", filament_used_grams=30.0)
+
+        runs = {r.archive_id: r for r in (await db_session.execute(select(PrintLogEntry))).scalars().all()}
+
+        resp = await async_client.delete(f"/api/v1/print-log/{runs[b.id].id}")
+        assert resp.status_code == 200
+
+        survivors = (await db_session.execute(select(PrintLogEntry.archive_id))).scalars().all()
+        assert set(survivors) == {a.id, c.id}
+
+
 class TestArchivesSlimAPI:
     """Integration tests for /api/v1/archives/slim endpoint."""
 

+ 2 - 0
frontend/src/api/client.ts

@@ -4364,6 +4364,8 @@ export const api = {
   getPrintLogThumbnail: (id: number) => withStreamToken(`${API_BASE}/print-log/${id}/thumbnail`),
   clearPrintLog: () =>
     request<{ deleted: number }>('/print-log/', { method: 'DELETE' }),
+  deletePrintLogEntry: (id: number) =>
+    request<{ status: string; id: number }>(`/print-log/${id}`, { method: 'DELETE' }),
 
   // Settings
   getSettings: () => request<AppSettings>('/settings/'),

+ 4 - 0
frontend/src/i18n/locales/de.ts

@@ -926,6 +926,10 @@ export default {
       clearLogButton: 'Alle löschen',
       cleared: '{{count}} Protokolleinträge gelöscht',
       clearFailed: 'Druckprotokoll konnte nicht gelöscht werden',
+      deleteEntryTitle: 'Druckprotokoll-Eintrag löschen',
+      deleteEntryConfirm: 'Dieser Eintrag wird aus dem Protokoll entfernt und sein Filament-, Zeit- und Kostenbeitrag verschwindet aus den Schnellstatistiken. Das zugehörige Archiv (falls vorhanden) ist nicht betroffen. Diese Aktion kann nicht rückgängig gemacht werden.',
+      entryDeleted: 'Druckprotokoll-Eintrag gelöscht',
+      entryDeleteFailed: 'Druckprotokoll-Eintrag konnte nicht gelöscht werden',
     },
   },
 

+ 4 - 0
frontend/src/i18n/locales/en.ts

@@ -926,6 +926,10 @@ export default {
       clearLogButton: 'Clear All',
       cleared: '{{count}} log entries cleared',
       clearFailed: 'Failed to clear print log',
+      deleteEntryTitle: 'Delete print log entry',
+      deleteEntryConfirm: 'This entry will be removed from the log and its filament, time, and cost contributions will drop out of Quick Stats. The matching archive (if any) is not affected. This action cannot be undone.',
+      entryDeleted: 'Print log entry deleted',
+      entryDeleteFailed: 'Failed to delete print log entry',
     },
   },
 

+ 4 - 0
frontend/src/i18n/locales/es.ts

@@ -926,6 +926,10 @@ export default {
       clearLogButton: 'Borrar todo',
       cleared: '{{count}} entradas del registro borradas',
       clearFailed: 'Error al borrar el registro de impresión',
+      deleteEntryTitle: 'Eliminar entrada del registro de impresión',
+      deleteEntryConfirm: 'Esta entrada se eliminará del registro y sus contribuciones de filamento, tiempo y costo desaparecerán de las estadísticas rápidas. El archivo correspondiente (si lo hay) no se ve afectado. Esta acción no se puede deshacer.',
+      entryDeleted: 'Entrada del registro de impresión eliminada',
+      entryDeleteFailed: 'Error al eliminar la entrada del registro de impresión',
     },
   },
 

+ 4 - 0
frontend/src/i18n/locales/fr.ts

@@ -926,6 +926,10 @@ export default {
       clearLogButton: 'Tout effacer',
       cleared: '{{count}} entrées de journal effacées',
       clearFailed: 'Échec de l\'effacement du journal d\'impression',
+      deleteEntryTitle: 'Supprimer l\'entrée du journal d\'impression',
+      deleteEntryConfirm: 'Cette entrée sera retirée du journal et ses contributions en filament, temps et coût disparaîtront des statistiques rapides. L\'archive correspondante (le cas échéant) n\'est pas affectée. Cette action est irréversible.',
+      entryDeleted: 'Entrée du journal d\'impression supprimée',
+      entryDeleteFailed: 'Échec de la suppression de l\'entrée du journal d\'impression',
     },
   },
 

+ 4 - 0
frontend/src/i18n/locales/it.ts

@@ -926,6 +926,10 @@ export default {
       clearLogButton: 'Cancella tutto',
       cleared: '{{count}} voci di registro cancellate',
       clearFailed: 'Impossibile cancellare il registro stampe',
+      deleteEntryTitle: 'Elimina voce del registro stampe',
+      deleteEntryConfirm: 'Questa voce verrà rimossa dal registro e i suoi contributi di filamento, tempo e costo verranno scartati dalle statistiche rapide. L\'archivio corrispondente (se presente) non è interessato. Questa azione non può essere annullata.',
+      entryDeleted: 'Voce del registro stampe eliminata',
+      entryDeleteFailed: 'Impossibile eliminare la voce del registro stampe',
     },
   },
 

+ 4 - 0
frontend/src/i18n/locales/ja.ts

@@ -925,6 +925,10 @@ export default {
       clearLogButton: 'すべてクリア',
       cleared: '{{count}}件のログエントリを削除しました',
       clearFailed: '印刷ログの削除に失敗しました',
+      deleteEntryTitle: '印刷ログのエントリを削除',
+      deleteEntryConfirm: 'このエントリはログから削除され、フィラメント、時間、コストの寄与はクイック統計から除外されます。対応するアーカイブ(ある場合)は影響を受けません。この操作は取り消せません。',
+      entryDeleted: '印刷ログのエントリを削除しました',
+      entryDeleteFailed: '印刷ログのエントリを削除できませんでした',
     },
   },
 

+ 5 - 1
frontend/src/i18n/locales/ko.ts

@@ -861,7 +861,11 @@ export default {
       clearLogConfirm: '모든 인쇄 로그 항목이 영구적으로 삭제됩니다. 아카이브와 대기열 항목은 영향받지 않습니다. 이 작업은 취소할 수 없습니다. 계속하시겠습니까?',
       clearLogButton: '모두 지우기',
       cleared: '{{count}}개 로그 항목이 지워졌습니다',
-      clearFailed: '인쇄 로그 지우기 실패'
+      clearFailed: '인쇄 로그 지우기 실패',
+      deleteEntryTitle: '인쇄 로그 항목 삭제',
+      deleteEntryConfirm: '이 항목이 로그에서 삭제되며 필라멘트, 시간 및 비용 기여도가 빠른 통계에서 제외됩니다. 해당 아카이브(있는 경우)는 영향을 받지 않습니다. 이 작업은 취소할 수 없습니다.',
+      entryDeleted: '인쇄 로그 항목이 삭제되었습니다',
+      entryDeleteFailed: '인쇄 로그 항목 삭제 실패'
     },
     runLog: {
       title: '인쇄 기록',

+ 4 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -926,6 +926,10 @@ export default {
       clearLogButton: 'Limpar Tudo',
       cleared: '{{count}} entradas do registro de impressão limpas',
       clearFailed: 'Falha ao limpar o registro de impressão',
+      deleteEntryTitle: 'Excluir entrada do registro de impressão',
+      deleteEntryConfirm: 'Esta entrada será removida do registro e suas contribuições de filamento, tempo e custo desaparecerão das estatísticas rápidas. O arquivo correspondente (se houver) não é afetado. Esta ação não pode ser desfeita.',
+      entryDeleted: 'Entrada do registro de impressão excluída',
+      entryDeleteFailed: 'Falha ao excluir entrada do registro de impressão',
     },
   },
 

+ 4 - 0
frontend/src/i18n/locales/tr.ts

@@ -926,6 +926,10 @@ export default {
       clearLogButton: 'Hepsini Temizle',
       cleared: '{{count}} günlük kaydı temizlendi',
       clearFailed: 'Baskı günlüğü temizlenemedi',
+      deleteEntryTitle: 'Baskı günlüğü girişini sil',
+      deleteEntryConfirm: 'Bu giriş günlükten kaldırılacak ve filament, süre ve maliyet katkıları Hızlı İstatistikler\'den çıkarılacak. İlgili arşiv (varsa) etkilenmez. Bu işlem geri alınamaz.',
+      entryDeleted: 'Baskı günlüğü girişi silindi',
+      entryDeleteFailed: 'Baskı günlüğü girişi silinemedi',
     },
   },
 

+ 4 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -926,6 +926,10 @@ export default {
       clearLogButton: '全部清除',
       cleared: '已清除 {{count}} 条日志',
       clearFailed: '清除打印日志失败',
+      deleteEntryTitle: '删除打印日志条目',
+      deleteEntryConfirm: '此条目将从日志中删除,其耗材、时间和成本贡献也将从快速统计中移除。对应的归档(如果有)不受影响。此操作无法撤销。',
+      entryDeleted: '已删除打印日志条目',
+      entryDeleteFailed: '删除打印日志条目失败',
     },
   },
 

+ 4 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -926,6 +926,10 @@ export default {
       clearLogButton: '全部清除',
       cleared: '已清除 {{count}} 條日誌',
       clearFailed: '清除列印日誌失敗',
+      deleteEntryTitle: '刪除列印日誌條目',
+      deleteEntryConfirm: '此條目將從日誌中刪除,其耗材、時間與成本貢獻也將從快速統計中移除。對應的歸檔(如有)不受影響。此操作無法復原。',
+      entryDeleted: '已刪除列印日誌條目',
+      entryDeleteFailed: '刪除列印日誌條目失敗',
     },
   },
 

+ 47 - 0
frontend/src/pages/ArchivesPage.tsx

@@ -2611,6 +2611,7 @@ export function ArchivesPage() {
     return saved ? Number(saved) : 0;
   });
   const [showClearLogConfirm, setShowClearLogConfirm] = useState(false);
+  const [pendingDeleteEntryId, setPendingDeleteEntryId] = useState<number | null>(null);
   const [logPageSize, setLogPageSize] = useState(() => {
     const saved = localStorage.getItem('logPageSize');
     return saved ? Number(saved) : 25;
@@ -2723,6 +2724,18 @@ export function ArchivesPage() {
     },
   });
 
+  const deleteLogEntryMutation = useMutation({
+    mutationFn: (id: number) => api.deletePrintLogEntry(id),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['print-log'] });
+      queryClient.invalidateQueries({ queryKey: ['archives-stats'] });
+      showToast(t('archives.log.entryDeleted'));
+    },
+    onError: () => {
+      showToast(t('archives.log.entryDeleteFailed'), 'error');
+    },
+  });
+
   // Persist all filters to localStorage
   useEffect(() => {
     if (filterPrinter !== null) {
@@ -3749,6 +3762,7 @@ export function ArchivesPage() {
                         <th className="px-4 py-3 font-medium">{t('archives.log.status')}</th>
                         <th className="px-4 py-3 font-medium">{t('archives.log.duration')}</th>
                         <th className="px-4 py-3 font-medium">{t('archives.log.filament')}</th>
+                        <th className="px-4 py-3 font-medium w-10" aria-label={t('common.actions')} />
                       </tr>
                     </thead>
                     <tbody className="divide-y divide-bambu-dark-tertiary">
@@ -3802,6 +3816,24 @@ export function ArchivesPage() {
                               </span>
                             </div>
                           </td>
+                          <td className="px-4 py-3 text-right">
+                            <button
+                              type="button"
+                              onClick={() => setPendingDeleteEntryId(entry.id)}
+                              disabled={
+                                deleteLogEntryMutation.isPending ||
+                                !(hasPermission('archives:delete_all') || hasPermission('archives:delete_own'))
+                              }
+                              title={
+                                hasPermission('archives:delete_all') || hasPermission('archives:delete_own')
+                                  ? t('archives.log.deleteEntryTitle')
+                                  : t('archives.permission.noDelete')
+                              }
+                              className="text-bambu-gray hover:text-red-400 disabled:opacity-40 disabled:hover:text-bambu-gray transition-colors"
+                            >
+                              <Trash2 className="w-4 h-4" />
+                            </button>
+                          </td>
                         </tr>
                       ))}
                     </tbody>
@@ -3925,6 +3957,21 @@ export function ArchivesPage() {
           onCancel={() => setShowClearLogConfirm(false)}
         />
       )}
+
+      {/* Per-row Print Log entry delete confirmation (#1687) */}
+      {pendingDeleteEntryId !== null && (
+        <ConfirmModal
+          title={t('archives.log.deleteEntryTitle')}
+          message={t('archives.log.deleteEntryConfirm')}
+          confirmText={t('common.delete')}
+          variant="danger"
+          onConfirm={() => {
+            deleteLogEntryMutation.mutate(pendingDeleteEntryId);
+            setPendingDeleteEntryId(null);
+          }}
+          onCancel={() => setPendingDeleteEntryId(null)}
+        />
+      )}
     </div>
   );
 }

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-Ba6CVgCu.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-BvmIMSUd.css


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-fm48nbe7.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-B9tytutS.css">
+    <script type="module" crossorigin src="/assets/index-Ba6CVgCu.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-BvmIMSUd.css">
   </head>
   <body>
     <div id="root"></div>

Некоторые файлы не были показаны из-за большого количества измененных файлов