mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-17 11:25:48 +02:00
The folder tree's "sort by recent activity" and the file pane's date sort put external (mapped/NAS) files in a near-random order instead of ls -t's newest-first. Nothing captured the files' on-disk mtime: the sort keyed off the DB updated_at/created_at, which for a bulk external scan is the same scan instant for every row, so a whole block tied and sorted arbitrarily; only rows Bambuddy had later touched individually looked "partially right." The tree also bubbled up only immediate child-file activity, so a file added deep in a subtree never lifted its parent folders. - Add nullable fs_modified_at to LibraryFile and LibraryFolder (dialect- branched migration, mirroring the #2615 dispatching_at pattern). - External scan records each file's and directory's real os.stat().st_mtime and refreshes it on every re-scan, so a file edited over the mount re-sorts and existing installs backfill on the next scan. - list_folders computes each folder's activity as a recursive newest- descendant roll-up (post-order), so a fresh deep file lifts every ancestor. - Folder tree sort and the file pane's date sort now use the real mtime, falling back to created_at for managed uploads with none. - New toolbar toggle shows/hides each item's last-modified date in the right pane (grid + list), with strings in all locales. Store the mtime as naive UTC to match the other timestamp columns so activity comparisons never mix naive and aware values on either dialect. Covered by integration tests (mtime capture, re-scan refresh, deep-file recursive bubble, folder mtime) and a frontend test proving fs_modified_at is preferred over created_at.
400 lines
9.8 KiB
Python
400 lines
9.8 KiB
Python
"""Pydantic schemas for library (File Manager) functionality."""
|
|
|
|
from datetime import datetime
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
# ============ Folder Schemas ============
|
|
|
|
|
|
class FolderCreate(BaseModel):
|
|
"""Schema for creating a new folder."""
|
|
|
|
name: str = Field(..., min_length=1, max_length=255)
|
|
parent_id: int | None = None
|
|
project_id: int | None = None
|
|
archive_id: int | None = None
|
|
|
|
|
|
class ExternalFolderCreate(BaseModel):
|
|
"""Schema for linking an external folder."""
|
|
|
|
name: str = Field(..., min_length=1, max_length=255)
|
|
external_path: str = Field(..., min_length=1, max_length=500)
|
|
readonly: bool = True
|
|
show_hidden: bool = False
|
|
parent_id: int | None = None
|
|
|
|
|
|
class FolderUpdate(BaseModel):
|
|
"""Schema for updating a folder."""
|
|
|
|
name: str | None = Field(None, min_length=1, max_length=255)
|
|
parent_id: int | None = None
|
|
project_id: int | None = None # 0 to unlink
|
|
archive_id: int | None = None # 0 to unlink
|
|
|
|
|
|
class FolderResponse(BaseModel):
|
|
"""Schema for folder response."""
|
|
|
|
id: int
|
|
name: str
|
|
parent_id: int | None
|
|
project_id: int | None = None
|
|
archive_id: int | None = None
|
|
project_name: str | None = None
|
|
archive_name: str | None = None
|
|
is_external: bool = False
|
|
external_path: str | None = None
|
|
external_readonly: bool = False
|
|
external_show_hidden: bool = False
|
|
file_count: int = 0 # Computed field
|
|
# max(folder.updated_at, max(immediate-child file.updated_at)). Used by the
|
|
# File Manager folder tree's "sort by recent activity" mode (#1770) so that
|
|
# adding a file inside a folder bubbles it up — folder.updated_at alone only
|
|
# tracks rename/move events. Recursion across subfolders is intentionally
|
|
# left out to keep the route a single GROUP BY rather than a recursive CTE.
|
|
latest_activity_at: datetime | None = None
|
|
created_at: datetime
|
|
updated_at: datetime
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
class FolderReadmeResponse(BaseModel):
|
|
"""Markdown sidebar payload for a folder (#1268).
|
|
|
|
``filename`` is the on-disk name (so the UI can show "README.md") and
|
|
``content`` is the raw markdown — the FE renders it. ``truncated`` is
|
|
True when the source file was clipped at the size cap.
|
|
"""
|
|
|
|
filename: str
|
|
content: str
|
|
truncated: bool
|
|
|
|
|
|
class FolderTreeItem(BaseModel):
|
|
"""Schema for folder tree item (includes children)."""
|
|
|
|
id: int
|
|
name: str
|
|
parent_id: int | None
|
|
project_id: int | None = None
|
|
archive_id: int | None = None
|
|
project_name: str | None = None
|
|
archive_name: str | None = None
|
|
is_external: bool = False
|
|
external_path: str | None = None
|
|
external_readonly: bool = False
|
|
file_count: int = 0
|
|
# See FolderResponse.latest_activity_at — #1770 folder sort source.
|
|
latest_activity_at: datetime | None = None
|
|
children: list["FolderTreeItem"] = []
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
# ============ File Schemas ============
|
|
|
|
|
|
class FileCreate(BaseModel):
|
|
"""Schema for creating a file entry (internal use after upload)."""
|
|
|
|
filename: str
|
|
file_path: str
|
|
file_type: str
|
|
file_size: int
|
|
file_hash: str | None = None
|
|
thumbnail_path: str | None = None
|
|
metadata: dict | None = None
|
|
folder_id: int | None = None
|
|
project_id: int | None = None
|
|
|
|
|
|
class FileUpdate(BaseModel):
|
|
"""Schema for updating a file."""
|
|
|
|
filename: str | None = Field(None, min_length=1, max_length=255)
|
|
folder_id: int | None = None
|
|
project_id: int | None = None
|
|
notes: str | None = None
|
|
|
|
|
|
class FileDuplicate(BaseModel):
|
|
"""Reference to a duplicate file."""
|
|
|
|
id: int
|
|
filename: str
|
|
folder_id: int | None
|
|
folder_name: str | None
|
|
created_at: datetime
|
|
|
|
|
|
class FileResponse(BaseModel):
|
|
"""Schema for file response."""
|
|
|
|
id: int
|
|
folder_id: int | None
|
|
folder_name: str | None = None
|
|
project_id: int | None
|
|
project_name: str | None = None
|
|
is_external: bool = False
|
|
|
|
filename: str
|
|
file_path: str
|
|
file_type: str
|
|
file_size: int
|
|
file_hash: str | None
|
|
thumbnail_path: str | None
|
|
|
|
metadata: dict | None
|
|
|
|
print_count: int
|
|
last_printed_at: datetime | None
|
|
|
|
notes: str | None
|
|
|
|
# Duplicate detection
|
|
duplicates: list[FileDuplicate] | None = None
|
|
duplicate_count: int = 0
|
|
|
|
# User tracking (Issue #206)
|
|
created_by_id: int | None = None
|
|
created_by_username: str | None = None
|
|
|
|
created_at: datetime
|
|
updated_at: datetime
|
|
|
|
# Metadata fields
|
|
print_name: str | None = None
|
|
print_time_seconds: int | None = None
|
|
filament_used_grams: float | None = None
|
|
sliced_for_model: str | None = None
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
class TagSummary(BaseModel):
|
|
"""Compact tag projection — embedded in file listings (#1268)."""
|
|
|
|
id: int
|
|
name: str
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
class FileListResponse(BaseModel):
|
|
"""Schema for file list item (lighter than full response)."""
|
|
|
|
id: int
|
|
folder_id: int | None
|
|
is_external: bool = False
|
|
filename: str
|
|
file_type: str
|
|
file_size: int
|
|
thumbnail_path: str | None
|
|
print_count: int
|
|
duplicate_count: int = 0
|
|
# User tracking (Issue #206)
|
|
created_by_id: int | None = None
|
|
created_by_username: str | None = None
|
|
created_at: datetime
|
|
# Real on-disk modification time (#2680). Populated for external files from
|
|
# their filesystem mtime; null for managed uploads. The file pane's date sort
|
|
# and the "Modified" column use ``fs_modified_at ?? created_at``.
|
|
fs_modified_at: datetime | None = None
|
|
|
|
# Key metadata fields for display
|
|
print_name: str | None = None
|
|
print_time_seconds: int | None = None
|
|
filament_used_grams: float | None = None
|
|
sliced_for_model: str | None = None
|
|
|
|
# Tags assigned to this file (#1268). Empty list when the file has none —
|
|
# never null, so the FE can iterate without a guard.
|
|
tags: list[TagSummary] = []
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
# ============ Tag Schemas (#1268) ============
|
|
|
|
|
|
class TagResponse(BaseModel):
|
|
"""Tag with the count of files currently using it."""
|
|
|
|
id: int
|
|
name: str
|
|
file_count: int
|
|
created_at: datetime
|
|
updated_at: datetime
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
class TagCreate(BaseModel):
|
|
"""Create a new tag (catalog row)."""
|
|
|
|
name: str = Field(..., min_length=1, max_length=64)
|
|
|
|
|
|
class TagUpdate(BaseModel):
|
|
"""Rename a tag. ``name`` is required — there's nothing else to update."""
|
|
|
|
name: str = Field(..., min_length=1, max_length=64)
|
|
|
|
|
|
class TagBulkAssignRequest(BaseModel):
|
|
"""Bulk tag assignment payload.
|
|
|
|
``action='add'`` → append tags to every listed file (idempotent on dup).
|
|
``action='remove'`` → strip the listed tags from every listed file.
|
|
``action='replace'`` → REPLACE the tag set on every listed file with the
|
|
exact set in ``tag_ids`` (omitting tag_ids clears
|
|
them all).
|
|
"""
|
|
|
|
file_ids: list[int] = Field(..., min_length=1)
|
|
tag_ids: list[int] = Field(default_factory=list)
|
|
action: str = Field("add", pattern="^(add|remove|replace)$")
|
|
|
|
|
|
class TagBulkAssignResponse(BaseModel):
|
|
"""Result of a bulk-assign call."""
|
|
|
|
files_updated: int
|
|
associations_added: int
|
|
associations_removed: int
|
|
|
|
|
|
class FileMoveRequest(BaseModel):
|
|
"""Schema for moving files to a folder."""
|
|
|
|
file_ids: list[int]
|
|
folder_id: int | None = None # None = move to root
|
|
|
|
|
|
class FileUploadResponse(BaseModel):
|
|
"""Schema for file upload response."""
|
|
|
|
id: int
|
|
filename: str
|
|
file_type: str
|
|
file_size: int
|
|
thumbnail_path: str | None
|
|
duplicate_of: int | None = None # ID of existing file with same hash
|
|
metadata: dict | None = None
|
|
|
|
|
|
# ============ Bulk Operations ============
|
|
|
|
|
|
class BulkDeleteRequest(BaseModel):
|
|
"""Schema for bulk delete operations."""
|
|
|
|
file_ids: list[int] = []
|
|
folder_ids: list[int] = []
|
|
|
|
|
|
class BulkDeleteResponse(BaseModel):
|
|
"""Schema for bulk delete response."""
|
|
|
|
deleted_files: int
|
|
deleted_folders: int
|
|
|
|
|
|
# ============ Queue Operations ============
|
|
|
|
|
|
class AddToQueueRequest(BaseModel):
|
|
"""Schema for adding library files to the print queue."""
|
|
|
|
file_ids: list[int] = Field(..., min_length=1)
|
|
|
|
|
|
class AddToQueueResult(BaseModel):
|
|
"""Result for a single file added to queue."""
|
|
|
|
file_id: int
|
|
filename: str
|
|
queue_item_id: int
|
|
|
|
|
|
class AddToQueueError(BaseModel):
|
|
"""Error for a file that couldn't be added to queue."""
|
|
|
|
file_id: int
|
|
filename: str
|
|
error: str
|
|
|
|
|
|
class AddToQueueResponse(BaseModel):
|
|
"""Schema for add-to-queue response."""
|
|
|
|
added: list[AddToQueueResult]
|
|
errors: list[AddToQueueError]
|
|
|
|
|
|
# ============ ZIP Extraction ============
|
|
|
|
|
|
class ZipExtractResult(BaseModel):
|
|
"""Result for a single file extracted from ZIP."""
|
|
|
|
filename: str
|
|
file_id: int
|
|
folder_id: int | None = None
|
|
|
|
|
|
class ZipExtractError(BaseModel):
|
|
"""Error for a file that couldn't be extracted."""
|
|
|
|
filename: str
|
|
error: str
|
|
|
|
|
|
class ZipExtractResponse(BaseModel):
|
|
"""Schema for ZIP extraction response."""
|
|
|
|
extracted: int
|
|
folders_created: int
|
|
files: list[ZipExtractResult]
|
|
errors: list[ZipExtractError]
|
|
|
|
|
|
# ============ STL Thumbnail Generation ============
|
|
|
|
|
|
class BatchThumbnailRequest(BaseModel):
|
|
"""Schema for batch STL thumbnail generation request."""
|
|
|
|
file_ids: list[int] | None = None
|
|
folder_id: int | None = None
|
|
all_missing: bool = False
|
|
|
|
|
|
class BatchThumbnailResult(BaseModel):
|
|
"""Result for a single file thumbnail generation."""
|
|
|
|
file_id: int
|
|
filename: str
|
|
success: bool
|
|
error: str | None = None
|
|
|
|
|
|
class BatchThumbnailResponse(BaseModel):
|
|
"""Schema for batch thumbnail generation response."""
|
|
|
|
processed: int
|
|
succeeded: int
|
|
failed: int
|
|
results: list[BatchThumbnailResult]
|