mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-05 13:41:36 +02:00
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.
233 lines
7.5 KiB
Python
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
|