|
@@ -12,23 +12,25 @@ import re
|
|
|
import shutil
|
|
import shutil
|
|
|
import uuid
|
|
import uuid
|
|
|
import zipfile
|
|
import zipfile
|
|
|
|
|
+from concurrent.futures import ThreadPoolExecutor
|
|
|
from datetime import datetime, timezone
|
|
from datetime import datetime, timezone
|
|
|
from pathlib import Path
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile
|
|
from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile
|
|
|
from fastapi.responses import FileResponse as FastAPIFileResponse
|
|
from fastapi.responses import FileResponse as FastAPIFileResponse
|
|
|
-from sqlalchemy import distinct, func, select, update
|
|
|
|
|
|
|
+from sqlalchemy import delete, distinct, func, select, update
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
from sqlalchemy.orm import selectinload
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
|
|
|
|
from backend.app.api.routes.cloud import resolve_api_key_cloud_owner
|
|
from backend.app.api.routes.cloud import resolve_api_key_cloud_owner
|
|
|
from backend.app.api.routes.library_variants import normalize_model_name, resolve_variant_model
|
|
from backend.app.api.routes.library_variants import normalize_model_name, resolve_variant_model
|
|
|
-from backend.app.api.routes.print_queue import _extract_filament_types_from_3mf
|
|
|
|
|
|
|
+from backend.app.api.routes.print_queue import _extract_filament_types_from_3mf, printed_plate_of_multi_plate_file
|
|
|
from backend.app.core.auth import (
|
|
from backend.app.core.auth import (
|
|
|
ApiKeyActor,
|
|
ApiKeyActor,
|
|
|
QueueReviewRequired,
|
|
QueueReviewRequired,
|
|
|
RequestActor,
|
|
RequestActor,
|
|
|
RequestPrinterScope,
|
|
RequestPrinterScope,
|
|
|
|
|
+ require_actor_still_exists,
|
|
|
require_media_token_ownership,
|
|
require_media_token_ownership,
|
|
|
require_ownership_permission,
|
|
require_ownership_permission,
|
|
|
require_permission_if_auth_enabled,
|
|
require_permission_if_auth_enabled,
|
|
@@ -74,7 +76,7 @@ from backend.app.schemas.library import (
|
|
|
ZipExtractResult,
|
|
ZipExtractResult,
|
|
|
)
|
|
)
|
|
|
from backend.app.schemas.slicer import SliceRequest, SliceResponse
|
|
from backend.app.schemas.slicer import SliceRequest, SliceResponse
|
|
|
-from backend.app.services.archive import ThreeMFParser
|
|
|
|
|
|
|
+from backend.app.services.archive import ThreeMFParser, estimate_filament_cost
|
|
|
from backend.app.services.design_settings import (
|
|
from backend.app.services.design_settings import (
|
|
|
DesignOverride,
|
|
DesignOverride,
|
|
|
apply_design_overrides,
|
|
apply_design_overrides,
|
|
@@ -96,7 +98,9 @@ from backend.app.services.library_folder_access import (
|
|
|
from backend.app.services.pdf_thumbnail import generate_pdf_thumbnail
|
|
from backend.app.services.pdf_thumbnail import generate_pdf_thumbnail
|
|
|
from backend.app.services.plate_thumbnail import inject_plate_thumbnails_if_missing
|
|
from backend.app.services.plate_thumbnail import inject_plate_thumbnails_if_missing
|
|
|
from backend.app.services.print_confirmation import confirm_outcome_for_new_queue_item
|
|
from backend.app.services.print_confirmation import confirm_outcome_for_new_queue_item
|
|
|
|
|
+from backend.app.services.print_defaults import default_print_options
|
|
|
from backend.app.services.process_overrides import apply_process_overrides
|
|
from backend.app.services.process_overrides import apply_process_overrides
|
|
|
|
|
+from backend.app.services.queue_announcements import announce_queue_jobs_added
|
|
|
from backend.app.services.slice_output_check import (
|
|
from backend.app.services.slice_output_check import (
|
|
|
missing_start_gcode_message,
|
|
missing_start_gcode_message,
|
|
|
start_gcode_is_missing,
|
|
start_gcode_is_missing,
|
|
@@ -135,7 +139,7 @@ _PROJECT_SETTINGS_PATH = "Metadata/project_settings.config"
|
|
|
|
|
|
|
|
def _ensure_library_file_visible(
|
|
def _ensure_library_file_visible(
|
|
|
library_file: LibraryFile | None,
|
|
library_file: LibraryFile | None,
|
|
|
- user: User | None,
|
|
|
|
|
|
|
+ user: User | ApiKeyActor | None,
|
|
|
can_read_all: bool,
|
|
can_read_all: bool,
|
|
|
) -> LibraryFile:
|
|
) -> LibraryFile:
|
|
|
"""Per-file visibility gate for ownership-scoped LIBRARY reads (#1726-adjacent).
|
|
"""Per-file visibility gate for ownership-scoped LIBRARY reads (#1726-adjacent).
|
|
@@ -159,7 +163,28 @@ def _ensure_library_file_visible(
|
|
|
return library_file
|
|
return library_file
|
|
|
|
|
|
|
|
|
|
|
|
|
-def _own_files_filter(user: User | None) -> list:
|
|
|
|
|
|
|
+async def _linkable_archives(db: AsyncSession, archive_ids, printer_scope: PrinterScope) -> dict[int, str | None]:
|
|
|
|
|
+ """Print names of the archives among ``archive_ids`` on the caller's printers.
|
|
|
|
|
+
|
|
|
|
|
+ A folder's link to any other archive reads as no link, and linking one
|
|
|
|
|
+ answers as for a missing archive.
|
|
|
|
|
+ """
|
|
|
|
|
+ ids = {i for i in archive_ids if i}
|
|
|
|
|
+ if not ids:
|
|
|
|
|
+ return {}
|
|
|
|
|
+ query = select(PrintArchive.id, PrintArchive.print_name).where(PrintArchive.id.in_(ids))
|
|
|
|
|
+ if (clause := printer_scope.where(PrintArchive.printer_id)) is not None:
|
|
|
|
|
+ query = query.where(clause)
|
|
|
|
|
+ return dict((await db.execute(query)).all())
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _archive_link(archive_id: int | None, archives: dict[int, str | None]) -> dict:
|
|
|
|
|
+ if archive_id not in archives:
|
|
|
|
|
+ return {"archive_id": None, "archive_name": None}
|
|
|
|
|
+ return {"archive_id": archive_id, "archive_name": archives[archive_id]}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _own_files_filter(user: User | ApiKeyActor | None) -> list:
|
|
|
"""Extra WHERE terms that count only ``user``'s files, for a read_own user.
|
|
"""Extra WHERE terms that count only ``user``'s files, for a read_own user.
|
|
|
|
|
|
|
|
Folder file counts and activity times must not reveal files the user
|
|
Folder file counts and activity times must not reveal files the user
|
|
@@ -170,7 +195,7 @@ def _own_files_filter(user: User | None) -> list:
|
|
|
return [LibraryFile.created_by_id == user.id]
|
|
return [LibraryFile.created_by_id == user.id]
|
|
|
|
|
|
|
|
|
|
|
|
|
-async def _load_index(db: AsyncSession, user: User | None) -> FolderIndex:
|
|
|
|
|
|
|
+async def _load_index(db: AsyncSession, user: User | ApiKeyActor | None) -> FolderIndex:
|
|
|
"""The folder index a response for ``user`` needs (#3201).
|
|
"""The folder index a response for ``user`` needs (#3201).
|
|
|
|
|
|
|
|
Who owns which file is needed only to decide what a read_own user sees
|
|
Who owns which file is needed only to decide what a read_own user sees
|
|
@@ -183,7 +208,7 @@ async def _load_index(db: AsyncSession, user: User | None) -> FolderIndex:
|
|
|
return await load_folder_index(db, with_files=needs_files)
|
|
return await load_folder_index(db, with_files=needs_files)
|
|
|
|
|
|
|
|
|
|
|
|
|
-def _folder_access_fields(index: FolderIndex, folder: LibraryFolder, user: User | None) -> dict:
|
|
|
|
|
|
|
+def _folder_access_fields(index: FolderIndex, folder: LibraryFolder, user: User | ApiKeyActor | None) -> dict:
|
|
|
"""The ownership fields of a folder response, for the user asking (#3201)."""
|
|
"""The ownership fields of a folder response, for the user asking (#3201)."""
|
|
|
return {
|
|
return {
|
|
|
"created_by_id": folder.created_by_id,
|
|
"created_by_id": folder.created_by_id,
|
|
@@ -734,9 +759,7 @@ async def save_3mf_bytes_to_library(
|
|
|
# a triangle.
|
|
# a triangle.
|
|
|
try:
|
|
try:
|
|
|
if file_path.stat().st_size >= MIN_USABLE_STL_BYTES:
|
|
if file_path.stat().st_size >= MIN_USABLE_STL_BYTES:
|
|
|
- thumbnail_path = await asyncio.to_thread(
|
|
|
|
|
- generate_stl_thumbnail, file_path, get_library_thumbnails_dir()
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ thumbnail_path = await _render_stl_thumbnail(file_path, get_library_thumbnails_dir())
|
|
|
except Exception as exc: # noqa: BLE001 — a thumbnail must never fail the import
|
|
except Exception as exc: # noqa: BLE001 — a thumbnail must never fail the import
|
|
|
logger.warning("Failed to render STL thumbnail for %s: %s", filename, exc)
|
|
logger.warning("Failed to render STL thumbnail for %s: %s", filename, exc)
|
|
|
|
|
|
|
@@ -911,6 +934,19 @@ MAX_CLIENT_THUMBNAIL_EDGE = 2048
|
|
|
STORED_CLIENT_THUMBNAIL_EDGE = 512
|
|
STORED_CLIENT_THUMBNAIL_EDGE = 512
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+# STL thumbnails get their own single worker thread. They are made one at a
|
|
|
|
|
+# time anyway (stl_thumbnail._RENDER_LOCK), and in the shared default pool the
|
|
|
|
|
+# ones waiting their turn would hold threads that every other to_thread call in
|
|
|
|
|
+# the app needs: a burst of STL uploads would stall them all.
|
|
|
|
|
+_STL_THUMBNAIL_EXECUTOR = ThreadPoolExecutor(max_workers=1, thread_name_prefix="stl-thumbnail")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def _render_stl_thumbnail(stl_path: Path, thumbnails_dir: Path) -> str | None:
|
|
|
|
|
+ """generate_stl_thumbnail, off the event loop: a large mesh takes seconds."""
|
|
|
|
|
+ loop = asyncio.get_running_loop()
|
|
|
|
|
+ return await loop.run_in_executor(_STL_THUMBNAIL_EXECUTOR, generate_stl_thumbnail, stl_path, thumbnails_dir)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
async def _backfill_external_thumbnails(folder_ids: list[int]) -> None:
|
|
async def _backfill_external_thumbnails(folder_ids: list[int]) -> None:
|
|
|
"""Generate STL and PDF thumbnails for an external folder tree in the background.
|
|
"""Generate STL and PDF thumbnails for an external folder tree in the background.
|
|
|
|
|
|
|
@@ -969,7 +1005,7 @@ async def _backfill_external_thumbnails(folder_ids: list[int]) -> None:
|
|
|
except OSError:
|
|
except OSError:
|
|
|
continue
|
|
continue
|
|
|
try:
|
|
try:
|
|
|
- thumb_path = generate_stl_thumbnail(abs_path, thumbnails_dir)
|
|
|
|
|
|
|
+ thumb_path = await _render_stl_thumbnail(abs_path, thumbnails_dir)
|
|
|
except Exception as exc: # noqa: BLE001 — never let one bad STL kill the rest
|
|
except Exception as exc: # noqa: BLE001 — never let one bad STL kill the rest
|
|
|
logger.debug("STL thumbnail backfill skipped %s: %s", abs_path, exc)
|
|
logger.debug("STL thumbnail backfill skipped %s: %s", abs_path, exc)
|
|
|
continue
|
|
continue
|
|
@@ -986,12 +1022,13 @@ async def _backfill_external_thumbnails(folder_ids: list[int]) -> None:
|
|
|
async def list_folders(
|
|
async def list_folders(
|
|
|
response: Response,
|
|
response: Response,
|
|
|
db: AsyncSession = Depends(get_db),
|
|
db: AsyncSession = Depends(get_db),
|
|
|
- auth_result: tuple[User | None, bool] = Depends(
|
|
|
|
|
|
|
+ auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
|
|
|
require_ownership_permission(
|
|
require_ownership_permission(
|
|
|
Permission.LIBRARY_READ_ALL,
|
|
Permission.LIBRARY_READ_ALL,
|
|
|
Permission.LIBRARY_READ_OWN,
|
|
Permission.LIBRARY_READ_OWN,
|
|
|
)
|
|
)
|
|
|
),
|
|
),
|
|
|
|
|
+ printer_scope: PrinterScope = RequestPrinterScope,
|
|
|
):
|
|
):
|
|
|
"""Get all folders as a tree structure.
|
|
"""Get all folders as a tree structure.
|
|
|
|
|
|
|
@@ -1007,12 +1044,12 @@ async def list_folders(
|
|
|
|
|
|
|
|
# Get all folders with project and archive joins
|
|
# Get all folders with project and archive joins
|
|
|
result = await db.execute(
|
|
result = await db.execute(
|
|
|
- select(LibraryFolder, Project.name, PrintArchive.print_name)
|
|
|
|
|
|
|
+ select(LibraryFolder, Project.name)
|
|
|
.outerjoin(Project, LibraryFolder.project_id == Project.id)
|
|
.outerjoin(Project, LibraryFolder.project_id == Project.id)
|
|
|
- .outerjoin(PrintArchive, LibraryFolder.archive_id == PrintArchive.id)
|
|
|
|
|
.order_by(LibraryFolder.name)
|
|
.order_by(LibraryFolder.name)
|
|
|
)
|
|
)
|
|
|
rows = [row for row in result.all() if visible is None or row[0].id in visible]
|
|
rows = [row for row in result.all() if visible is None or row[0].id in visible]
|
|
|
|
|
+ archives = await _linkable_archives(db, [row[0].archive_id for row in rows], printer_scope)
|
|
|
own_files = _own_files_filter(user)
|
|
own_files = _own_files_filter(user)
|
|
|
|
|
|
|
|
# Get file counts per folder
|
|
# Get file counts per folder
|
|
@@ -1046,7 +1083,7 @@ async def list_folders(
|
|
|
folder_map = {}
|
|
folder_map = {}
|
|
|
root_folders = []
|
|
root_folders = []
|
|
|
|
|
|
|
|
- for folder, project_name, archive_name in rows:
|
|
|
|
|
|
|
+ for folder, project_name in rows:
|
|
|
own_activity = folder.fs_modified_at or folder.updated_at
|
|
own_activity = folder.fs_modified_at or folder.updated_at
|
|
|
latest_file = latest_file_activity.get(folder.id)
|
|
latest_file = latest_file_activity.get(folder.id)
|
|
|
if latest_file is not None and latest_file > own_activity:
|
|
if latest_file is not None and latest_file > own_activity:
|
|
@@ -1056,9 +1093,8 @@ async def list_folders(
|
|
|
name=folder.name,
|
|
name=folder.name,
|
|
|
parent_id=folder.parent_id,
|
|
parent_id=folder.parent_id,
|
|
|
project_id=folder.project_id,
|
|
project_id=folder.project_id,
|
|
|
- archive_id=folder.archive_id,
|
|
|
|
|
project_name=project_name,
|
|
project_name=project_name,
|
|
|
- archive_name=archive_name,
|
|
|
|
|
|
|
+ **_archive_link(folder.archive_id, archives),
|
|
|
is_external=folder.is_external,
|
|
is_external=folder.is_external,
|
|
|
external_path=folder.external_path,
|
|
external_path=folder.external_path,
|
|
|
external_readonly=folder.external_readonly,
|
|
external_readonly=folder.external_readonly,
|
|
@@ -1070,7 +1106,7 @@ async def list_folders(
|
|
|
folder_map[folder.id] = folder_item
|
|
folder_map[folder.id] = folder_item
|
|
|
|
|
|
|
|
# Link children to parents
|
|
# Link children to parents
|
|
|
- for folder, _, _ in rows:
|
|
|
|
|
|
|
+ for folder, _ in rows:
|
|
|
folder_item = folder_map[folder.id]
|
|
folder_item = folder_map[folder.id]
|
|
|
if folder.parent_id is None:
|
|
if folder.parent_id is None:
|
|
|
root_folders.append(folder_item)
|
|
root_folders.append(folder_item)
|
|
@@ -1106,12 +1142,13 @@ async def list_folders(
|
|
|
async def get_folders_by_project(
|
|
async def get_folders_by_project(
|
|
|
project_id: int,
|
|
project_id: int,
|
|
|
db: AsyncSession = Depends(get_db),
|
|
db: AsyncSession = Depends(get_db),
|
|
|
- auth_result: tuple[User | None, bool] = Depends(
|
|
|
|
|
|
|
+ auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
|
|
|
require_ownership_permission(
|
|
require_ownership_permission(
|
|
|
Permission.LIBRARY_READ_ALL,
|
|
Permission.LIBRARY_READ_ALL,
|
|
|
Permission.LIBRARY_READ_OWN,
|
|
Permission.LIBRARY_READ_OWN,
|
|
|
)
|
|
)
|
|
|
),
|
|
),
|
|
|
|
|
+ printer_scope: PrinterScope = RequestPrinterScope,
|
|
|
):
|
|
):
|
|
|
"""Get all folders linked to a specific project, those the user may see (#3201)."""
|
|
"""Get all folders linked to a specific project, those the user may see (#3201)."""
|
|
|
user, _ = auth_result
|
|
user, _ = auth_result
|
|
@@ -1125,6 +1162,7 @@ async def get_folders_by_project(
|
|
|
.order_by(LibraryFolder.name)
|
|
.order_by(LibraryFolder.name)
|
|
|
)
|
|
)
|
|
|
rows = [row for row in result.all() if visible is None or row[0].id in visible]
|
|
rows = [row for row in result.all() if visible is None or row[0].id in visible]
|
|
|
|
|
+ archives = await _linkable_archives(db, [row[0].archive_id for row in rows], printer_scope)
|
|
|
|
|
|
|
|
folders = []
|
|
folders = []
|
|
|
for folder, project_name in rows:
|
|
for folder, project_name in rows:
|
|
@@ -1151,9 +1189,8 @@ async def get_folders_by_project(
|
|
|
name=folder.name,
|
|
name=folder.name,
|
|
|
parent_id=folder.parent_id,
|
|
parent_id=folder.parent_id,
|
|
|
project_id=folder.project_id,
|
|
project_id=folder.project_id,
|
|
|
- archive_id=folder.archive_id,
|
|
|
|
|
project_name=project_name,
|
|
project_name=project_name,
|
|
|
- archive_name=None,
|
|
|
|
|
|
|
+ **_archive_link(folder.archive_id, archives),
|
|
|
is_external=folder.is_external,
|
|
is_external=folder.is_external,
|
|
|
external_path=folder.external_path,
|
|
external_path=folder.external_path,
|
|
|
external_readonly=folder.external_readonly,
|
|
external_readonly=folder.external_readonly,
|
|
@@ -1173,28 +1210,29 @@ async def get_folders_by_project(
|
|
|
async def get_folders_by_archive(
|
|
async def get_folders_by_archive(
|
|
|
archive_id: int,
|
|
archive_id: int,
|
|
|
db: AsyncSession = Depends(get_db),
|
|
db: AsyncSession = Depends(get_db),
|
|
|
- auth_result: tuple[User | None, bool] = Depends(
|
|
|
|
|
|
|
+ auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
|
|
|
require_ownership_permission(
|
|
require_ownership_permission(
|
|
|
Permission.LIBRARY_READ_ALL,
|
|
Permission.LIBRARY_READ_ALL,
|
|
|
Permission.LIBRARY_READ_OWN,
|
|
Permission.LIBRARY_READ_OWN,
|
|
|
)
|
|
)
|
|
|
),
|
|
),
|
|
|
|
|
+ printer_scope: PrinterScope = RequestPrinterScope,
|
|
|
):
|
|
):
|
|
|
"""Get all folders linked to a specific archive, those the user may see (#3201)."""
|
|
"""Get all folders linked to a specific archive, those the user may see (#3201)."""
|
|
|
user, _ = auth_result
|
|
user, _ = auth_result
|
|
|
|
|
+ archives = await _linkable_archives(db, [archive_id], printer_scope)
|
|
|
|
|
+ if archive_id not in archives:
|
|
|
|
|
+ return []
|
|
|
index = await _load_index(db, user)
|
|
index = await _load_index(db, user)
|
|
|
visible = visible_folder_ids(index, user)
|
|
visible = visible_folder_ids(index, user)
|
|
|
own_files = _own_files_filter(user)
|
|
own_files = _own_files_filter(user)
|
|
|
result = await db.execute(
|
|
result = await db.execute(
|
|
|
- select(LibraryFolder, PrintArchive.print_name)
|
|
|
|
|
- .outerjoin(PrintArchive, LibraryFolder.archive_id == PrintArchive.id)
|
|
|
|
|
- .where(LibraryFolder.archive_id == archive_id)
|
|
|
|
|
- .order_by(LibraryFolder.name)
|
|
|
|
|
|
|
+ select(LibraryFolder).where(LibraryFolder.archive_id == archive_id).order_by(LibraryFolder.name)
|
|
|
)
|
|
)
|
|
|
- rows = [row for row in result.all() if visible is None or row[0].id in visible]
|
|
|
|
|
|
|
+ rows = [folder for folder in result.scalars().all() if visible is None or folder.id in visible]
|
|
|
|
|
|
|
|
folders = []
|
|
folders = []
|
|
|
- for folder, archive_name in rows:
|
|
|
|
|
|
|
+ for folder in rows:
|
|
|
# Get file count + latest file activity (#1770/#2680) in one trip. Prefer
|
|
# Get file count + latest file activity (#1770/#2680) in one trip. Prefer
|
|
|
# the real on-disk mtime (external scans), fall back to the DB updated_at.
|
|
# the real on-disk mtime (external scans), fall back to the DB updated_at.
|
|
|
agg_result = await db.execute(
|
|
agg_result = await db.execute(
|
|
@@ -1218,9 +1256,8 @@ async def get_folders_by_archive(
|
|
|
name=folder.name,
|
|
name=folder.name,
|
|
|
parent_id=folder.parent_id,
|
|
parent_id=folder.parent_id,
|
|
|
project_id=folder.project_id,
|
|
project_id=folder.project_id,
|
|
|
- archive_id=folder.archive_id,
|
|
|
|
|
project_name=None,
|
|
project_name=None,
|
|
|
- archive_name=archive_name,
|
|
|
|
|
|
|
+ **_archive_link(folder.archive_id, archives),
|
|
|
is_external=folder.is_external,
|
|
is_external=folder.is_external,
|
|
|
external_path=folder.external_path,
|
|
external_path=folder.external_path,
|
|
|
external_readonly=folder.external_readonly,
|
|
external_readonly=folder.external_readonly,
|
|
@@ -1243,6 +1280,7 @@ async def create_folder(
|
|
|
db: AsyncSession = Depends(get_db),
|
|
db: AsyncSession = Depends(get_db),
|
|
|
current_user: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPLOAD)),
|
|
current_user: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPLOAD)),
|
|
|
actor: User | ApiKeyActor | None = RequestActor,
|
|
actor: User | ApiKeyActor | None = RequestActor,
|
|
|
|
|
+ printer_scope: PrinterScope = RequestPrinterScope,
|
|
|
):
|
|
):
|
|
|
"""Create a new folder, owned by the user who makes it (#3201)."""
|
|
"""Create a new folder, owned by the user who makes it (#3201)."""
|
|
|
# A read_own user may only create inside their own or a shared folder.
|
|
# A read_own user may only create inside their own or a shared folder.
|
|
@@ -1259,13 +1297,9 @@ async def create_folder(
|
|
|
project_name = project.name
|
|
project_name = project.name
|
|
|
|
|
|
|
|
# Verify archive exists if specified
|
|
# Verify archive exists if specified
|
|
|
- archive_name = None
|
|
|
|
|
- if data.archive_id is not None:
|
|
|
|
|
- archive_result = await db.execute(select(PrintArchive).where(PrintArchive.id == data.archive_id))
|
|
|
|
|
- archive = archive_result.scalar_one_or_none()
|
|
|
|
|
- if not archive:
|
|
|
|
|
- raise HTTPException(status_code=404, detail="Archive not found")
|
|
|
|
|
- archive_name = archive.print_name
|
|
|
|
|
|
|
+ archives = await _linkable_archives(db, [data.archive_id], printer_scope)
|
|
|
|
|
+ if data.archive_id is not None and data.archive_id not in archives:
|
|
|
|
|
+ raise HTTPException(status_code=404, detail="Archive not found")
|
|
|
|
|
|
|
|
folder = LibraryFolder(
|
|
folder = LibraryFolder(
|
|
|
name=data.name,
|
|
name=data.name,
|
|
@@ -1286,9 +1320,8 @@ async def create_folder(
|
|
|
name=folder.name,
|
|
name=folder.name,
|
|
|
parent_id=folder.parent_id,
|
|
parent_id=folder.parent_id,
|
|
|
project_id=folder.project_id,
|
|
project_id=folder.project_id,
|
|
|
- archive_id=folder.archive_id,
|
|
|
|
|
project_name=project_name,
|
|
project_name=project_name,
|
|
|
- archive_name=archive_name,
|
|
|
|
|
|
|
+ **_archive_link(folder.archive_id, archives),
|
|
|
is_external=folder.is_external,
|
|
is_external=folder.is_external,
|
|
|
external_path=folder.external_path,
|
|
external_path=folder.external_path,
|
|
|
external_readonly=folder.external_readonly,
|
|
external_readonly=folder.external_readonly,
|
|
@@ -1307,12 +1340,13 @@ async def create_folder(
|
|
|
async def get_folder(
|
|
async def get_folder(
|
|
|
folder_id: int,
|
|
folder_id: int,
|
|
|
db: AsyncSession = Depends(get_db),
|
|
db: AsyncSession = Depends(get_db),
|
|
|
- auth_result: tuple[User | None, bool] = Depends(
|
|
|
|
|
|
|
+ auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
|
|
|
require_ownership_permission(
|
|
require_ownership_permission(
|
|
|
Permission.LIBRARY_READ_ALL,
|
|
Permission.LIBRARY_READ_ALL,
|
|
|
Permission.LIBRARY_READ_OWN,
|
|
Permission.LIBRARY_READ_OWN,
|
|
|
)
|
|
)
|
|
|
),
|
|
),
|
|
|
|
|
+ printer_scope: PrinterScope = RequestPrinterScope,
|
|
|
):
|
|
):
|
|
|
"""Get a folder by ID; 404 for a folder the user may not see (#3201)."""
|
|
"""Get a folder by ID; 404 for a folder the user may not see (#3201)."""
|
|
|
user, _ = auth_result
|
|
user, _ = auth_result
|
|
@@ -1321,9 +1355,8 @@ async def get_folder(
|
|
|
if visible is not None and folder_id not in visible:
|
|
if visible is not None and folder_id not in visible:
|
|
|
raise HTTPException(status_code=404, detail="Folder not found")
|
|
raise HTTPException(status_code=404, detail="Folder not found")
|
|
|
result = await db.execute(
|
|
result = await db.execute(
|
|
|
- select(LibraryFolder, Project.name, PrintArchive.print_name)
|
|
|
|
|
|
|
+ select(LibraryFolder, Project.name)
|
|
|
.outerjoin(Project, LibraryFolder.project_id == Project.id)
|
|
.outerjoin(Project, LibraryFolder.project_id == Project.id)
|
|
|
- .outerjoin(PrintArchive, LibraryFolder.archive_id == PrintArchive.id)
|
|
|
|
|
.where(LibraryFolder.id == folder_id)
|
|
.where(LibraryFolder.id == folder_id)
|
|
|
)
|
|
)
|
|
|
row = result.one_or_none()
|
|
row = result.one_or_none()
|
|
@@ -1331,7 +1364,8 @@ async def get_folder(
|
|
|
if not row:
|
|
if not row:
|
|
|
raise HTTPException(status_code=404, detail="Folder not found")
|
|
raise HTTPException(status_code=404, detail="Folder not found")
|
|
|
|
|
|
|
|
- folder, project_name, archive_name = row
|
|
|
|
|
|
|
+ folder, project_name = row
|
|
|
|
|
+ archives = await _linkable_archives(db, [folder.archive_id], printer_scope)
|
|
|
|
|
|
|
|
# Get file count + latest file activity (#1770) in one trip
|
|
# Get file count + latest file activity (#1770) in one trip
|
|
|
agg_result = await db.execute(
|
|
agg_result = await db.execute(
|
|
@@ -1353,9 +1387,8 @@ async def get_folder(
|
|
|
name=folder.name,
|
|
name=folder.name,
|
|
|
parent_id=folder.parent_id,
|
|
parent_id=folder.parent_id,
|
|
|
project_id=folder.project_id,
|
|
project_id=folder.project_id,
|
|
|
- archive_id=folder.archive_id,
|
|
|
|
|
project_name=project_name,
|
|
project_name=project_name,
|
|
|
- archive_name=archive_name,
|
|
|
|
|
|
|
+ **_archive_link(folder.archive_id, archives),
|
|
|
is_external=folder.is_external,
|
|
is_external=folder.is_external,
|
|
|
external_path=folder.external_path,
|
|
external_path=folder.external_path,
|
|
|
external_readonly=folder.external_readonly,
|
|
external_readonly=folder.external_readonly,
|
|
@@ -1376,7 +1409,7 @@ _README_PREFERRED_STEMS = ("readme", "description")
|
|
|
async def get_folder_readme(
|
|
async def get_folder_readme(
|
|
|
folder_id: int,
|
|
folder_id: int,
|
|
|
db: AsyncSession = Depends(get_db),
|
|
db: AsyncSession = Depends(get_db),
|
|
|
- auth_result: tuple[User | None, bool] = Depends(
|
|
|
|
|
|
|
+ auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
|
|
|
require_ownership_permission(
|
|
require_ownership_permission(
|
|
|
Permission.LIBRARY_READ_ALL,
|
|
Permission.LIBRARY_READ_ALL,
|
|
|
Permission.LIBRARY_READ_OWN,
|
|
Permission.LIBRARY_READ_OWN,
|
|
@@ -1437,12 +1470,13 @@ async def update_folder(
|
|
|
folder_id: int,
|
|
folder_id: int,
|
|
|
data: FolderUpdate,
|
|
data: FolderUpdate,
|
|
|
db: AsyncSession = Depends(get_db),
|
|
db: AsyncSession = Depends(get_db),
|
|
|
- auth_result: tuple[User | None, bool] = Depends(
|
|
|
|
|
|
|
+ auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
|
|
|
require_ownership_permission(
|
|
require_ownership_permission(
|
|
|
Permission.LIBRARY_UPDATE_ALL,
|
|
Permission.LIBRARY_UPDATE_ALL,
|
|
|
Permission.LIBRARY_UPDATE_OWN,
|
|
Permission.LIBRARY_UPDATE_OWN,
|
|
|
)
|
|
)
|
|
|
),
|
|
),
|
|
|
|
|
+ printer_scope: PrinterScope = RequestPrinterScope,
|
|
|
):
|
|
):
|
|
|
"""Update a folder.
|
|
"""Update a folder.
|
|
|
|
|
|
|
@@ -1502,11 +1536,12 @@ async def update_folder(
|
|
|
# Update archive_id (0 to unlink)
|
|
# Update archive_id (0 to unlink)
|
|
|
if data.archive_id is not None:
|
|
if data.archive_id is not None:
|
|
|
if data.archive_id == 0:
|
|
if data.archive_id == 0:
|
|
|
- folder.archive_id = None
|
|
|
|
|
|
|
+ # A link the caller can't see is not theirs to remove.
|
|
|
|
|
+ if folder.archive_id in await _linkable_archives(db, [folder.archive_id], printer_scope):
|
|
|
|
|
+ folder.archive_id = None
|
|
|
else:
|
|
else:
|
|
|
# Verify archive exists
|
|
# Verify archive exists
|
|
|
- archive_result = await db.execute(select(PrintArchive).where(PrintArchive.id == data.archive_id))
|
|
|
|
|
- if not archive_result.scalar_one_or_none():
|
|
|
|
|
|
|
+ if data.archive_id not in await _linkable_archives(db, [data.archive_id], printer_scope):
|
|
|
raise HTTPException(status_code=404, detail="Archive not found")
|
|
raise HTTPException(status_code=404, detail="Archive not found")
|
|
|
folder.archive_id = data.archive_id
|
|
folder.archive_id = data.archive_id
|
|
|
|
|
|
|
@@ -1530,22 +1565,18 @@ async def update_folder(
|
|
|
|
|
|
|
|
# Get project and archive names
|
|
# Get project and archive names
|
|
|
project_name = None
|
|
project_name = None
|
|
|
- archive_name = None
|
|
|
|
|
if folder.project_id:
|
|
if folder.project_id:
|
|
|
project_result = await db.execute(select(Project.name).where(Project.id == folder.project_id))
|
|
project_result = await db.execute(select(Project.name).where(Project.id == folder.project_id))
|
|
|
project_name = project_result.scalar()
|
|
project_name = project_result.scalar()
|
|
|
- if folder.archive_id:
|
|
|
|
|
- archive_result = await db.execute(select(PrintArchive.print_name).where(PrintArchive.id == folder.archive_id))
|
|
|
|
|
- archive_name = archive_result.scalar()
|
|
|
|
|
|
|
+ archives = await _linkable_archives(db, [folder.archive_id], printer_scope)
|
|
|
|
|
|
|
|
return FolderResponse(
|
|
return FolderResponse(
|
|
|
id=folder.id,
|
|
id=folder.id,
|
|
|
name=folder.name,
|
|
name=folder.name,
|
|
|
parent_id=folder.parent_id,
|
|
parent_id=folder.parent_id,
|
|
|
project_id=folder.project_id,
|
|
project_id=folder.project_id,
|
|
|
- archive_id=folder.archive_id,
|
|
|
|
|
project_name=project_name,
|
|
project_name=project_name,
|
|
|
- archive_name=archive_name,
|
|
|
|
|
|
|
+ **_archive_link(folder.archive_id, archives),
|
|
|
is_external=folder.is_external,
|
|
is_external=folder.is_external,
|
|
|
external_path=folder.external_path,
|
|
external_path=folder.external_path,
|
|
|
external_readonly=folder.external_readonly,
|
|
external_readonly=folder.external_readonly,
|
|
@@ -1562,7 +1593,7 @@ async def update_folder(
|
|
|
async def delete_folder(
|
|
async def delete_folder(
|
|
|
folder_id: int,
|
|
folder_id: int,
|
|
|
db: AsyncSession = Depends(get_db),
|
|
db: AsyncSession = Depends(get_db),
|
|
|
- auth_result: tuple[User | None, bool] = Depends(
|
|
|
|
|
|
|
+ auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
|
|
|
require_ownership_permission(
|
|
require_ownership_permission(
|
|
|
Permission.LIBRARY_DELETE_ALL,
|
|
Permission.LIBRARY_DELETE_ALL,
|
|
|
Permission.LIBRARY_DELETE_OWN,
|
|
Permission.LIBRARY_DELETE_OWN,
|
|
@@ -2146,6 +2177,7 @@ async def scan_external_folder(
|
|
|
# every scan even though the file is still there. os.path.exists keeps
|
|
# every scan even though the file is still there. os.path.exists keeps
|
|
|
# such records; genuinely-deleted files (absent from disk) are still
|
|
# such records; genuinely-deleted files (absent from disk) are still
|
|
|
# cleaned up. External file_path is the absolute on-disk path.
|
|
# cleaned up. External file_path is the absolute on-disk path.
|
|
|
|
|
+ vanished_ids: list[int] = []
|
|
|
for path_str, db_file in existing_files.items():
|
|
for path_str, db_file in existing_files.items():
|
|
|
if path_str not in found_paths and not os.path.exists(path_str):
|
|
if path_str not in found_paths and not os.path.exists(path_str):
|
|
|
# Clean up thumbnail if we generated one
|
|
# Clean up thumbnail if we generated one
|
|
@@ -2160,8 +2192,17 @@ async def scan_external_folder(
|
|
|
# its photos go with it rather than being orphaned under an id
|
|
# its photos go with it rather than being orphaned under an id
|
|
|
# nothing points at any more (#3077).
|
|
# nothing points at any more (#3077).
|
|
|
remove_library_photos_dir(db_file.id)
|
|
remove_library_photos_dir(db_file.id)
|
|
|
- await db.delete(db_file)
|
|
|
|
|
- removed += 1
|
|
|
|
|
|
|
+ vanished_ids.append(db_file.id)
|
|
|
|
|
+ if vanished_ids:
|
|
|
|
|
+ # Same clean-up as every other hard delete of library rows, once for
|
|
|
|
|
+ # all of them: a share that lost thousands of files must not run it
|
|
|
|
|
+ # per file while holding the SQLite write lock.
|
|
|
|
|
+ from backend.app.services.library_trash import delete_dependent_variants, release_queue_references
|
|
|
|
|
+
|
|
|
|
|
+ await delete_dependent_variants(db, vanished_ids)
|
|
|
|
|
+ await release_queue_references(db, vanished_ids)
|
|
|
|
|
+ await db.execute(delete(LibraryFile).where(LibraryFile.id.in_(vanished_ids)))
|
|
|
|
|
+ removed += len(vanished_ids)
|
|
|
|
|
|
|
|
# Remove empty subfolders whose directories no longer exist on disk
|
|
# Remove empty subfolders whose directories no longer exist on disk
|
|
|
# Process deepest-first by sorting on path depth (descending)
|
|
# Process deepest-first by sorting on path depth (descending)
|
|
@@ -2231,7 +2272,7 @@ async def list_files(
|
|
|
recursive: bool = False,
|
|
recursive: bool = False,
|
|
|
tag_ids: list[int] = Query(default_factory=list),
|
|
tag_ids: list[int] = Query(default_factory=list),
|
|
|
db: AsyncSession = Depends(get_db),
|
|
db: AsyncSession = Depends(get_db),
|
|
|
- auth_result: tuple[User | None, bool] = Depends(
|
|
|
|
|
|
|
+ auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
|
|
|
require_ownership_permission(
|
|
require_ownership_permission(
|
|
|
Permission.LIBRARY_READ_ALL,
|
|
Permission.LIBRARY_READ_ALL,
|
|
|
Permission.LIBRARY_READ_OWN,
|
|
Permission.LIBRARY_READ_OWN,
|
|
@@ -2527,7 +2568,7 @@ async def upload_file(
|
|
|
if generate_stl_thumbnails:
|
|
if generate_stl_thumbnails:
|
|
|
try:
|
|
try:
|
|
|
if file_path.stat().st_size >= MIN_USABLE_STL_BYTES:
|
|
if file_path.stat().st_size >= MIN_USABLE_STL_BYTES:
|
|
|
- thumbnail_path = generate_stl_thumbnail(file_path, thumbnails_dir)
|
|
|
|
|
|
|
+ thumbnail_path = await _render_stl_thumbnail(file_path, thumbnails_dir)
|
|
|
except OSError:
|
|
except OSError:
|
|
|
pass
|
|
pass
|
|
|
|
|
|
|
@@ -2623,6 +2664,9 @@ async def extract_zip_file(
|
|
|
folders_created = 0
|
|
folders_created = 0
|
|
|
folder_cache: dict[str, int] = {} # path -> folder_id
|
|
folder_cache: dict[str, int] = {} # path -> folder_id
|
|
|
|
|
|
|
|
|
|
+ # The upload may have taken a while; and again before each file below.
|
|
|
|
|
+ await require_actor_still_exists(db, actor)
|
|
|
|
|
+
|
|
|
# If create_folder_from_zip is True, create a folder named after the ZIP file
|
|
# If create_folder_from_zip is True, create a folder named after the ZIP file
|
|
|
zip_folder_id = folder_id
|
|
zip_folder_id = folder_id
|
|
|
logger.info(
|
|
logger.info(
|
|
@@ -2675,6 +2719,9 @@ async def extract_zip_file(
|
|
|
]
|
|
]
|
|
|
|
|
|
|
|
for zip_path in file_list:
|
|
for zip_path in file_list:
|
|
|
|
|
+ # An extract can run for minutes; a user deleted meanwhile must
|
|
|
|
|
+ # not have the rest saved under their id (SQLite reuses it).
|
|
|
|
|
+ await require_actor_still_exists(db, actor)
|
|
|
try:
|
|
try:
|
|
|
# Determine target folder (use zip_folder_id as base if create_folder_from_zip was used)
|
|
# Determine target folder (use zip_folder_id as base if create_folder_from_zip was used)
|
|
|
target_folder_id = zip_folder_id
|
|
target_folder_id = zip_folder_id
|
|
@@ -2819,7 +2866,7 @@ async def extract_zip_file(
|
|
|
# stub STLs would otherwise log one debug line per
|
|
# stub STLs would otherwise log one debug line per
|
|
|
# file via the empty-mesh branch in trimesh.load.
|
|
# file via the empty-mesh branch in trimesh.load.
|
|
|
if generate_stl_thumbnails and len(file_content) >= MIN_USABLE_STL_BYTES:
|
|
if generate_stl_thumbnails and len(file_content) >= MIN_USABLE_STL_BYTES:
|
|
|
- thumbnail_path = generate_stl_thumbnail(file_path, thumbnails_dir)
|
|
|
|
|
|
|
+ thumbnail_path = await _render_stl_thumbnail(file_path, thumbnails_dir)
|
|
|
|
|
|
|
|
# Create database entry (store relative paths for portability)
|
|
# Create database entry (store relative paths for portability)
|
|
|
library_file = LibraryFile(
|
|
library_file = LibraryFile(
|
|
@@ -2864,6 +2911,8 @@ async def extract_zip_file(
|
|
|
|
|
|
|
|
except zipfile.BadZipFile:
|
|
except zipfile.BadZipFile:
|
|
|
raise HTTPException(status_code=400, detail="Invalid or corrupted ZIP file")
|
|
raise HTTPException(status_code=400, detail="Invalid or corrupted ZIP file")
|
|
|
|
|
+ except HTTPException:
|
|
|
|
|
+ raise
|
|
|
except Exception as e:
|
|
except Exception as e:
|
|
|
logger.error("ZIP extraction failed: %s", e, exc_info=True)
|
|
logger.error("ZIP extraction failed: %s", e, exc_info=True)
|
|
|
raise HTTPException(status_code=500, detail=f"ZIP extraction failed: {str(e)}")
|
|
raise HTTPException(status_code=500, detail=f"ZIP extraction failed: {str(e)}")
|
|
@@ -2882,7 +2931,7 @@ async def extract_zip_file(
|
|
|
async def batch_generate_stl_thumbnails(
|
|
async def batch_generate_stl_thumbnails(
|
|
|
request: BatchThumbnailRequest,
|
|
request: BatchThumbnailRequest,
|
|
|
db: AsyncSession = Depends(get_db),
|
|
db: AsyncSession = Depends(get_db),
|
|
|
- auth_result: tuple[User | None, bool] = Depends(
|
|
|
|
|
|
|
+ auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
|
|
|
require_ownership_permission(
|
|
require_ownership_permission(
|
|
|
Permission.LIBRARY_UPDATE_ALL,
|
|
Permission.LIBRARY_UPDATE_ALL,
|
|
|
Permission.LIBRARY_UPDATE_OWN,
|
|
Permission.LIBRARY_UPDATE_OWN,
|
|
@@ -2964,7 +3013,7 @@ async def batch_generate_stl_thumbnails(
|
|
|
if target_file.file_type == "pdf":
|
|
if target_file.file_type == "pdf":
|
|
|
thumbnail_path = await asyncio.to_thread(generate_pdf_thumbnail, file_path, thumbnails_dir)
|
|
thumbnail_path = await asyncio.to_thread(generate_pdf_thumbnail, file_path, thumbnails_dir)
|
|
|
else:
|
|
else:
|
|
|
- thumbnail_path = generate_stl_thumbnail(file_path, thumbnails_dir)
|
|
|
|
|
|
|
+ thumbnail_path = await _render_stl_thumbnail(file_path, thumbnails_dir)
|
|
|
|
|
|
|
|
if thumbnail_path:
|
|
if thumbnail_path:
|
|
|
# Update database with relative path
|
|
# Update database with relative path
|
|
@@ -3087,6 +3136,7 @@ async def combine_files(
|
|
|
# The preview is embedded as Metadata/thumbnail.png by combine_parts_to_3mf,
|
|
# The preview is embedded as Metadata/thumbnail.png by combine_parts_to_3mf,
|
|
|
# so ThreeMFParser picks it up here like any other 3MF's thumbnail. Loading
|
|
# so ThreeMFParser picks it up here like any other 3MF's thumbnail. Loading
|
|
|
# the combined file back to render one would expand every copy.
|
|
# the combined file back to render one would expand every copy.
|
|
|
|
|
+ await require_actor_still_exists(db, actor) # the combine may have taken a while
|
|
|
library_file, _ = await save_3mf_bytes_to_library(
|
|
library_file, _ = await save_3mf_bytes_to_library(
|
|
|
db,
|
|
db,
|
|
|
file_bytes=content,
|
|
file_bytes=content,
|
|
@@ -3153,17 +3203,19 @@ async def add_files_to_queue(
|
|
|
# silently declines when it finds nothing, because there, owning no such
|
|
# silently declines when it finds nothing, because there, owning no such
|
|
|
# printer is the user's situation rather than their mistake -- the file
|
|
# printer is the user's situation rather than their mistake -- the file
|
|
|
# still queues, as the unassigned row it has always been.
|
|
# still queues, as the unassigned row it has always been.
|
|
|
- active_models: set[str] = set()
|
|
|
|
|
|
|
+ # Keyed without regard to case, as the scheduler matches a model, to the
|
|
|
|
|
+ # printers' own spelling, which the items store.
|
|
|
|
|
+ active_models: dict[str, str] = {}
|
|
|
if request.printer_id is None:
|
|
if request.printer_id is None:
|
|
|
- active_models = {
|
|
|
|
|
- model
|
|
|
|
|
- for (model,) in (
|
|
|
|
|
- await db.execute(select(Printer.model).where(Printer.is_active == True).distinct()) # noqa: E712
|
|
|
|
|
- ).all()
|
|
|
|
|
- if model
|
|
|
|
|
- }
|
|
|
|
|
- if target_model_norm and target_model_norm not in active_models:
|
|
|
|
|
- raise HTTPException(400, f"No active printers for model: {target_model_norm}")
|
|
|
|
|
|
|
+ for (model,) in (
|
|
|
|
|
+ await db.execute(select(Printer.model).where(Printer.is_active == True).order_by(Printer.id)) # noqa: E712
|
|
|
|
|
+ ).all():
|
|
|
|
|
+ if model:
|
|
|
|
|
+ active_models.setdefault(model.lower(), model)
|
|
|
|
|
+ if target_model_norm:
|
|
|
|
|
+ if target_model_norm.lower() not in active_models:
|
|
|
|
|
+ raise HTTPException(400, f"No active printers for model: {target_model_norm}")
|
|
|
|
|
+ target_model_norm = active_models[target_model_norm.lower()]
|
|
|
|
|
|
|
|
# Get all requested files
|
|
# Get all requested files
|
|
|
result = await db.execute(LibraryFile.active().where(LibraryFile.id.in_(request.file_ids)))
|
|
result = await db.execute(LibraryFile.active().where(LibraryFile.id.in_(request.file_ids)))
|
|
@@ -3176,7 +3228,7 @@ async def add_files_to_queue(
|
|
|
# about which ids exist. Ownerless rows need LIBRARY_READ_ALL, matching
|
|
# about which ids exist. Ownerless rows need LIBRARY_READ_ALL, matching
|
|
|
# _ensure_library_file_visible.
|
|
# _ensure_library_file_visible.
|
|
|
if actor is not None and not actor.has_permission(Permission.LIBRARY_READ_ALL.value):
|
|
if actor is not None and not actor.has_permission(Permission.LIBRARY_READ_ALL.value):
|
|
|
- files = {fid: f for fid, f in files.items() if f.created_by_id == actor.id}
|
|
|
|
|
|
|
+ files = {fid: f for fid, f in files.items() if f.created_by_id is not None and f.created_by_id == actor.id}
|
|
|
|
|
|
|
|
# Project attribution (#1897): a file queued from a project-linked folder
|
|
# Project attribution (#1897): a file queued from a project-linked folder
|
|
|
# inherits that project, so the resulting archive counts toward the
|
|
# inherits that project, so the resulting archive counts toward the
|
|
@@ -3196,6 +3248,9 @@ async def add_files_to_queue(
|
|
|
# There is no per-job ask-for-outcome toggle on a bulk add, so the rows take
|
|
# There is no per-job ask-for-outcome toggle on a bulk add, so the rows take
|
|
|
# the same default the print dialog seeds itself from (#1898).
|
|
# the same default the print dialog seeds itself from (#1898).
|
|
|
confirm_outcome = await confirm_outcome_for_new_queue_item(db)
|
|
confirm_outcome = await confirm_outcome_for_new_queue_item(db)
|
|
|
|
|
+ # No print options on a bulk add either: the install's defaults, as the
|
|
|
|
|
+ # print dialog would have offered them.
|
|
|
|
|
+ print_options = await default_print_options(db)
|
|
|
|
|
|
|
|
for file_id in request.file_ids:
|
|
for file_id in request.file_ids:
|
|
|
lib_file = files.get(file_id)
|
|
lib_file = files.get(file_id)
|
|
@@ -3259,11 +3314,16 @@ async def add_files_to_queue(
|
|
|
item_target_model = target_model_norm
|
|
item_target_model = target_model_norm
|
|
|
else:
|
|
else:
|
|
|
inferred = resolve_variant_model(lib_file)
|
|
inferred = resolve_variant_model(lib_file)
|
|
|
- item_target_model = inferred if inferred in active_models else None
|
|
|
|
|
|
|
+ item_target_model = active_models.get(inferred.lower()) if inferred else None
|
|
|
|
|
|
|
|
# Filament the scheduler must match before handing a model-based
|
|
# Filament the scheduler must match before handing a model-based
|
|
|
# item to hardware. Without it the item goes to whichever printer
|
|
# item to hardware. Without it the item goes to whichever printer
|
|
|
# of that model is idle, whatever is loaded in it.
|
|
# of that model is idle, whatever is loaded in it.
|
|
|
|
|
+ # The plate that prints: added without one, a multi-plate file
|
|
|
|
|
+ # prints its default plate. Stored on the item, so the filament a
|
|
|
|
|
+ # printer must have, the AMS mapping and the unmatched-filament
|
|
|
|
|
+ # check all read that plate rather than every plate.
|
|
|
|
|
+ printed_plate = await asyncio.to_thread(printed_plate_of_multi_plate_file, file_path)
|
|
|
required_filament_types = None
|
|
required_filament_types = None
|
|
|
if item_target_model:
|
|
if item_target_model:
|
|
|
# POST /queue/'s own extractor, borrowed rather than
|
|
# POST /queue/'s own extractor, borrowed rather than
|
|
@@ -3275,7 +3335,7 @@ async def add_files_to_queue(
|
|
|
# bulk add of a few hundred -- especially from an external
|
|
# bulk add of a few hundred -- especially from an external
|
|
|
# folder on a NAS -- the zip reads add up to a stall the whole
|
|
# folder on a NAS -- the zip reads add up to a stall the whole
|
|
|
# event loop takes, status ingest included.
|
|
# event loop takes, status ingest included.
|
|
|
- filament_types = await asyncio.to_thread(_extract_filament_types_from_3mf, file_path)
|
|
|
|
|
|
|
+ filament_types = await asyncio.to_thread(_extract_filament_types_from_3mf, file_path, printed_plate)
|
|
|
if filament_types:
|
|
if filament_types:
|
|
|
required_filament_types = json.dumps(filament_types)
|
|
required_filament_types = json.dumps(filament_types)
|
|
|
|
|
|
|
@@ -3285,6 +3345,7 @@ async def add_files_to_queue(
|
|
|
printer_id=item_printer_id,
|
|
printer_id=item_printer_id,
|
|
|
target_model=item_target_model,
|
|
target_model=item_target_model,
|
|
|
required_filament_types=required_filament_types,
|
|
required_filament_types=required_filament_types,
|
|
|
|
|
+ plate_id=printed_plate,
|
|
|
library_file_id=file_id,
|
|
library_file_id=file_id,
|
|
|
project_id=lib_file.project_id
|
|
project_id=lib_file.project_id
|
|
|
or (folder_projects.get(lib_file.folder_id) if lib_file.folder_id is not None else None),
|
|
or (folder_projects.get(lib_file.folder_id) if lib_file.folder_id is not None else None),
|
|
@@ -3297,6 +3358,7 @@ async def add_files_to_queue(
|
|
|
created_by_id=actor.id if actor else None,
|
|
created_by_id=actor.id if actor else None,
|
|
|
# Waits for someone to start it unless they may print without review (#1620)
|
|
# Waits for someone to start it unless they may print without review (#1620)
|
|
|
manual_start=review_required,
|
|
manual_start=review_required,
|
|
|
|
|
+ **print_options,
|
|
|
)
|
|
)
|
|
|
db.add(queue_item)
|
|
db.add(queue_item)
|
|
|
|
|
|
|
@@ -3329,6 +3391,7 @@ async def add_files_to_queue(
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
await db.commit()
|
|
await db.commit()
|
|
|
|
|
+ await announce_queue_jobs_added(db, [a.queue_item_id for a in added])
|
|
|
|
|
|
|
|
return AddToQueueResponse(added=added, errors=errors)
|
|
return AddToQueueResponse(added=added, errors=errors)
|
|
|
|
|
|
|
@@ -3337,7 +3400,7 @@ async def add_files_to_queue(
|
|
|
async def get_library_file_plates(
|
|
async def get_library_file_plates(
|
|
|
file_id: int,
|
|
file_id: int,
|
|
|
db: AsyncSession = Depends(get_db),
|
|
db: AsyncSession = Depends(get_db),
|
|
|
- auth_result: tuple[User | None, bool] = Depends(
|
|
|
|
|
|
|
+ auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
|
|
|
require_ownership_permission(
|
|
require_ownership_permission(
|
|
|
Permission.LIBRARY_READ_ALL,
|
|
Permission.LIBRARY_READ_ALL,
|
|
|
Permission.LIBRARY_READ_OWN,
|
|
Permission.LIBRARY_READ_OWN,
|
|
@@ -3530,6 +3593,8 @@ async def get_library_file_plates(
|
|
|
"slot_id": int(filament_id),
|
|
"slot_id": int(filament_id),
|
|
|
"type": filament_type,
|
|
"type": filament_type,
|
|
|
"color": filament_color,
|
|
"color": filament_color,
|
|
|
|
|
+ # The preset the slicer assigned (GFA01, ...): names the product line
|
|
|
|
|
+ "tray_info_idx": filament_elem.get("tray_info_idx", "") or "",
|
|
|
"used_grams": round(used_grams, 1),
|
|
"used_grams": round(used_grams, 1),
|
|
|
"used_meters": float(used_m) if used_m else 0,
|
|
"used_meters": float(used_m) if used_m else 0,
|
|
|
}
|
|
}
|
|
@@ -3628,7 +3693,7 @@ async def get_library_file_plate_thumbnail(
|
|
|
file_id: int,
|
|
file_id: int,
|
|
|
plate_index: int,
|
|
plate_index: int,
|
|
|
db: AsyncSession = Depends(get_db),
|
|
db: AsyncSession = Depends(get_db),
|
|
|
- auth_result: tuple[User | None, bool] = Depends(
|
|
|
|
|
|
|
+ auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
|
|
|
require_media_token_ownership(
|
|
require_media_token_ownership(
|
|
|
Permission.LIBRARY_READ_ALL,
|
|
Permission.LIBRARY_READ_ALL,
|
|
|
Permission.LIBRARY_READ_OWN,
|
|
Permission.LIBRARY_READ_OWN,
|
|
@@ -3716,7 +3781,7 @@ async def get_library_file_filament_requirements(
|
|
|
request_id: str | None = None,
|
|
request_id: str | None = None,
|
|
|
full_slots: bool = False,
|
|
full_slots: bool = False,
|
|
|
db: AsyncSession = Depends(get_db),
|
|
db: AsyncSession = Depends(get_db),
|
|
|
- auth_result: tuple[User | None, bool] = Depends(
|
|
|
|
|
|
|
+ auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
|
|
|
require_ownership_permission(
|
|
require_ownership_permission(
|
|
|
Permission.LIBRARY_READ_ALL,
|
|
Permission.LIBRARY_READ_ALL,
|
|
|
Permission.LIBRARY_READ_OWN,
|
|
Permission.LIBRARY_READ_OWN,
|
|
@@ -3886,7 +3951,7 @@ async def get_library_file_filament_requirements(
|
|
|
filaments.sort(key=lambda x: x["slot_id"])
|
|
filaments.sort(key=lambda x: x["slot_id"])
|
|
|
|
|
|
|
|
# Enrich with nozzle mapping for dual-nozzle printers
|
|
# Enrich with nozzle mapping for dual-nozzle printers
|
|
|
- nozzle_mapping = extract_nozzle_mapping_from_3mf(zf)
|
|
|
|
|
|
|
+ nozzle_mapping = extract_nozzle_mapping_from_3mf(zf, plate_id=plate_id)
|
|
|
if nozzle_mapping:
|
|
if nozzle_mapping:
|
|
|
for filament in filaments:
|
|
for filament in filaments:
|
|
|
filament["nozzle_id"] = nozzle_mapping.get(filament["slot_id"])
|
|
filament["nozzle_id"] = nozzle_mapping.get(filament["slot_id"])
|
|
@@ -4886,6 +4951,10 @@ async def slice_and_persist(
|
|
|
if extra_metadata:
|
|
if extra_metadata:
|
|
|
metadata.update(extra_metadata)
|
|
metadata.update(extra_metadata)
|
|
|
|
|
|
|
|
|
|
+ from backend.app.services.slice_dispatch import slice_dispatch as _dispatch
|
|
|
|
|
+
|
|
|
|
|
+ # Nobody's, when the user was deleted while it sliced: the id may be someone else's now.
|
|
|
|
|
+ current_user_id = _dispatch.owner_now(job_id, current_user_id)
|
|
|
new_file = LibraryFile(
|
|
new_file = LibraryFile(
|
|
|
folder_id=folder_id,
|
|
folder_id=folder_id,
|
|
|
is_external=out_is_external,
|
|
is_external=out_is_external,
|
|
@@ -5071,6 +5140,10 @@ async def slice_and_persist_as_archive(
|
|
|
)
|
|
)
|
|
|
new_printer_id = None if is_cross_model_reslice else source_archive.printer_id
|
|
new_printer_id = None if is_cross_model_reslice else source_archive.printer_id
|
|
|
|
|
|
|
|
|
|
+ from backend.app.services.slice_dispatch import slice_dispatch as _dispatch
|
|
|
|
|
+
|
|
|
|
|
+ # Nobody's, when the user was deleted while it sliced: the id may be someone else's now.
|
|
|
|
|
+ current_user_id = _dispatch.owner_now(job_id, current_user_id)
|
|
|
new_archive = PrintArchive(
|
|
new_archive = PrintArchive(
|
|
|
printer_id=new_printer_id,
|
|
printer_id=new_printer_id,
|
|
|
project_id=source_archive.project_id,
|
|
project_id=source_archive.project_id,
|
|
@@ -5104,9 +5177,13 @@ async def slice_and_persist_as_archive(
|
|
|
bed_type=parsed_metadata.get("bed_type") or source_archive.bed_type,
|
|
bed_type=parsed_metadata.get("bed_type") or source_archive.bed_type,
|
|
|
makerworld_url=source_archive.makerworld_url,
|
|
makerworld_url=source_archive.makerworld_url,
|
|
|
designer=source_archive.designer,
|
|
designer=source_archive.designer,
|
|
|
- # Sliced-but-not-printed: keep status default ("completed") so it
|
|
|
|
|
- # surfaces in the normal archives list, but do not stamp
|
|
|
|
|
- # started/completed_at — the user hasn't actually printed it yet.
|
|
|
|
|
|
|
+ # Sliced, not printed: "archived", as archive_print files an unprinted
|
|
|
|
|
+ # 3MF. It is listed under "Not printed" and is not counted as a
|
|
|
|
|
+ # completed print; no started/completed_at either.
|
|
|
|
|
+ status="archived",
|
|
|
|
|
+ # Priced like any archive with grams, for the card's estimate.
|
|
|
|
|
+ # Statistics read the print log, so nothing is counted that did not print.
|
|
|
|
|
+ cost=await estimate_filament_cost(db, filament_g or None, new_filament_type),
|
|
|
extra_data=metadata,
|
|
extra_data=metadata,
|
|
|
created_by_id=current_user_id,
|
|
created_by_id=current_user_id,
|
|
|
)
|
|
)
|
|
@@ -5273,7 +5350,7 @@ async def print_library_file(
|
|
|
async def get_file(
|
|
async def get_file(
|
|
|
file_id: int,
|
|
file_id: int,
|
|
|
db: AsyncSession = Depends(get_db),
|
|
db: AsyncSession = Depends(get_db),
|
|
|
- auth_result: tuple[User | None, bool] = Depends(
|
|
|
|
|
|
|
+ auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
|
|
|
require_ownership_permission(
|
|
require_ownership_permission(
|
|
|
Permission.LIBRARY_READ_ALL,
|
|
Permission.LIBRARY_READ_ALL,
|
|
|
Permission.LIBRARY_READ_OWN,
|
|
Permission.LIBRARY_READ_OWN,
|
|
@@ -5372,7 +5449,7 @@ async def update_file(
|
|
|
file_id: int,
|
|
file_id: int,
|
|
|
data: FileUpdate,
|
|
data: FileUpdate,
|
|
|
db: AsyncSession = Depends(get_db),
|
|
db: AsyncSession = Depends(get_db),
|
|
|
- auth_result: tuple[User | None, bool] = Depends(
|
|
|
|
|
|
|
+ auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
|
|
|
require_ownership_permission(
|
|
require_ownership_permission(
|
|
|
Permission.LIBRARY_UPDATE_ALL,
|
|
Permission.LIBRARY_UPDATE_ALL,
|
|
|
Permission.LIBRARY_UPDATE_OWN,
|
|
Permission.LIBRARY_UPDATE_OWN,
|
|
@@ -5406,12 +5483,21 @@ async def update_file(
|
|
|
# and _without_print_name strips the embedded 3MF Title on import (#1489).
|
|
# and _without_print_name strips the embedded 3MF Title on import (#1489).
|
|
|
|
|
|
|
|
if data.folder_id is not None:
|
|
if data.folder_id is not None:
|
|
|
- if data.folder_id == 0:
|
|
|
|
|
- file.folder_id = None
|
|
|
|
|
- else:
|
|
|
|
|
|
|
+ target_folder = None
|
|
|
|
|
+ if data.folder_id != 0:
|
|
|
# library:update_all moves into any folder, as before (#3201).
|
|
# library:update_all moves into any folder, as before (#3201).
|
|
|
- await get_writable_folder(db, data.folder_id, None if can_modify_all else user)
|
|
|
|
|
- file.folder_id = data.folder_id
|
|
|
|
|
|
|
+ target_folder = await get_writable_folder(db, data.folder_id, None if can_modify_all else user)
|
|
|
|
|
+ # Into or out of an external folder the bytes have to move too, and a
|
|
|
|
|
+ # read-only one refuses them: that is the move route's work. Here only
|
|
|
|
|
+ # folder_id changed, so the file showed in a folder its bytes were not
|
|
|
|
|
+ # in (F8).
|
|
|
|
|
+ unchanged = (target_folder.id if target_folder is not None else None) == file.folder_id
|
|
|
|
|
+ if not unchanged and (file.is_external or (target_folder is not None and target_folder.is_external)):
|
|
|
|
|
+ raise HTTPException(
|
|
|
|
|
+ status_code=400,
|
|
|
|
|
+ detail="Move a file into or out of an external folder with POST /api/v1/library/files/move",
|
|
|
|
|
+ )
|
|
|
|
|
+ file.folder_id = target_folder.id if target_folder is not None else None
|
|
|
|
|
|
|
|
if data.project_id is not None:
|
|
if data.project_id is not None:
|
|
|
if data.project_id == 0:
|
|
if data.project_id == 0:
|
|
@@ -5443,7 +5529,7 @@ async def update_file(
|
|
|
async def delete_file(
|
|
async def delete_file(
|
|
|
file_id: int,
|
|
file_id: int,
|
|
|
db: AsyncSession = Depends(get_db),
|
|
db: AsyncSession = Depends(get_db),
|
|
|
- auth_result: tuple[User | None, bool] = Depends(
|
|
|
|
|
|
|
+ auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
|
|
|
require_ownership_permission(
|
|
require_ownership_permission(
|
|
|
Permission.LIBRARY_DELETE_ALL,
|
|
Permission.LIBRARY_DELETE_ALL,
|
|
|
Permission.LIBRARY_DELETE_OWN,
|
|
Permission.LIBRARY_DELETE_OWN,
|
|
@@ -5501,7 +5587,7 @@ async def delete_file(
|
|
|
async def download_file(
|
|
async def download_file(
|
|
|
file_id: int,
|
|
file_id: int,
|
|
|
db: AsyncSession = Depends(get_db),
|
|
db: AsyncSession = Depends(get_db),
|
|
|
- auth_result: tuple[User | None, bool] = Depends(
|
|
|
|
|
|
|
+ auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
|
|
|
require_ownership_permission(
|
|
require_ownership_permission(
|
|
|
Permission.LIBRARY_READ_ALL,
|
|
Permission.LIBRARY_READ_ALL,
|
|
|
Permission.LIBRARY_READ_OWN,
|
|
Permission.LIBRARY_READ_OWN,
|
|
@@ -5528,7 +5614,7 @@ async def download_file(
|
|
|
async def create_library_slicer_token(
|
|
async def create_library_slicer_token(
|
|
|
file_id: int,
|
|
file_id: int,
|
|
|
db: AsyncSession = Depends(get_db),
|
|
db: AsyncSession = Depends(get_db),
|
|
|
- auth_result: tuple[User | None, bool] = Depends(
|
|
|
|
|
|
|
+ auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
|
|
|
require_ownership_permission(
|
|
require_ownership_permission(
|
|
|
Permission.LIBRARY_READ_ALL,
|
|
Permission.LIBRARY_READ_ALL,
|
|
|
Permission.LIBRARY_READ_OWN,
|
|
Permission.LIBRARY_READ_OWN,
|
|
@@ -5590,7 +5676,7 @@ async def download_library_file_for_slicer(
|
|
|
async def get_thumbnail(
|
|
async def get_thumbnail(
|
|
|
file_id: int,
|
|
file_id: int,
|
|
|
db: AsyncSession = Depends(get_db),
|
|
db: AsyncSession = Depends(get_db),
|
|
|
- auth_result: tuple[User | None, bool] = Depends(
|
|
|
|
|
|
|
+ auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
|
|
|
require_media_token_ownership(
|
|
require_media_token_ownership(
|
|
|
Permission.LIBRARY_READ_ALL,
|
|
Permission.LIBRARY_READ_ALL,
|
|
|
Permission.LIBRARY_READ_OWN,
|
|
Permission.LIBRARY_READ_OWN,
|
|
@@ -5631,7 +5717,7 @@ async def upload_preview_thumbnail(
|
|
|
file_id: int,
|
|
file_id: int,
|
|
|
thumbnail: UploadFile = File(...),
|
|
thumbnail: UploadFile = File(...),
|
|
|
db: AsyncSession = Depends(get_db),
|
|
db: AsyncSession = Depends(get_db),
|
|
|
- auth_result: tuple[User | None, bool] = Depends(
|
|
|
|
|
|
|
+ auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
|
|
|
require_ownership_permission(
|
|
require_ownership_permission(
|
|
|
Permission.LIBRARY_UPDATE_ALL,
|
|
Permission.LIBRARY_UPDATE_ALL,
|
|
|
Permission.LIBRARY_UPDATE_OWN,
|
|
Permission.LIBRARY_UPDATE_OWN,
|
|
@@ -5732,7 +5818,7 @@ async def upload_file_photo(
|
|
|
file_id: int,
|
|
file_id: int,
|
|
|
file: UploadFile = File(...),
|
|
file: UploadFile = File(...),
|
|
|
db: AsyncSession = Depends(get_db),
|
|
db: AsyncSession = Depends(get_db),
|
|
|
- auth_result: tuple[User | None, bool] = Depends(
|
|
|
|
|
|
|
+ auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
|
|
|
require_ownership_permission(
|
|
require_ownership_permission(
|
|
|
Permission.LIBRARY_UPDATE_ALL,
|
|
Permission.LIBRARY_UPDATE_ALL,
|
|
|
Permission.LIBRARY_UPDATE_OWN,
|
|
Permission.LIBRARY_UPDATE_OWN,
|
|
@@ -5788,7 +5874,7 @@ async def get_file_photo(
|
|
|
file_id: int,
|
|
file_id: int,
|
|
|
filename: str,
|
|
filename: str,
|
|
|
db: AsyncSession = Depends(get_db),
|
|
db: AsyncSession = Depends(get_db),
|
|
|
- auth_result: tuple[User | None, bool] = Depends(
|
|
|
|
|
|
|
+ auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
|
|
|
require_media_token_ownership(
|
|
require_media_token_ownership(
|
|
|
Permission.LIBRARY_READ_ALL,
|
|
Permission.LIBRARY_READ_ALL,
|
|
|
Permission.LIBRARY_READ_OWN,
|
|
Permission.LIBRARY_READ_OWN,
|
|
@@ -5821,7 +5907,7 @@ async def delete_file_photo(
|
|
|
file_id: int,
|
|
file_id: int,
|
|
|
filename: str,
|
|
filename: str,
|
|
|
db: AsyncSession = Depends(get_db),
|
|
db: AsyncSession = Depends(get_db),
|
|
|
- auth_result: tuple[User | None, bool] = Depends(
|
|
|
|
|
|
|
+ auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
|
|
|
require_ownership_permission(
|
|
require_ownership_permission(
|
|
|
Permission.LIBRARY_UPDATE_ALL,
|
|
Permission.LIBRARY_UPDATE_ALL,
|
|
|
Permission.LIBRARY_UPDATE_OWN,
|
|
Permission.LIBRARY_UPDATE_OWN,
|
|
@@ -5867,7 +5953,7 @@ async def get_gcode(
|
|
|
file_id: int,
|
|
file_id: int,
|
|
|
plate: int | None = None,
|
|
plate: int | None = None,
|
|
|
db: AsyncSession = Depends(get_db),
|
|
db: AsyncSession = Depends(get_db),
|
|
|
- auth_result: tuple[User | None, bool] = Depends(
|
|
|
|
|
|
|
+ auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
|
|
|
require_ownership_permission(
|
|
require_ownership_permission(
|
|
|
Permission.LIBRARY_READ_ALL,
|
|
Permission.LIBRARY_READ_ALL,
|
|
|
Permission.LIBRARY_READ_OWN,
|
|
Permission.LIBRARY_READ_OWN,
|
|
@@ -5930,7 +6016,7 @@ async def get_gcode(
|
|
|
async def move_files(
|
|
async def move_files(
|
|
|
data: FileMoveRequest,
|
|
data: FileMoveRequest,
|
|
|
db: AsyncSession = Depends(get_db),
|
|
db: AsyncSession = Depends(get_db),
|
|
|
- auth_result: tuple[User | None, bool] = Depends(
|
|
|
|
|
|
|
+ auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
|
|
|
require_ownership_permission(
|
|
require_ownership_permission(
|
|
|
Permission.LIBRARY_UPDATE_ALL,
|
|
Permission.LIBRARY_UPDATE_ALL,
|
|
|
Permission.LIBRARY_UPDATE_OWN,
|
|
Permission.LIBRARY_UPDATE_OWN,
|
|
@@ -6036,7 +6122,7 @@ async def move_files(
|
|
|
async def bulk_delete(
|
|
async def bulk_delete(
|
|
|
data: BulkDeleteRequest,
|
|
data: BulkDeleteRequest,
|
|
|
db: AsyncSession = Depends(get_db),
|
|
db: AsyncSession = Depends(get_db),
|
|
|
- auth_result: tuple[User | None, bool] = Depends(
|
|
|
|
|
|
|
+ auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
|
|
|
require_ownership_permission(
|
|
require_ownership_permission(
|
|
|
Permission.LIBRARY_DELETE_ALL,
|
|
Permission.LIBRARY_DELETE_ALL,
|
|
|
Permission.LIBRARY_DELETE_OWN,
|
|
Permission.LIBRARY_DELETE_OWN,
|
|
@@ -6136,7 +6222,7 @@ async def bulk_delete(
|
|
|
@router.get("/stats")
|
|
@router.get("/stats")
|
|
|
async def get_library_stats(
|
|
async def get_library_stats(
|
|
|
db: AsyncSession = Depends(get_db),
|
|
db: AsyncSession = Depends(get_db),
|
|
|
- auth_result: tuple[User | None, bool] = Depends(
|
|
|
|
|
|
|
+ auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
|
|
|
require_ownership_permission(
|
|
require_ownership_permission(
|
|
|
Permission.LIBRARY_READ_ALL,
|
|
Permission.LIBRARY_READ_ALL,
|
|
|
Permission.LIBRARY_READ_OWN,
|
|
Permission.LIBRARY_READ_OWN,
|