mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 11:56:01 +02:00
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.
337 lines
10 KiB
Python
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] = []
|