Files
maziggy 6c8a821bca Add a wear cost per printing hour to printers (#694)
Print costs covered filament and energy but not the printer. Each printer
can now carry an optional wear cost per printing hour. Every run's wear is
its logged duration at that rate, stored on the print log entry; the
archive keeps the first run's wear, like energy (#1378). Failed and
cancelled runs count, reconciled runs (unknown duration) don't, and a rate
applies to new prints only.

Wear is shown on the archive card, as a hidden sortable Print Log column,
as a Quick Stats tile, in the most expensive record, in project totals and
in batch order cost, and is kept by GitHub backup and restore. Billing
still charges filament only.

New nullable columns: printers.wear_cost_per_hour, print_log_entries.wear_cost
and print_archives.wear_cost.
2026-10-05 09:29:12 +02:00

337 lines
10 KiB
Python

from datetime import datetime
from pydantic import BaseModel, field_validator
def _validate_project_url(value: str | None) -> str | None:
"""Reject anything that isn't an http(s) URL — the URL is rendered as a
clickable `<a href>` so a `javascript:` / `data:` / `file:` value would be
an XSS vector even with React's default escaping (#1155)."""
if value is None:
return value
trimmed = value.strip()
if not trimmed:
return None
lowered = trimmed.lower()
if not (lowered.startswith("http://") or lowered.startswith("https://")):
raise ValueError("url must start with http:// or https://")
return trimmed
class ProjectCreate(BaseModel):
"""Schema for creating a new project."""
name: str
description: str | None = None
color: str | None = None
target_count: int | None = None
target_parts_count: int | None = None
target_sets: int | None = None # Copies-per-file target (#1897)
notes: str | None = None
tags: str | None = None
due_date: datetime | None = None
priority: str = "normal"
budget: float | None = None
parent_id: int | None = None # For sub-projects
url: str | None = None
@field_validator("url")
@classmethod
def _check_url(cls, v: str | None) -> str | None:
return _validate_project_url(v)
class ProjectUpdate(BaseModel):
"""Schema for updating a project."""
name: str | None = None
description: str | None = None
color: str | None = None
status: str | None = None # active, completed, archived
target_count: int | None = None
target_parts_count: int | None = None
target_sets: int | None = None # Copies-per-file target (#1897)
notes: str | None = None
tags: str | None = None
due_date: datetime | None = None
priority: str | None = None
budget: float | None = None
parent_id: int | None = None
url: str | None = None
@field_validator("url")
@classmethod
def _check_url(cls, v: str | None) -> str | None:
return _validate_project_url(v)
class ProjectStats(BaseModel):
"""Statistics for a project."""
total_archives: int = 0 # Number of archive records
total_items: int = 0 # Sum of quantities (total items printed)
completed_prints: int = 0 # Sum of quantities for completed prints
failed_prints: int = 0 # Sum of quantities for failed prints
queued_prints: int = 0
in_progress_prints: int = 0
total_print_time_hours: float = 0.0
total_filament_grams: float = 0.0
progress_percent: float | None = None # Based on target_count (plates)
parts_progress_percent: float | None = None # Based on target_parts_count
# Cost tracking (Phase 6)
estimated_cost: float = 0.0 # Based on filament cost
total_energy_kwh: float = 0.0
total_energy_cost: float = 0.0
total_wear_cost: float = 0.0 # Printer wear (#694)
remaining_prints: int | None = None # target_count - total_archives
remaining_parts: int | None = None # target_parts_count - completed_prints
# BOM stats (Phase 7)
bom_total_items: int = 0
bom_completed_items: int = 0
bom_cost: float = 0.0 # Total cost of BOM items (sum of unit_price * quantity_needed)
class ProjectChildPreview(BaseModel):
"""A sub-project as listed on its parent's page.
The figures cover the child's *own* subtree, not just its own prints, so
the listed rows add up to the parent's roll-up minus the parent's own
prints (#1264).
"""
id: int
name: str
color: str | None
status: str
progress_percent: float | None = None
descendant_count: int = 0 # Sub-projects nested under this one, at any depth
total_archives: int = 0
completed_prints: int = 0
total_print_time_hours: float = 0.0
total_filament_grams: float = 0.0
total_cost: float = 0.0 # Filament + energy + BOM, matching the parent's cost card
class ProjectResponse(BaseModel):
"""Schema for project response."""
id: int
name: str
description: str | None
color: str | None
status: str
target_count: int | None
target_parts_count: int | None = None
target_sets: int | None = None # Copies-per-file target (#1897)
notes: str | None = None
attachments: list | None = None
tags: str | None = None
due_date: datetime | None = None
priority: str = "normal"
budget: float | None = None
is_template: bool = False
template_source_id: int | None = None
parent_id: int | None = None
parent_name: str | None = None # For display
children: list[ProjectChildPreview] = []
descendant_count: int = 0 # Sub-projects at any depth beneath this one (#1264)
created_at: datetime
updated_at: datetime
stats: ProjectStats | None = None
# This project's numbers combined with every sub-project's. Null when there
# are none, since it would only repeat ``stats`` (#1264).
rollup_stats: ProjectStats | None = None
url: str | None = None
cover_image_filename: str | None = None
class Config:
from_attributes = True
class ProjectFileProgress(BaseModel):
"""Completed-run count for one library file inside a project (#1897)."""
file_id: int
completed_count: int
class ArchivePreview(BaseModel):
"""Minimal archive data for project preview."""
id: int
print_name: str | None
thumbnail_path: str | None
status: str
filament_type: str | None = None
filament_color: str | None = None
class ProjectListResponse(BaseModel):
"""Schema for project list item (lighter weight)."""
id: int
name: str
description: str | None
color: str | None
status: str
target_count: int | None
target_parts_count: int | None = None
target_sets: int | None = None # Copies-per-file target (#1897); the shared edit dialog needs it
budget: float | None = None
# The edit dialog is shared with the project detail page and seeds its fields
# from whichever project object it is handed, so the list payload has to carry
# everything the dialog edits — otherwise a save from the list view submits a
# blank tags field and a default priority over the stored values (#2536).
tags: str | None = None
due_date: datetime | None = None
priority: str = "normal"
created_at: datetime
# Quick stats
archive_count: int = 0 # Number of print jobs
total_items: int = 0 # Sum of quantities (total items printed, including failed)
completed_count: int = 0 # Sum of quantities for completed prints only
failed_count: int = 0 # Sum of quantities for failed prints
queue_count: int = 0
progress_percent: float | None = None
# Nesting (#1264) — the grid needs both to tell a sub-project apart from a
# top-level one without fetching every project's detail.
parent_id: int | None = None
child_count: int = 0 # Direct sub-projects only
# Preview of archives (up to 5)
archives: list[ArchivePreview] = []
# #1155: card-level metadata
url: str | None = None
cover_image_filename: str | None = None
class Config:
from_attributes = True
class BatchAddArchives(BaseModel):
"""Schema for batch adding archives to a project."""
archive_ids: list[int]
class BatchAddQueueItems(BaseModel):
"""Schema for batch adding queue items to a project."""
queue_item_ids: list[int]
# Phase 7: BOM Schemas - Tracks sourced/purchased parts
class BOMItemCreate(BaseModel):
"""Schema for creating a BOM item."""
name: str
quantity_needed: int = 1
unit_price: float | None = None
sourcing_url: str | None = None
archive_id: int | None = None
stl_filename: str | None = None
remarks: str | None = None
class BOMItemUpdate(BaseModel):
"""Schema for updating a BOM item."""
name: str | None = None
quantity_needed: int | None = None
quantity_acquired: int | None = None
unit_price: float | None = None
sourcing_url: str | None = None
archive_id: int | None = None
stl_filename: str | None = None
remarks: str | None = None
class BOMItemResponse(BaseModel):
"""Schema for BOM item response."""
id: int
project_id: int
name: str
quantity_needed: int
quantity_acquired: int
unit_price: float | None
sourcing_url: str | None
archive_id: int | None
archive_name: str | None = None
stl_filename: str | None
remarks: str | None
sort_order: int
is_complete: bool = False
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
# Phase 9: Timeline Schemas
class TimelineEvent(BaseModel):
"""Schema for a timeline event."""
event_type: str # archive_added, queue_started, queue_completed, status_changed, note_updated
timestamp: datetime
title: str
description: str | None = None
metadata: dict | None = None # Additional event-specific data
# Phase 10: Import/Export Schemas
class BOMItemExport(BaseModel):
"""Schema for exporting a BOM item."""
name: str
quantity_needed: int
quantity_acquired: int
unit_price: float | None
sourcing_url: str | None
stl_filename: str | None
remarks: str | None
class LinkedFolderExport(BaseModel):
"""Schema for exporting a linked library folder."""
name: str
class ProjectExport(BaseModel):
"""Schema for exporting a project."""
name: str
description: str | None
color: str | None
status: str
target_count: int | None
target_parts_count: int | None
target_sets: int | None = None
notes: str | None
tags: str | None
due_date: datetime | None
priority: str
budget: float | None
bom_items: list[BOMItemExport] = []
linked_folders: list[LinkedFolderExport] = []
class ProjectImport(BaseModel):
"""Schema for importing a project."""
name: str
description: str | None = None
color: str | None = None
status: str = "active"
target_count: int | None = None
target_parts_count: int | None = None
target_sets: int | None = None
notes: str | None = None
tags: str | None = None
due_date: datetime | None = None
priority: str = "normal"
budget: float | None = None
bom_items: list[BOMItemExport] = []
linked_folders: list[LinkedFolderExport] = []