|
|
@@ -1,15 +1,16 @@
|
|
|
import io
|
|
|
import json
|
|
|
import logging
|
|
|
+import re as _re
|
|
|
import zipfile
|
|
|
from collections import defaultdict
|
|
|
-from datetime import date, datetime, time, timezone
|
|
|
+from datetime import date, datetime, time, timedelta, timezone
|
|
|
from decimal import ROUND_HALF_UP, Decimal
|
|
|
from pathlib import Path
|
|
|
|
|
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile
|
|
|
from fastapi.responses import FileResponse, Response
|
|
|
-from sqlalchemy import and_, func, or_, select
|
|
|
+from sqlalchemy import and_, case, func, or_, select
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
from backend.app.core.auth import (
|
|
|
@@ -25,6 +26,7 @@ from backend.app.models.filament import Filament
|
|
|
from backend.app.models.spool_usage_history import SpoolUsageHistory
|
|
|
from backend.app.models.user import User
|
|
|
from backend.app.schemas.archive import ArchiveResponse, ArchiveSlim, ArchiveStats, ArchiveUpdate, ReprintRequest
|
|
|
+from backend.app.schemas.print_log import PrintLogResponse
|
|
|
from backend.app.schemas.slicer import SliceRequest
|
|
|
from backend.app.services.archive import ArchiveService
|
|
|
from backend.app.utils.http import build_content_disposition
|
|
|
@@ -48,6 +50,74 @@ def _safe_filename(filename: str) -> str:
|
|
|
return Path(filename.replace("\\", "/")).name
|
|
|
|
|
|
|
|
|
+_TIMELAPSE_FILENAME_TS_RE = _re.compile(r"(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})")
|
|
|
+_DEFAULT_TIMELAPSE_OFFSETS_HOURS: tuple[int, ...] = (0, 8, -8, 7, -7, 1, -1)
|
|
|
+_DEFAULT_TIMELAPSE_TOLERANCE = timedelta(hours=4)
|
|
|
+_DEFAULT_TIMELAPSE_AMBIGUITY_MARGIN = timedelta(minutes=15)
|
|
|
+
|
|
|
+
|
|
|
+def _match_timelapse_by_timestamp(
|
|
|
+ video_files: list[dict],
|
|
|
+ archive_start: datetime | None,
|
|
|
+ *,
|
|
|
+ tolerance: timedelta = _DEFAULT_TIMELAPSE_TOLERANCE,
|
|
|
+ ambiguity_margin: timedelta = _DEFAULT_TIMELAPSE_AMBIGUITY_MARGIN,
|
|
|
+ offsets_hours: tuple[int, ...] = _DEFAULT_TIMELAPSE_OFFSETS_HOURS,
|
|
|
+) -> tuple[dict | None, timedelta | None]:
|
|
|
+ """Pick the timelapse whose filename timestamp best matches the print start time.
|
|
|
+
|
|
|
+ Bambu timelapse filenames embed the printer-local START time (e.g.
|
|
|
+ "video_2026-05-08_09-41-29.mp4"). The printer's clock may be offset from the
|
|
|
+ server's — especially in LAN-Only mode where NTP is unreachable — so we try a
|
|
|
+ small set of common UTC offsets and keep the (video, offset) pair with the
|
|
|
+ smallest absolute distance from archive_start. We deliberately do NOT consider
|
|
|
+ archive_end here: the filename is start time, not end time, so comparing it to
|
|
|
+ completion is not a real signal (Strategy 3 handles end via file mtime).
|
|
|
+
|
|
|
+ Because the offset list densely covers a wide span, an unrelated video's
|
|
|
+ filename can coincidentally land near a later print's start at some offset.
|
|
|
+ To avoid that false positive, we require the best (video, offset) pair to
|
|
|
+ beat the next-best pair *from a different video* by at least `ambiguity_margin`.
|
|
|
+ When the top two candidates from different videos are too close to call,
|
|
|
+ we return None and let the caller fall back to manual selection.
|
|
|
+ """
|
|
|
+ if archive_start is None:
|
|
|
+ return None, None
|
|
|
+
|
|
|
+ # (diff, video) for every (video, offset) pair within tolerance.
|
|
|
+ candidates: list[tuple[timedelta, dict]] = []
|
|
|
+
|
|
|
+ for f in video_files:
|
|
|
+ fname = f.get("name", "")
|
|
|
+ m = _TIMELAPSE_FILENAME_TS_RE.search(fname)
|
|
|
+ if not m:
|
|
|
+ continue
|
|
|
+ try:
|
|
|
+ file_time = datetime.strptime(m.group(1), "%Y-%m-%d_%H-%M-%S")
|
|
|
+ except ValueError:
|
|
|
+ continue
|
|
|
+
|
|
|
+ for hour_offset in offsets_hours:
|
|
|
+ adjusted = file_time - timedelta(hours=hour_offset)
|
|
|
+ diff = abs(adjusted - archive_start)
|
|
|
+ if diff <= tolerance:
|
|
|
+ candidates.append((diff, f))
|
|
|
+
|
|
|
+ if not candidates:
|
|
|
+ return None, None
|
|
|
+
|
|
|
+ candidates.sort(key=lambda c: c[0])
|
|
|
+ best_diff, best_video = candidates[0]
|
|
|
+ best_name = best_video.get("name")
|
|
|
+
|
|
|
+ for diff, video in candidates[1:]:
|
|
|
+ if video.get("name") != best_name and (diff - best_diff) < ambiguity_margin:
|
|
|
+ # Another video matches almost as well — refuse to auto-pick.
|
|
|
+ return None, None
|
|
|
+
|
|
|
+ return best_video, best_diff
|
|
|
+
|
|
|
+
|
|
|
def _validate_user_filter_permission(current_user: User | None, created_by_id: int | None):
|
|
|
"""Raise 403 if created_by_id filter is used without stats:filter_by_user permission."""
|
|
|
if created_by_id is None or current_user is None:
|
|
|
@@ -67,6 +137,17 @@ def _apply_user_filter(conditions: list, created_by_id: int | None):
|
|
|
conditions.append(PrintArchive.created_by_id == created_by_id)
|
|
|
|
|
|
|
|
|
+def _apply_run_user_filter(conditions: list, created_by_id: int | None):
|
|
|
+ """Append created_by_id filter scoped to PrintLogEntry rows."""
|
|
|
+ from backend.app.models.print_log import PrintLogEntry
|
|
|
+
|
|
|
+ if created_by_id is not None:
|
|
|
+ if created_by_id == -1:
|
|
|
+ conditions.append(PrintLogEntry.created_by_id.is_(None))
|
|
|
+ else:
|
|
|
+ conditions.append(PrintLogEntry.created_by_id == created_by_id)
|
|
|
+
|
|
|
+
|
|
|
def compute_time_accuracy(archive: PrintArchive) -> dict:
|
|
|
"""Compute actual print time and accuracy for an archive.
|
|
|
|
|
|
@@ -94,12 +175,48 @@ def compute_time_accuracy(archive: PrintArchive) -> dict:
|
|
|
return result
|
|
|
|
|
|
|
|
|
+async def _load_run_aggregates(db: AsyncSession, archive_ids: list[int]) -> dict[int, dict]:
|
|
|
+ """Batch-load per-archive run aggregates from PrintLogEntry.
|
|
|
+
|
|
|
+ Returns ``{archive_id: {run_count, last_run_at, total_filament_actual_grams,
|
|
|
+ successful_run_count, failed_run_count}}``. Archives with no logged runs are
|
|
|
+ absent from the map; callers should treat that as zero/none.
|
|
|
+ """
|
|
|
+ from backend.app.models.print_log import PrintLogEntry
|
|
|
+
|
|
|
+ if not archive_ids:
|
|
|
+ return {}
|
|
|
+ rows = await db.execute(
|
|
|
+ select(
|
|
|
+ PrintLogEntry.archive_id,
|
|
|
+ func.count(PrintLogEntry.id).label("run_count"),
|
|
|
+ func.max(PrintLogEntry.started_at).label("last_run_at"),
|
|
|
+ func.coalesce(func.sum(PrintLogEntry.filament_used_grams), 0).label("total_filament"),
|
|
|
+ func.sum(case((PrintLogEntry.status == "completed", 1), else_=0)).label("successful"),
|
|
|
+ func.sum(case((PrintLogEntry.status == "failed", 1), else_=0)).label("failed"),
|
|
|
+ )
|
|
|
+ .where(PrintLogEntry.archive_id.in_(archive_ids))
|
|
|
+ .group_by(PrintLogEntry.archive_id)
|
|
|
+ )
|
|
|
+ aggregates: dict[int, dict] = {}
|
|
|
+ for archive_id, run_count, last_run_at, total_filament, successful, failed in rows.all():
|
|
|
+ aggregates[archive_id] = {
|
|
|
+ "run_count": int(run_count or 0),
|
|
|
+ "last_run_at": last_run_at,
|
|
|
+ "total_filament_actual_grams": float(total_filament) if total_filament else None,
|
|
|
+ "successful_run_count": int(successful or 0),
|
|
|
+ "failed_run_count": int(failed or 0),
|
|
|
+ }
|
|
|
+ return aggregates
|
|
|
+
|
|
|
+
|
|
|
def archive_to_response(
|
|
|
archive: PrintArchive,
|
|
|
duplicates: list[dict] | None = None,
|
|
|
duplicate_count: int = 0,
|
|
|
duplicate_sequence: int = 0,
|
|
|
original_archive_id: int | None = None,
|
|
|
+ run_aggregate: dict | None = None,
|
|
|
) -> dict:
|
|
|
"""Convert archive model to response dict with computed fields."""
|
|
|
data = {
|
|
|
@@ -157,6 +274,13 @@ def archive_to_response(
|
|
|
accuracy_data = compute_time_accuracy(archive)
|
|
|
data.update(accuracy_data)
|
|
|
|
|
|
+ if run_aggregate:
|
|
|
+ data["run_count"] = run_aggregate.get("run_count", 0)
|
|
|
+ data["last_run_at"] = run_aggregate.get("last_run_at")
|
|
|
+ data["total_filament_actual_grams"] = run_aggregate.get("total_filament_actual_grams")
|
|
|
+ data["successful_run_count"] = run_aggregate.get("successful_run_count", 0)
|
|
|
+ data["failed_run_count"] = run_aggregate.get("failed_run_count", 0)
|
|
|
+
|
|
|
return data
|
|
|
|
|
|
|
|
|
@@ -214,7 +338,7 @@ async def list_archives(
|
|
|
PrintArchive.created_at,
|
|
|
PrintArchive.content_hash,
|
|
|
func.lower(PrintArchive.print_name).label("print_name_lower"),
|
|
|
- ).where(or_(*duplicate_group_conditions))
|
|
|
+ ).where(or_(*duplicate_group_conditions), PrintArchive.deleted_at.is_(None))
|
|
|
)
|
|
|
|
|
|
duplicate_groups_by_hash: dict[str, list[tuple[int, datetime]]] = defaultdict(list)
|
|
|
@@ -249,6 +373,8 @@ async def list_archives(
|
|
|
for sequence, (archive_id, _) in enumerate(group):
|
|
|
duplicate_meta_by_archive_id.setdefault(archive_id, (sequence, original_id, duplicate_count))
|
|
|
|
|
|
+ run_aggregates = await _load_run_aggregates(db, [a.id for a in archives])
|
|
|
+
|
|
|
# Build response with duplicate sequence and original archive ID pre-computed
|
|
|
result = []
|
|
|
for a in archives:
|
|
|
@@ -273,6 +399,7 @@ async def list_archives(
|
|
|
duplicate_count=duplicate_count,
|
|
|
duplicate_sequence=duplicate_sequence,
|
|
|
original_archive_id=original_archive_id,
|
|
|
+ run_aggregate=run_aggregates.get(a.id),
|
|
|
)
|
|
|
)
|
|
|
return result
|
|
|
@@ -415,12 +542,15 @@ async def search_archives(
|
|
|
select(PrintArchive)
|
|
|
.options(selectinload(PrintArchive.project))
|
|
|
.where(
|
|
|
- (PrintArchive.print_name.ilike(like_pattern))
|
|
|
- | (PrintArchive.filename.ilike(like_pattern))
|
|
|
- | (PrintArchive.tags.ilike(like_pattern))
|
|
|
- | (PrintArchive.notes.ilike(like_pattern))
|
|
|
- | (PrintArchive.designer.ilike(like_pattern))
|
|
|
- | (PrintArchive.filament_type.ilike(like_pattern))
|
|
|
+ (
|
|
|
+ (PrintArchive.print_name.ilike(like_pattern))
|
|
|
+ | (PrintArchive.filename.ilike(like_pattern))
|
|
|
+ | (PrintArchive.tags.ilike(like_pattern))
|
|
|
+ | (PrintArchive.notes.ilike(like_pattern))
|
|
|
+ | (PrintArchive.designer.ilike(like_pattern))
|
|
|
+ | (PrintArchive.filament_type.ilike(like_pattern))
|
|
|
+ ),
|
|
|
+ PrintArchive.deleted_at.is_(None),
|
|
|
)
|
|
|
.order_by(PrintArchive.created_at.desc())
|
|
|
)
|
|
|
@@ -440,8 +570,12 @@ async def search_archives(
|
|
|
if not matched_ids:
|
|
|
return []
|
|
|
|
|
|
- # Fetch full archive records for matched IDs
|
|
|
- query = select(PrintArchive).options(selectinload(PrintArchive.project)).where(PrintArchive.id.in_(matched_ids))
|
|
|
+ # Fetch full archive records for matched IDs (excluding soft-deleted, #1343)
|
|
|
+ query = (
|
|
|
+ select(PrintArchive)
|
|
|
+ .options(selectinload(PrintArchive.project))
|
|
|
+ .where(PrintArchive.id.in_(matched_ids), PrintArchive.deleted_at.is_(None))
|
|
|
+ )
|
|
|
|
|
|
# Apply additional filters
|
|
|
if printer_id:
|
|
|
@@ -686,69 +820,75 @@ async def get_archive_stats(
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.STATS_READ),
|
|
|
):
|
|
|
- """Get statistics across all archives."""
|
|
|
+ """Get statistics across all archives.
|
|
|
+
|
|
|
+ Stats aggregate over PrintLogEntry (one row per print event), not over
|
|
|
+ PrintArchive (one row per file). A reprint contributes a new PrintLogEntry
|
|
|
+ so its filament/cost/time/energy add to the totals instead of overwriting
|
|
|
+ the source archive's first-run values (#1378).
|
|
|
+ """
|
|
|
+ from backend.app.models.print_log import PrintLogEntry
|
|
|
+
|
|
|
_validate_user_filter_permission(current_user, created_by_id)
|
|
|
|
|
|
- # Build date filter conditions
|
|
|
+ # Build date filter conditions scoped to PrintLogEntry (event-time).
|
|
|
base_conditions = []
|
|
|
if date_from:
|
|
|
dt_from = datetime.combine(date_from, time.min, tzinfo=timezone.utc)
|
|
|
- base_conditions.append(PrintArchive.created_at >= dt_from)
|
|
|
+ base_conditions.append(PrintLogEntry.created_at >= dt_from)
|
|
|
if date_to:
|
|
|
dt_to = datetime.combine(date_to, time.max, tzinfo=timezone.utc)
|
|
|
- base_conditions.append(PrintArchive.created_at <= dt_to)
|
|
|
- _apply_user_filter(base_conditions, created_by_id)
|
|
|
+ base_conditions.append(PrintLogEntry.created_at <= dt_to)
|
|
|
+ _apply_run_user_filter(base_conditions, created_by_id)
|
|
|
|
|
|
- # Total counts
|
|
|
- total_result = await db.execute(select(func.count(PrintArchive.id)).where(*base_conditions))
|
|
|
+ # Total counts (one row per print event).
|
|
|
+ total_result = await db.execute(select(func.count(PrintLogEntry.id)).where(*base_conditions))
|
|
|
total_prints = total_result.scalar() or 0
|
|
|
|
|
|
successful_result = await db.execute(
|
|
|
- select(func.count(PrintArchive.id)).where(PrintArchive.status == "completed", *base_conditions)
|
|
|
+ select(func.count(PrintLogEntry.id)).where(PrintLogEntry.status == "completed", *base_conditions)
|
|
|
)
|
|
|
successful_prints = successful_result.scalar() or 0
|
|
|
|
|
|
failed_result = await db.execute(
|
|
|
- select(func.count(PrintArchive.id)).where(PrintArchive.status == "failed", *base_conditions)
|
|
|
+ select(func.count(PrintLogEntry.id)).where(PrintLogEntry.status == "failed", *base_conditions)
|
|
|
)
|
|
|
failed_prints = failed_result.scalar() or 0
|
|
|
|
|
|
- # Totals - use actual print time from timestamps (not slicer estimates)
|
|
|
- # For archives with both started_at and completed_at, calculate actual duration
|
|
|
- # Fall back to print_time_seconds only for archives without timestamps
|
|
|
- archives_for_time = await db.execute(
|
|
|
- select(PrintArchive.started_at, PrintArchive.completed_at, PrintArchive.print_time_seconds).where(
|
|
|
- *base_conditions
|
|
|
- )
|
|
|
+ # Total elapsed time — PrintLogEntry stores duration_seconds directly so we
|
|
|
+ # can sum it server-side. Rows missing duration fall back to the slicer
|
|
|
+ # estimate from the archive (joined for that case only).
|
|
|
+ time_rows = await db.execute(
|
|
|
+ select(
|
|
|
+ PrintLogEntry.duration_seconds,
|
|
|
+ PrintLogEntry.started_at,
|
|
|
+ PrintLogEntry.completed_at,
|
|
|
+ ).where(*base_conditions)
|
|
|
)
|
|
|
total_seconds = 0
|
|
|
- for started_at, completed_at, print_time_seconds in archives_for_time.all():
|
|
|
- if started_at and completed_at:
|
|
|
- # Use actual elapsed time
|
|
|
- actual_seconds = (completed_at - started_at).total_seconds()
|
|
|
- if actual_seconds > 0:
|
|
|
- total_seconds += actual_seconds
|
|
|
- elif print_time_seconds:
|
|
|
- # Fallback to estimate only if no timestamps
|
|
|
- total_seconds += print_time_seconds
|
|
|
+ for duration_seconds, started_at, completed_at in time_rows.all():
|
|
|
+ if duration_seconds:
|
|
|
+ total_seconds += duration_seconds
|
|
|
+ elif started_at and completed_at:
|
|
|
+ elapsed = (completed_at - started_at).total_seconds()
|
|
|
+ if elapsed > 0:
|
|
|
+ total_seconds += int(elapsed)
|
|
|
total_time = total_seconds / 3600 # Convert to hours
|
|
|
|
|
|
- # Sum filament directly - filament_used_grams already contains the total for the print job
|
|
|
filament_result = await db.execute(
|
|
|
- select(func.coalesce(func.sum(PrintArchive.filament_used_grams), 0)).where(*base_conditions)
|
|
|
+ select(func.coalesce(func.sum(PrintLogEntry.filament_used_grams), 0)).where(*base_conditions)
|
|
|
)
|
|
|
total_filament = filament_result.scalar() or 0
|
|
|
|
|
|
- cost_result = await db.execute(select(func.sum(PrintArchive.cost)).where(*base_conditions))
|
|
|
+ cost_result = await db.execute(select(func.sum(PrintLogEntry.cost)).where(*base_conditions))
|
|
|
total_cost = cost_result.scalar() or 0
|
|
|
|
|
|
# By filament type (split comma-separated values for multi-material prints)
|
|
|
filament_type_result = await db.execute(
|
|
|
- select(PrintArchive.filament_type).where(PrintArchive.filament_type.isnot(None), *base_conditions)
|
|
|
+ select(PrintLogEntry.filament_type).where(PrintLogEntry.filament_type.isnot(None), *base_conditions)
|
|
|
)
|
|
|
prints_by_filament: dict[str, int] = {}
|
|
|
for (filament_types,) in filament_type_result.all():
|
|
|
- # Split by comma and count each type
|
|
|
for ftype in filament_types.split(","):
|
|
|
ftype = ftype.strip()
|
|
|
if ftype:
|
|
|
@@ -756,47 +896,49 @@ async def get_archive_stats(
|
|
|
|
|
|
# By printer
|
|
|
printer_result = await db.execute(
|
|
|
- select(PrintArchive.printer_id, func.count(PrintArchive.id))
|
|
|
+ select(PrintLogEntry.printer_id, func.count(PrintLogEntry.id))
|
|
|
.where(*base_conditions)
|
|
|
- .group_by(PrintArchive.printer_id)
|
|
|
+ .group_by(PrintLogEntry.printer_id)
|
|
|
)
|
|
|
prints_by_printer = {str(k): v for k, v in printer_result.all()}
|
|
|
|
|
|
- # Time accuracy statistics
|
|
|
- # Get all completed archives with both estimated and actual times
|
|
|
- accuracy_result = await db.execute(
|
|
|
- select(PrintArchive)
|
|
|
- .where(PrintArchive.status == "completed", *base_conditions)
|
|
|
- .where(PrintArchive.print_time_seconds.isnot(None))
|
|
|
- .where(PrintArchive.started_at.isnot(None))
|
|
|
- .where(PrintArchive.completed_at.isnot(None))
|
|
|
+ # Time accuracy — compare each completed run's actual duration to the
|
|
|
+ # slicer's estimate on the linked archive. Runs without a linked archive
|
|
|
+ # (NULL archive_id) or without an estimate are excluded.
|
|
|
+ accuracy_rows = await db.execute(
|
|
|
+ select(
|
|
|
+ PrintLogEntry.duration_seconds,
|
|
|
+ PrintLogEntry.started_at,
|
|
|
+ PrintLogEntry.completed_at,
|
|
|
+ PrintLogEntry.printer_id,
|
|
|
+ PrintArchive.print_time_seconds,
|
|
|
+ )
|
|
|
+ .join(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
|
|
|
+ .where(
|
|
|
+ PrintLogEntry.status == "completed",
|
|
|
+ PrintArchive.print_time_seconds.isnot(None),
|
|
|
+ *base_conditions,
|
|
|
+ )
|
|
|
)
|
|
|
- archives_with_times = list(accuracy_result.scalars().all())
|
|
|
-
|
|
|
average_accuracy = None
|
|
|
accuracy_by_printer: dict[str, float] = {}
|
|
|
-
|
|
|
- if archives_with_times:
|
|
|
- accuracies = []
|
|
|
- printer_accuracies: dict[str, list[float]] = {}
|
|
|
-
|
|
|
- for archive in archives_with_times:
|
|
|
- acc_data = compute_time_accuracy(archive)
|
|
|
- if acc_data["time_accuracy"] is not None:
|
|
|
- accuracies.append(acc_data["time_accuracy"])
|
|
|
-
|
|
|
- # Group by printer
|
|
|
- printer_key = str(archive.printer_id) if archive.printer_id else "unknown"
|
|
|
- if printer_key not in printer_accuracies:
|
|
|
- printer_accuracies[printer_key] = []
|
|
|
- printer_accuracies[printer_key].append(acc_data["time_accuracy"])
|
|
|
-
|
|
|
- if accuracies:
|
|
|
- average_accuracy = round(sum(accuracies) / len(accuracies), 1)
|
|
|
-
|
|
|
- # Calculate per-printer averages
|
|
|
- for printer_key, accs in printer_accuracies.items():
|
|
|
- accuracy_by_printer[printer_key] = round(sum(accs) / len(accs), 1)
|
|
|
+ accuracies: list[float] = []
|
|
|
+ printer_accuracies: dict[str, list[float]] = {}
|
|
|
+ for duration_seconds, started_at, completed_at, run_printer_id, estimate_seconds in accuracy_rows.all():
|
|
|
+ actual_seconds = duration_seconds
|
|
|
+ if not actual_seconds and started_at and completed_at:
|
|
|
+ elapsed = (completed_at - started_at).total_seconds()
|
|
|
+ actual_seconds = int(elapsed) if elapsed > 0 else None
|
|
|
+ if not actual_seconds or not estimate_seconds:
|
|
|
+ continue
|
|
|
+ accuracy = (estimate_seconds / actual_seconds) * 100
|
|
|
+ accuracies.append(accuracy)
|
|
|
+ printer_key = str(run_printer_id) if run_printer_id else "unknown"
|
|
|
+ printer_accuracies.setdefault(printer_key, []).append(accuracy)
|
|
|
+ if accuracies:
|
|
|
+ average_accuracy = round(sum(accuracies) / len(accuracies), 1)
|
|
|
+ for printer_key, accs in printer_accuracies.items():
|
|
|
+ accuracy_by_printer[printer_key] = round(sum(accs) / len(accs), 1)
|
|
|
|
|
|
# Energy totals - check which mode to use
|
|
|
from backend.app.api.routes.settings import get_setting
|
|
|
@@ -823,11 +965,11 @@ async def get_archive_stats(
|
|
|
)
|
|
|
total_energy_cost = total_energy_kwh * energy_cost_per_kwh
|
|
|
else:
|
|
|
- # Per-print mode: sum the per-print energy column directly.
|
|
|
- energy_kwh_result = await db.execute(select(func.sum(PrintArchive.energy_kwh)).where(*base_conditions))
|
|
|
+ # Per-print mode: sum the per-run energy column from PrintLogEntry.
|
|
|
+ energy_kwh_result = await db.execute(select(func.sum(PrintLogEntry.energy_kwh)).where(*base_conditions))
|
|
|
total_energy_kwh = energy_kwh_result.scalar() or 0
|
|
|
|
|
|
- energy_cost_result = await db.execute(select(func.sum(PrintArchive.energy_cost)).where(*base_conditions))
|
|
|
+ energy_cost_result = await db.execute(select(func.sum(PrintLogEntry.energy_cost)).where(*base_conditions))
|
|
|
total_energy_cost = energy_cost_result.scalar() or 0
|
|
|
|
|
|
return ArchiveStats(
|
|
|
@@ -977,7 +1119,9 @@ async def get_all_tags(
|
|
|
Returns a list of tags sorted by count (descending), then by name.
|
|
|
"""
|
|
|
# Query all archives with non-null tags
|
|
|
- result = await db.execute(select(PrintArchive.tags).where(PrintArchive.tags.isnot(None)))
|
|
|
+ result = await db.execute(
|
|
|
+ select(PrintArchive.tags).where(PrintArchive.tags.isnot(None), PrintArchive.deleted_at.is_(None))
|
|
|
+ )
|
|
|
all_tags_rows = result.all()
|
|
|
|
|
|
# Count occurrences of each tag
|
|
|
@@ -1018,7 +1162,9 @@ async def rename_tag(
|
|
|
return {"affected": 0}
|
|
|
|
|
|
# Find all archives containing the old tag
|
|
|
- result = await db.execute(select(PrintArchive).where(PrintArchive.tags.isnot(None)))
|
|
|
+ result = await db.execute(
|
|
|
+ select(PrintArchive).where(PrintArchive.tags.isnot(None), PrintArchive.deleted_at.is_(None))
|
|
|
+ )
|
|
|
archives = list(result.scalars().all())
|
|
|
|
|
|
affected = 0
|
|
|
@@ -1054,7 +1200,9 @@ async def delete_tag(
|
|
|
Returns the count of affected archives.
|
|
|
"""
|
|
|
# Find all archives containing the tag
|
|
|
- result = await db.execute(select(PrintArchive).where(PrintArchive.tags.isnot(None)))
|
|
|
+ result = await db.execute(
|
|
|
+ select(PrintArchive).where(PrintArchive.tags.isnot(None), PrintArchive.deleted_at.is_(None))
|
|
|
+ )
|
|
|
archives = list(result.scalars().all())
|
|
|
|
|
|
affected = 0
|
|
|
@@ -1081,7 +1229,11 @@ async def get_archive(
|
|
|
"""Get a specific archive."""
|
|
|
service = ArchiveService(db)
|
|
|
archive = await service.get_archive(archive_id)
|
|
|
- if not archive:
|
|
|
+ # Soft-deleted archives are hidden from the UI (#1343) — surface them as
|
|
|
+ # 404 here too so a stale bookmark / direct URL doesn't expose a row the
|
|
|
+ # user has already removed. The hard-delete (?purge_stats=true) path
|
|
|
+ # bypasses this check by querying PrintArchive directly.
|
|
|
+ if not archive or archive.deleted_at is not None:
|
|
|
raise HTTPException(404, "Archive not found")
|
|
|
|
|
|
# Find duplicates
|
|
|
@@ -1092,7 +1244,35 @@ async def get_archive(
|
|
|
print_name=archive.print_name,
|
|
|
makerworld_model_id=makerworld_id,
|
|
|
)
|
|
|
- return archive_to_response(archive, duplicates)
|
|
|
+ run_aggregates = await _load_run_aggregates(db, [archive.id])
|
|
|
+ return archive_to_response(archive, duplicates, run_aggregate=run_aggregates.get(archive.id))
|
|
|
+
|
|
|
+
|
|
|
+@router.get("/{archive_id}/runs", response_model=PrintLogResponse)
|
|
|
+async def list_archive_runs(
|
|
|
+ archive_id: int,
|
|
|
+ db: AsyncSession = Depends(get_db),
|
|
|
+ _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
|
|
|
+):
|
|
|
+ """List PrintLogEntry rows for this archive — one per print event.
|
|
|
+
|
|
|
+ Newest first. Drives the per-archive "Print Log" view (#1378).
|
|
|
+ """
|
|
|
+ from backend.app.models.print_log import PrintLogEntry
|
|
|
+ from backend.app.schemas.print_log import PrintLogEntrySchema
|
|
|
+
|
|
|
+ archive = await db.get(PrintArchive, archive_id)
|
|
|
+ if not archive or archive.deleted_at is not None:
|
|
|
+ raise HTTPException(404, "Archive not found")
|
|
|
+
|
|
|
+ rows = await db.execute(
|
|
|
+ select(PrintLogEntry)
|
|
|
+ .where(PrintLogEntry.archive_id == archive_id)
|
|
|
+ .order_by(PrintLogEntry.started_at.desc().nulls_last(), PrintLogEntry.id.desc())
|
|
|
+ )
|
|
|
+ entries = list(rows.scalars().all())
|
|
|
+ items = [PrintLogEntrySchema.model_validate(e, from_attributes=True) for e in entries]
|
|
|
+ return PrintLogResponse(items=items, total=len(items))
|
|
|
|
|
|
|
|
|
@router.get("/{archive_id}/similar")
|
|
|
@@ -1230,15 +1410,29 @@ async def rescan_archive(
|
|
|
if metadata.get("designer"):
|
|
|
archive.designer = metadata["designer"]
|
|
|
|
|
|
- # Calculate cost: prefer spool-based cost if available, else catalog-based
|
|
|
+ # Calculate cost: prefer spool-based cost if available, else catalog-based.
|
|
|
+ # When spool-based costs exist but don't cover every filament gram used
|
|
|
+ # (#1344), fall back to the global default rate for the untracked weight
|
|
|
+ # so the displayed cost still reflects the whole print.
|
|
|
|
|
|
if archive.filament_used_grams and archive.filament_type:
|
|
|
+ default_cost_setting = await get_setting(db, "default_filament_cost")
|
|
|
+ default_cost_per_kg = float(default_cost_setting) if default_cost_setting else 25.0
|
|
|
usage_result = await db.execute(
|
|
|
- select(func.sum(SpoolUsageHistory.cost)).where(SpoolUsageHistory.archive_id == archive.id)
|
|
|
+ select(
|
|
|
+ func.sum(SpoolUsageHistory.cost),
|
|
|
+ func.sum(SpoolUsageHistory.weight_used),
|
|
|
+ ).where(SpoolUsageHistory.archive_id == archive.id)
|
|
|
)
|
|
|
- usage_cost = usage_result.scalar()
|
|
|
+ usage_cost_row = usage_result.one()
|
|
|
+ usage_cost = usage_cost_row[0]
|
|
|
+ tracked_grams = float(usage_cost_row[1] or 0)
|
|
|
if usage_cost is not None and usage_cost > 0:
|
|
|
- archive.cost = float(Decimal(str(usage_cost)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
|
|
|
+ total_cost = float(usage_cost)
|
|
|
+ untracked_grams = max(0.0, archive.filament_used_grams - tracked_grams)
|
|
|
+ if untracked_grams > 0 and default_cost_per_kg > 0:
|
|
|
+ total_cost += (untracked_grams / 1000.0) * default_cost_per_kg
|
|
|
+ archive.cost = float(Decimal(str(total_cost)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
|
|
|
else:
|
|
|
primary_type = archive.filament_type.split(",")[0].strip()
|
|
|
filament_result = await db.execute(select(Filament).where(Filament.type == primary_type).limit(1))
|
|
|
@@ -1250,9 +1444,6 @@ async def rescan_archive(
|
|
|
)
|
|
|
)
|
|
|
else:
|
|
|
- # Use default filament cost from settings
|
|
|
- default_cost_setting = await get_setting(db, "default_filament_cost")
|
|
|
- default_cost_per_kg = float(default_cost_setting) if default_cost_setting else 25.0
|
|
|
archive.cost = float(
|
|
|
Decimal(str((archive.filament_used_grams / 1000) * default_cost_per_kg)).quantize(
|
|
|
Decimal("0.01"), rounding=ROUND_HALF_UP
|
|
|
@@ -1284,18 +1475,34 @@ async def recalculate_all_costs(
|
|
|
default_cost_setting = await get_setting(db, "default_filament_cost")
|
|
|
default_cost_per_kg = float(default_cost_setting) if default_cost_setting else 25.0
|
|
|
|
|
|
- # Pre-fetch all usage costs by archive_id
|
|
|
+ # Pre-fetch all usage costs and tracked weight by archive_id.
|
|
|
+ # Tracked weight is used to top-up the cost at the default rate for any
|
|
|
+ # filament grams not covered by an inventory spool (#1344).
|
|
|
usage_costs_result = await db.execute(
|
|
|
- select(SpoolUsageHistory.archive_id, func.sum(SpoolUsageHistory.cost)).group_by(SpoolUsageHistory.archive_id)
|
|
|
+ select(
|
|
|
+ SpoolUsageHistory.archive_id,
|
|
|
+ func.sum(SpoolUsageHistory.cost),
|
|
|
+ func.sum(SpoolUsageHistory.weight_used),
|
|
|
+ ).group_by(SpoolUsageHistory.archive_id)
|
|
|
)
|
|
|
usage_costs = usage_costs_result.fetchall()
|
|
|
- cost_map = {row[0]: row[1] for row in usage_costs if row[0] is not None and row[1] is not None and row[1] > 0}
|
|
|
+ cost_map = {
|
|
|
+ row[0]: (row[1], float(row[2] or 0))
|
|
|
+ for row in usage_costs
|
|
|
+ if row[0] is not None and row[1] is not None and row[1] > 0
|
|
|
+ }
|
|
|
|
|
|
updated = 0
|
|
|
for archive in archives:
|
|
|
- usage_cost = cost_map.get(archive.id)
|
|
|
- if usage_cost is not None:
|
|
|
- new_cost = round(usage_cost, 2)
|
|
|
+ usage = cost_map.get(archive.id)
|
|
|
+ if usage is not None:
|
|
|
+ usage_cost, tracked_grams = usage
|
|
|
+ total_cost = float(usage_cost)
|
|
|
+ archive_grams = float(archive.filament_used_grams or 0)
|
|
|
+ untracked_grams = max(0.0, archive_grams - tracked_grams)
|
|
|
+ if untracked_grams > 0 and default_cost_per_kg > 0:
|
|
|
+ total_cost += (untracked_grams / 1000.0) * default_cost_per_kg
|
|
|
+ new_cost = round(total_cost, 2)
|
|
|
else:
|
|
|
# Fallback: sum costs for old records by print_name
|
|
|
usage_result = await db.execute(
|
|
|
@@ -1425,6 +1632,15 @@ async def backfill_content_hashes(
|
|
|
@router.delete("/{archive_id}")
|
|
|
async def delete_archive(
|
|
|
archive_id: int,
|
|
|
+ purge_stats: bool = Query(
|
|
|
+ False,
|
|
|
+ description=(
|
|
|
+ "When false (default) the archive is soft-deleted — files removed "
|
|
|
+ "from disk, row hidden from listings, but its filament / energy / "
|
|
|
+ "time / cost contribution stays in Quick Stats. Set true to also "
|
|
|
+ "drop the row from statistics (#1343)."
|
|
|
+ ),
|
|
|
+ ),
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
auth_result: tuple[User | None, bool] = Depends(
|
|
|
require_ownership_permission(
|
|
|
@@ -1433,7 +1649,7 @@ async def delete_archive(
|
|
|
)
|
|
|
),
|
|
|
):
|
|
|
- """Delete an archive."""
|
|
|
+ """Delete an archive (soft by default; ``?purge_stats=true`` to hard-delete)."""
|
|
|
user, can_modify_all = auth_result
|
|
|
|
|
|
# Get archive first to check ownership
|
|
|
@@ -1448,9 +1664,25 @@ async def delete_archive(
|
|
|
raise HTTPException(403, "You can only delete your own archives")
|
|
|
|
|
|
service = ArchiveService(db)
|
|
|
- if not await service.delete_archive(archive_id):
|
|
|
+ if purge_stats:
|
|
|
+ # Hard-delete the linked PrintLogEntry rows first so their filament /
|
|
|
+ # cost / count contributions disappear from /archives/stats. The FK is
|
|
|
+ # ON DELETE SET NULL, so without this delete the runs would survive
|
|
|
+ # the archive row and keep showing up in totals (#1343 / #1378).
|
|
|
+ from sqlalchemy import delete as sa_delete
|
|
|
+
|
|
|
+ from backend.app.models.print_log import PrintLogEntry
|
|
|
+
|
|
|
+ await db.execute(sa_delete(PrintLogEntry).where(PrintLogEntry.archive_id == archive_id))
|
|
|
+ await db.commit()
|
|
|
+
|
|
|
+ if not await service.delete_archive(archive_id):
|
|
|
+ raise HTTPException(404, "Archive not found")
|
|
|
+ return {"status": "deleted", "purged_from_stats": True}
|
|
|
+
|
|
|
+ if not await service.soft_delete_archive(archive_id):
|
|
|
raise HTTPException(404, "Archive not found")
|
|
|
- return {"status": "deleted"}
|
|
|
+ return {"status": "deleted", "purged_from_stats": False}
|
|
|
|
|
|
|
|
|
@router.get("/{archive_id}/download")
|
|
|
@@ -1721,65 +1953,15 @@ async def scan_timelapse(
|
|
|
matching_file = f
|
|
|
break
|
|
|
|
|
|
- # Strategy 2: Match by timestamp proximity
|
|
|
- # Bambu timelapse filename uses the print START time (when recording began)
|
|
|
- if not matching_file and (archive.started_at or archive.completed_at or archive.created_at):
|
|
|
- import re
|
|
|
- from datetime import datetime, timedelta
|
|
|
-
|
|
|
- # Prefer started_at since video filename is the print start time
|
|
|
- # Fall back to completed_at or created_at if started_at is not available
|
|
|
- archive_start = archive.started_at
|
|
|
- archive_end = archive.completed_at or archive.created_at
|
|
|
- best_match = None
|
|
|
- best_diff = timedelta(hours=24) # Max 24 hour difference
|
|
|
-
|
|
|
- for f in video_files:
|
|
|
- fname = f.get("name", "")
|
|
|
- # Parse timestamp from filename like "video_2025-11-24_03-17-40.mp4"
|
|
|
- match = re.search(r"(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})", fname)
|
|
|
- if match:
|
|
|
- try:
|
|
|
- file_time = datetime.strptime(match.group(1), "%Y-%m-%d_%H-%M-%S")
|
|
|
-
|
|
|
- # Try multiple timezone offsets since printer timezone can vary
|
|
|
- # Common cases: local time (0), CST/UTC+8 (+8), or UTC (-local offset)
|
|
|
- for hour_offset in [0, 8, -8, 7, -7, 1, -1]:
|
|
|
- adjusted_file_time = file_time - timedelta(hours=hour_offset)
|
|
|
-
|
|
|
- # Check against start time (video filename = print start)
|
|
|
- if archive_start:
|
|
|
- diff = abs(adjusted_file_time - archive_start)
|
|
|
- if diff < best_diff:
|
|
|
- best_diff = diff
|
|
|
- best_match = f
|
|
|
- logger.debug(
|
|
|
- f"Timelapse match candidate: {fname} with offset {hour_offset}h, "
|
|
|
- f"diff from start: {diff}"
|
|
|
- )
|
|
|
-
|
|
|
- # Also check against end time with a buffer
|
|
|
- # (video timestamp should be BEFORE completion time)
|
|
|
- if archive_end:
|
|
|
- # The video timestamp should be within the print duration before completion
|
|
|
- if adjusted_file_time < archive_end:
|
|
|
- diff = archive_end - adjusted_file_time
|
|
|
- # Reasonable print duration: up to 48 hours
|
|
|
- if diff < timedelta(hours=48) and diff < best_diff:
|
|
|
- best_diff = diff
|
|
|
- best_match = f
|
|
|
- logger.debug(
|
|
|
- f"Timelapse match candidate (from end): {fname} with offset {hour_offset}h, "
|
|
|
- f"diff: {diff}"
|
|
|
- )
|
|
|
-
|
|
|
- except ValueError:
|
|
|
- continue
|
|
|
-
|
|
|
- # Accept match within 4 hours (more lenient for timezone issues)
|
|
|
- if best_match and best_diff < timedelta(hours=4):
|
|
|
- matching_file = best_match
|
|
|
- logger.info("Matched timelapse by timestamp: %s (diff: %s)", best_match.get("name"), best_diff)
|
|
|
+ # Strategy 2: Match by timestamp proximity against print START time.
|
|
|
+ # Bambu timelapse filename embeds the print start time in printer-local clock.
|
|
|
+ # See _match_timelapse_by_timestamp for the offset-search rationale and why we
|
|
|
+ # intentionally don't try to match filename against end time here.
|
|
|
+ if not matching_file and archive.started_at:
|
|
|
+ candidate, diff = _match_timelapse_by_timestamp(video_files, archive.started_at)
|
|
|
+ if candidate is not None:
|
|
|
+ matching_file = candidate
|
|
|
+ logger.info("Matched timelapse by timestamp: %s (diff: %s)", candidate.get("name"), diff)
|
|
|
|
|
|
# Strategy 3: Use file modification time from FTP listing
|
|
|
# This handles cases where printer's filename timestamp is wrong but file mtime is correct
|