Files
bambuddy/backend/app/schemas/project.py
maziggy c640ddc1f7 fix(projects): carry tags, due date and priority in the list payload (#2536)
The edit dialog is shared between the projects list and the project detail
page and seeds itself from whichever project object it is handed. The list
payload never carried tags, due_date or priority, so editing from the list
showed a blank tags field -- and, unreported, submitted the dialog's default
priority over a stored high/urgent one. The component read those fields
through a cast, so the compiler never flagged that they were always absent.

Put them on ProjectListResponse and ProjectListItem, drop the casts, and let
an explicit null clear tags and due date the way it already clears budget and
url -- an emptied field was previously sent as undefined and silently reverted.
The template list was missing target_parts_count, which the same dialog edits.
2026-07-13 08:59:36 +02:00

304 lines
8.6 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
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
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
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):
"""Minimal project data for child preview."""
id: int
name: str
color: str | None
status: str
progress_percent: float | None = None
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
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] = []
created_at: datetime
updated_at: datetime
stats: ProjectStats | None = None
url: str | None = None
cover_image_filename: str | None = None
class Config:
from_attributes = True
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
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
# 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
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
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] = []