Files
bambuddy/backend/app/schemas/archive.py
T
maziggy aff737999f Build the slice output's path from a name a folder can have (#2832)
A print's display name comes from inside the 3MF, not from the filename,
so a MakerWorld title arrives with its punctuation: "Planter Pot with
Drip Tray, 12 cm / 5 inches". The slice-to-archive sink used it verbatim
for the output folder and the output file, and a slash in a folder name
is not a character -- it is another folder. mkdir(parents=True) created
the level it implied and the file's own join added a third that nobody
had made, so the slice failed with ENOENT on a path that half existed.
Renaming the print first was the only way through.

Reduce a display name to a single path component before it becomes one.
Characters a name cannot hold are replaced rather than dropped, so the
folder still reads like the model's title, and the set is the one the SD
card already rejects -- which covers a Windows install too, where the
colon in "Model: v2" fails the same way. The name shown in Bambuddy is
untouched: a title is allowed its punctuation, and refusing the slash
would reject the name this was reported about.

The joins are asserted to stay under the archive directory. That was
already claimed by a SEC-PATH-OK marker on both lines, citing a
sanitiser that is defined in another module and was never called here;
without the marker the path-join backstop flags them both. The claim is
now true, and a future edit that reaches around the reduction is caught
rather than trusted.

The library sink takes the same embedded name, so it gets the same
reduction: managed storage names the file after a UUID and never saw
this, but an external folder writes the name as given.

Display names are also stripped of control characters on the way into
the database, in the schema and in the archive service. The validator
hands back anything that is not a string rather than iterating it, so
the field still answers a list or a bare int with a 422 instead of
accepting the one and failing on the other.

Display names are also stripped of control characters on the way into
the database, in the schema and in the archive service, cleaned before
the filename fallback rather than after it so a whitespace-only embedded
name still falls through to the filename. The validator hands back
anything that is not a string rather than iterating it, so the field
still answers a list or a bare int with a 422 instead of accepting the
one and failing on the other.
2026-08-14 16:02:53 +02:00

233 lines
7.5 KiB
Python

from datetime import datetime
from typing import Annotated
from pydantic import BaseModel, BeforeValidator, model_validator
from backend.app.utils.filename import clean_display_name
# Free text, punctuation and all -- only control characters are taken out, and
# only on the way in (#2832). Anything that turns a name into a path sanitises
# it there instead, where the budget and the fallback are known.
DisplayName = Annotated[str | None, BeforeValidator(clean_display_name)]
class ArchiveBase(BaseModel):
print_name: DisplayName = None
is_favorite: bool | None = None
tags: str | None = None
notes: str | None = None
cost: float | None = None
failure_reason: str | None = None
quantity: int | None = None # Number of items printed
# User-defined link (Printables, Thingiverse, etc.)
external_url: str | None = None
class ArchiveUpdate(ArchiveBase):
printer_id: int | None = None
project_id: int | None = None
# Allow changing status (e.g., clearing failed flag)
status: str | None = None
class ArchiveDuplicate(BaseModel):
"""Reference to a duplicate archive."""
id: int
print_name: str | None
created_at: datetime | None
match_type: str # "exact" (hash match) or "similar" (name match)
class ArchiveResponse(BaseModel):
id: int
printer_id: int | None
project_id: int | None = None
project_name: str | None = None # Included for convenience
filename: str
file_path: str
file_size: int
content_hash: str | None
thumbnail_path: str | None
timelapse_path: str | None
source_3mf_path: str | None = None # Original project 3MF from slicer
f3d_path: str | None = None # Fusion 360 design file
# Duplicate detection
duplicates: list[ArchiveDuplicate] | None = None
duplicate_count: int = 0 # Quick count for list views
duplicate_sequence: int = 0 # 0 = original, 1+ = nth duplicate
original_archive_id: int | None = None # ID of the first/original archive
# Object count (computed from extra_data.printable_objects)
object_count: int | None = None
print_name: str | None
plate_id: int | None = None # Selected plate of a multi-plate 3MF (#2603)
print_time_seconds: int | None # Estimated time from slicer
actual_time_seconds: int | None = None # Computed from started_at/completed_at
# Percentage: 100 = perfect, >100 = faster than estimated
time_accuracy: float | None = None
filament_used_grams: float | None
filament_type: str | None
filament_color: str | None
layer_height: float | None
total_layers: int | None = None
nozzle_diameter: float | None
bed_temperature: int | None
bed_type: str | None = None # e.g. "Cool Plate", "Textured PEI Plate" (from 3MF curr_bed_type)
nozzle_temperature: int | None
sliced_for_model: str | None = None # Printer model this file was sliced for
status: str
started_at: datetime | None
completed_at: datetime | None
extra_data: dict | None
makerworld_url: str | None
designer: str | None
# User-defined link (Printables, Thingiverse, etc.)
external_url: str | None = None
is_favorite: bool
tags: str | None
notes: str | None
cost: float | None
photos: list | None
failure_reason: str | None
quantity: int = 1 # Number of items printed
# Energy tracking
energy_kwh: float | None = None
energy_cost: float | None = None
created_at: datetime | None
# User tracking (Issue #206)
created_by_id: int | None = None
created_by_username: str | None = None
# Per-archive run aggregates (#1378). Computed from PrintLogEntry — one
# row per actual print event — so reprints contribute to these counters
# without overwriting the source archive's first-run data.
run_count: int = 0
last_run_at: datetime | None = None
total_filament_actual_grams: float | None = None
successful_run_count: int = 0
failed_run_count: int = 0
@model_validator(mode="after")
def compute_object_count(self) -> "ArchiveResponse":
"""Compute object_count from extra_data.printable_objects if not set."""
if self.object_count is None and self.extra_data:
printable_objects = self.extra_data.get("printable_objects")
if printable_objects and isinstance(printable_objects, dict):
self.object_count = len(printable_objects)
return self
class Config:
from_attributes = True
class ArchiveSlim(BaseModel):
"""Lightweight archive response for stats/dashboard widgets."""
printer_id: int | None
print_name: str | None
print_time_seconds: int | None
actual_time_seconds: int | None = None
filament_used_grams: float | None
filament_type: str | None
filament_color: str | None
status: str
started_at: datetime | None
completed_at: datetime | None
cost: float | None
energy_kwh: float | None = None
energy_cost: float | None = None
quantity: int = 1
created_at: datetime | None
class Config:
from_attributes = True
class ArchiveStats(BaseModel):
total_prints: int
successful_prints: int
failed_prints: int
# User/system-stopped prints (PrintLogEntry.status in stopped/cancelled/
# skipped). Defaulted so older clients that don't send this field still
# validate against historical fixtures.
cancelled_prints: int = 0
total_print_time_hours: float
total_filament_grams: float
total_cost: float
prints_by_filament_type: dict
prints_by_printer: dict
# Time accuracy stats
# Average across all prints with data
average_time_accuracy: float | None = None
time_accuracy_by_printer: dict | None = None # Per-printer accuracy
# Energy stats
total_energy_kwh: float = 0.0
total_energy_cost: float = 0.0
# Set when the date-range query in "total consumption" mode is running on
# incomplete snapshot history — e.g. right after a fresh upgrade before the
# hourly snapshot loop has built up a baseline. Frontend shows a tooltip.
energy_data_warming_up: bool = False
class ProjectPageImage(BaseModel):
"""Image embedded in 3MF project page."""
name: str
path: str # Path within 3MF
url: str # API URL to fetch image
class ProjectPageResponse(BaseModel):
"""Project page data extracted from 3MF file."""
# Model info
title: str | None = None
description: str | None = None # HTML content
designer: str | None = None
designer_user_id: str | None = None
license: str | None = None
copyright: str | None = None
creation_date: str | None = None
modification_date: str | None = None
origin: str | None = None # "original" or "remix"
# Profile info
profile_title: str | None = None
profile_description: str | None = None
profile_cover: str | None = None
profile_user_id: str | None = None
profile_user_name: str | None = None
# MakerWorld info
design_model_id: str | None = None
design_profile_id: str | None = None
design_region: str | None = None
# Images
model_pictures: list[ProjectPageImage] = []
profile_pictures: list[ProjectPageImage] = []
thumbnails: list[ProjectPageImage] = []
class ProjectPageUpdate(BaseModel):
"""Update project page data in 3MF file."""
title: str | None = None
description: str | None = None
designer: str | None = None
license: str | None = None
copyright: str | None = None
profile_title: str | None = None
profile_description: str | None = None