Просмотр исходного кода

Record who queued a file from the Library and the webhook API

    PrintQueueItem.created_by_id is what the queue:read_own / queue:update_own /
    queue:delete_own permissions filter on, but only three of the paths that create
    queue items were setting it.

    The Library's bulk "Add to queue" required Permission.QUEUE_CREATE and then
    bound the dependency to `_`, discarding the user, so every item it created was
    ownerless -- and invisible to the person who added it if their permissions are
    scoped to their own work. That is the one path built for adding many files at
    once, which is where it was hardest to notice.

    The webhook queue endpoint has no request user, but APIKey.user_id records the
    key's owner, which is the acting identity everywhere else the key is used, so
    its items are credited to that owner. Keys minted before per-user ownership
    have no user_id and their items stay ownerless.

    The virtual-printer path is left as-is on purpose. VirtualPrinter carries no
    owner, and the obvious substitute is wrong rather than incomplete: one admin
    typically configures the VP while everyone sends prints through it, so
    crediting those to the admin would make the "added by" column lie and put other
    people's jobs in the admin's own queue. Existing NULL rows are not backfilled
    -- there is no record of who created them, and the ownerless case is already
    handled throughout.

    Tests pin both fixed paths and the two cases that must stay ownerless (auth
    disabled, legacy key).
maziggy 3 недель назад
Родитель
Сommit
ffcfa70e5b

+ 5 - 1
backend/app/api/routes/library.py

@@ -2670,7 +2670,7 @@ def is_sliced_file(filename: str) -> bool:
 async def add_files_to_queue(
     request: AddToQueueRequest,
     db: AsyncSession = Depends(get_db),
-    _: User | None = Depends(require_permission_if_auth_enabled(Permission.QUEUE_CREATE)),
+    current_user: User | None = Depends(require_permission_if_auth_enabled(Permission.QUEUE_CREATE)),
 ):
     """Add library files to the print queue.
 
@@ -2736,6 +2736,10 @@ async def add_files_to_queue(
                 or (folder_projects.get(lib_file.folder_id) if lib_file.folder_id is not None else None),
                 position=max_position,
                 status="pending",
+                # Without this the row is ownerless, and `queue:read_own` filters
+                # on `created_by_id` — so the user who queued the file could not
+                # see it in their own queue.
+                created_by_id=current_user.id if current_user else None,
             )
             db.add(queue_item)
 

+ 295 - 106
backend/app/api/routes/projects.py

@@ -4,12 +4,14 @@ import logging
 import os
 import uuid
 import zipfile
+from collections.abc import Sequence
+from dataclasses import dataclass, fields
 from datetime import datetime
 from pathlib import Path
 
 from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
 from fastapi.responses import FileResponse, StreamingResponse
-from sqlalchemy import case, func, select
+from sqlalchemy import case, func, select, update
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
@@ -68,10 +70,42 @@ _FAILURE_STATUSES = ("failed", "aborted", "cancelled", "stopped")
 _LIVE_ARCHIVE = PrintArchive.deleted_at.is_(None)
 
 
-async def compute_project_stats(
-    db: AsyncSession, project_id: int, target_count: int | None = None, target_parts_count: int | None = None
-) -> ProjectStats:
-    """Compute statistics for a project.
+@dataclass
+class _ProjectTotals:
+    """Raw per-project aggregates, before targets turn them into percentages.
+
+    Kept addable so a master project's numbers are the plain sum of its own
+    and every descendant's (#1264) — no second set of SQL that could drift
+    from the single-project path.
+    """
+
+    total_runs: int = 0
+    total_items: int = 0
+    completed_items: int = 0
+    failed_runs: int = 0
+    total_time_seconds: float = 0.0
+    total_filament_grams: float = 0.0
+    filament_cost: float = 0.0
+    energy_kwh: float = 0.0
+    energy_cost: float = 0.0
+    queued_prints: int = 0
+    in_progress_prints: int = 0
+    bom_total_items: int = 0
+    bom_completed_items: int = 0
+    bom_cost: float = 0.0
+
+    def __add__(self, other: "_ProjectTotals") -> "_ProjectTotals":
+        return _ProjectTotals(
+            **{f.name: getattr(self, f.name) + getattr(other, f.name) for f in fields(_ProjectTotals)}
+        )
+
+
+async def _load_totals(db: AsyncSession, project_ids: Sequence[int]) -> dict[int, _ProjectTotals]:
+    """Aggregate prints, queue and BOM for several projects at once.
+
+    Grouped rather than one round trip per project because a master project
+    has to aggregate its whole subtree, and the sub-project list shows each
+    branch's own roll-up alongside it (#1264).
 
     Aggregates from ``print_log_entries`` joined to ``print_archives`` so
     every actual run contributes — pre-fix this counted ``print_archives``
@@ -83,31 +117,28 @@ async def compute_project_stats(
     Orphan log entries (``archive_id IS NULL`` after archive deletion via
     ``ON DELETE SET NULL``) are excluded by the inner join — they can't
     be attributed to a project.
+
+    Projects with nothing recorded are absent from every grouped result, so
+    the caller gets a zeroed ``_ProjectTotals`` for them rather than a KeyError.
     """
-    # Per-run aggregates from print_log_entries joined on archive_id so
-    # the WHERE filters by archives.project_id. Each run's duration,
-    # filament, cost, and energy come from the log row, not the source
-    # archive — so multi-plate 3MFs and reprints both count correctly.
-    log_stats_result = await db.execute(
+    totals: dict[int, _ProjectTotals] = {pid: _ProjectTotals() for pid in project_ids}
+    if not totals:
+        return totals
+
+    # Per-run aggregates. Each run's duration, filament, cost, and energy come
+    # from the log row, not the source archive — so multi-plate 3MFs and
+    # reprints both count correctly. The total/completed/failed splits are all
+    # per-run too: quantity is summed per run, while failures are counted as
+    # runs rather than parts.
+    log_rows = await db.execute(
         select(
+            PrintArchive.project_id.label("project_id"),
             func.count(PrintLogEntry.id).label("total_runs"),
             func.coalesce(func.sum(PrintLogEntry.duration_seconds), 0).label("total_time"),
             func.coalesce(func.sum(PrintLogEntry.filament_used_grams), 0).label("total_filament"),
             func.coalesce(func.sum(PrintLogEntry.cost), 0).label("total_filament_cost"),
             func.coalesce(func.sum(PrintLogEntry.energy_kwh), 0).label("total_energy"),
             func.coalesce(func.sum(PrintLogEntry.energy_cost), 0).label("total_energy_cost"),
-        )
-        .join(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
-        .where(PrintArchive.project_id == project_id, _LIVE_ARCHIVE)
-    )
-    log_stats = log_stats_result.first()
-    total_archives = int(log_stats.total_runs or 0)
-
-    # Total items the project has produced or attempted: sum of quantity
-    # per run (each run contributes its archive's quantity). The total/
-    # completed/failed splits are all per-run, not per-file.
-    items_split_result = await db.execute(
-        select(
             func.coalesce(func.sum(PrintArchive.quantity), 0).label("total_items"),
             func.coalesce(
                 func.sum(case((PrintLogEntry.status == "completed", PrintArchive.quantity), else_=0)),
@@ -119,77 +150,237 @@ async def compute_project_stats(
             ).label("failed_runs"),
         )
         .join(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
-        .where(PrintArchive.project_id == project_id, _LIVE_ARCHIVE)
+        .where(PrintArchive.project_id.in_(list(totals)), _LIVE_ARCHIVE)
+        .group_by(PrintArchive.project_id)
     )
-    items_split = items_split_result.first()
-    total_items = int(items_split.total_items or 0)
-    completed_items = int(items_split.completed_items or 0)
-    failed_prints = int(items_split.failed_runs or 0)
-
-    # Count queued items
-    queued_result = await db.execute(
-        select(func.count(PrintQueueItem.id)).where(
-            PrintQueueItem.project_id == project_id, PrintQueueItem.status == "pending"
+    for row in log_rows:
+        entry = totals[row.project_id]
+        entry.total_runs = int(row.total_runs or 0)
+        entry.total_time_seconds = float(row.total_time or 0)
+        entry.total_filament_grams = float(row.total_filament or 0)
+        entry.filament_cost = float(row.total_filament_cost or 0)
+        entry.energy_kwh = float(row.total_energy or 0)
+        entry.energy_cost = float(row.total_energy_cost or 0)
+        entry.total_items = int(row.total_items or 0)
+        entry.completed_items = int(row.completed_items or 0)
+        entry.failed_runs = int(row.failed_runs or 0)
+
+    queue_rows = await db.execute(
+        select(
+            PrintQueueItem.project_id.label("project_id"),
+            func.coalesce(func.sum(case((PrintQueueItem.status == "pending", 1), else_=0)), 0).label("queued"),
+            func.coalesce(func.sum(case((PrintQueueItem.status == "printing", 1), else_=0)), 0).label("in_progress"),
         )
+        .where(PrintQueueItem.project_id.in_(list(totals)))
+        .group_by(PrintQueueItem.project_id)
     )
-    queued_prints = queued_result.scalar() or 0
+    for row in queue_rows:
+        entry = totals[row.project_id]
+        entry.queued_prints = int(row.queued or 0)
+        entry.in_progress_prints = int(row.in_progress or 0)
 
-    # Count in-progress items
-    in_progress_result = await db.execute(
-        select(func.count(PrintQueueItem.id)).where(
-            PrintQueueItem.project_id == project_id, PrintQueueItem.status == "printing"
+    bom_rows = await db.execute(
+        select(
+            ProjectBOMItem.project_id.label("project_id"),
+            func.count(ProjectBOMItem.id).label("total"),
+            func.sum(case((ProjectBOMItem.quantity_acquired >= ProjectBOMItem.quantity_needed, 1), else_=0)).label(
+                "completed"
+            ),
+            func.coalesce(func.sum(ProjectBOMItem.unit_price * ProjectBOMItem.quantity_needed), 0).label("bom_cost"),
         )
+        .where(ProjectBOMItem.project_id.in_(list(totals)))
+        .group_by(ProjectBOMItem.project_id)
     )
-    in_progress_prints = in_progress_result.scalar() or 0
+    for row in bom_rows:
+        entry = totals[row.project_id]
+        entry.bom_total_items = int(row.total or 0)
+        entry.bom_completed_items = int(row.completed or 0)
+        entry.bom_cost = float(row.bom_cost or 0)
+
+    return totals
 
+
+def _stats_from_totals(
+    totals: _ProjectTotals, target_count: int | None = None, target_parts_count: int | None = None
+) -> ProjectStats:
+    """Turn raw aggregates into the response shape, applying the targets."""
     # Calculate progress for plates (target_count vs total_archives)
     progress_percent = None
     remaining_prints = None
     if target_count and target_count > 0:
-        progress_percent = round((total_archives / target_count) * 100, 1)
-        remaining_prints = max(0, target_count - total_archives)
+        progress_percent = round((totals.total_runs / target_count) * 100, 1)
+        remaining_prints = max(0, target_count - totals.total_runs)
 
     # Calculate progress for parts (target_parts_count vs completed_items)
     parts_progress_percent = None
     remaining_parts = None
     if target_parts_count and target_parts_count > 0:
-        parts_progress_percent = round((completed_items / target_parts_count) * 100, 1)
-        remaining_parts = max(0, target_parts_count - completed_items)
-
-    # BOM stats
-    bom_result = await db.execute(
-        select(
-            func.count(ProjectBOMItem.id).label("total"),
-            func.sum(case((ProjectBOMItem.quantity_acquired >= ProjectBOMItem.quantity_needed, 1), else_=0)).label(
-                "completed"
-            ),
-            func.coalesce(func.sum(ProjectBOMItem.unit_price * ProjectBOMItem.quantity_needed), 0).label("bom_cost"),
-        ).where(ProjectBOMItem.project_id == project_id)
-    )
-    bom_stats = bom_result.first()
+        parts_progress_percent = round((totals.completed_items / target_parts_count) * 100, 1)
+        remaining_parts = max(0, target_parts_count - totals.completed_items)
 
     return ProjectStats(
-        total_archives=total_archives,
-        total_items=int(total_items),
-        completed_prints=completed_items,  # Now reflects sum of quantities for completed prints
-        failed_prints=int(failed_prints),
-        queued_prints=queued_prints,
-        in_progress_prints=in_progress_prints,
-        total_print_time_hours=round((log_stats.total_time or 0) / 3600, 2),
-        total_filament_grams=round(log_stats.total_filament or 0, 2),
+        total_archives=totals.total_runs,
+        total_items=totals.total_items,
+        completed_prints=totals.completed_items,  # Sum of quantities for completed prints
+        failed_prints=totals.failed_runs,
+        queued_prints=totals.queued_prints,
+        in_progress_prints=totals.in_progress_prints,
+        total_print_time_hours=round(totals.total_time_seconds / 3600, 2),
+        total_filament_grams=round(totals.total_filament_grams, 2),
         progress_percent=progress_percent,
         parts_progress_percent=parts_progress_percent,
-        estimated_cost=round((log_stats.total_filament_cost or 0), 2),
-        total_energy_kwh=round((log_stats.total_energy or 0), 3),
-        total_energy_cost=round((log_stats.total_energy_cost or 0), 3),
+        estimated_cost=round(totals.filament_cost, 2),
+        total_energy_kwh=round(totals.energy_kwh, 3),
+        total_energy_cost=round(totals.energy_cost, 3),
         remaining_prints=remaining_prints,
         remaining_parts=remaining_parts,
-        bom_total_items=bom_stats.total or 0,
-        bom_completed_items=int(bom_stats.completed or 0),
-        bom_cost=round(float(bom_stats.bom_cost or 0), 2),
+        bom_total_items=totals.bom_total_items,
+        bom_completed_items=totals.bom_completed_items,
+        bom_cost=round(totals.bom_cost, 2),
     )
 
 
+async def compute_project_stats(
+    db: AsyncSession, project_id: int, target_count: int | None = None, target_parts_count: int | None = None
+) -> ProjectStats:
+    """Compute statistics for a single project, excluding any sub-projects.
+
+    Sub-project roll-ups go through ``compute_subtree_stats`` instead. This
+    stays own-prints-only on purpose: it is what every existing caller means
+    by "this project's numbers", and widening it would silently restate the
+    figures of anyone who had already nested projects over the API.
+    """
+    totals = (await _load_totals(db, [project_id]))[project_id]
+    return _stats_from_totals(totals, target_count, target_parts_count)
+
+
+def _descendants_of(children: dict[int, list[int]], root_id: int) -> list[int]:
+    """Every project nested under ``root_id``, at any depth, root excluded.
+
+    Walked in Python off one already-fetched parent map rather than a recursive
+    CTE, so SQLite and PostgreSQL stay on identical code paths.
+
+    ``seen`` is not belt-and-braces. ``update_project`` only ever rejected a
+    project as its own *direct* parent, so any database written before that
+    guard was widened can hold A -> B -> A, and an unguarded walk over one
+    would never terminate.
+    """
+    found: list[int] = []
+    seen = {root_id}
+    stack = [root_id]
+    while stack:
+        for child in children.get(stack.pop(), ()):
+            if child in seen:
+                continue
+            seen.add(child)
+            found.append(child)
+            stack.append(child)
+    return found
+
+
+async def _project_descendants(db: AsyncSession, root_id: int) -> list[int]:
+    """``_descendants_of`` for callers that only need the ids, not the totals."""
+    rows = (await db.execute(select(Project.id, Project.parent_id).where(Project.parent_id.is_not(None)))).all()
+    children: dict[int, list[int]] = {}
+    for pid, parent_id in rows:
+        children.setdefault(parent_id, []).append(pid)
+    return _descendants_of(children, root_id)
+
+
+@dataclass
+class _SubtreeReport:
+    """What the detail endpoint needs to describe a project and its tree."""
+
+    descendant_count: int
+    # None when the project has no sub-projects: the roll-up would be identical
+    # to the project's own stats, and the UI uses its absence to stay quiet
+    # rather than showing a second, equal set of numbers.
+    rollup: ProjectStats | None
+    child_previews: list[ProjectChildPreview]
+
+
+async def compute_subtree_stats(db: AsyncSession, root_id: int) -> _SubtreeReport:
+    """Roll a project's own numbers up with every sub-project beneath it (#1264).
+
+    Four queries regardless of tree size or depth: one for the parent map, then
+    the three grouped aggregates in ``_load_totals`` covering the whole subtree
+    at once. Each direct child's preview carries *its* branch's roll-up, so the
+    listed rows add up to the master's total minus the master's own prints.
+    """
+    rows = (
+        await db.execute(
+            select(
+                Project.id,
+                Project.parent_id,
+                Project.name,
+                Project.color,
+                Project.status,
+                Project.target_count,
+                Project.target_parts_count,
+            )
+        )
+    ).all()
+    by_id = {row.id: row for row in rows}
+    children: dict[int, list[int]] = {}
+    for row in rows:
+        if row.parent_id is not None:
+            children.setdefault(row.parent_id, []).append(row.id)
+
+    descendants = _descendants_of(children, root_id)
+    if not descendants:
+        return _SubtreeReport(descendant_count=0, rollup=None, child_previews=[])
+
+    totals = await _load_totals(db, [root_id, *descendants])
+
+    def branch(node_id: int) -> tuple[_ProjectTotals, list[int]]:
+        """Totals for ``node_id`` plus everything under it, and that id list."""
+        ids = [node_id, *_descendants_of(children, node_id)]
+        summed = _ProjectTotals()
+        for pid in ids:
+            summed = summed + totals[pid]
+        return summed, ids
+
+    def summed_target(ids: Sequence[int], attr: str) -> int | None:
+        """Targets add up across the tree; all-unset stays unset, not zero."""
+        total = sum(getattr(by_id[pid], attr) or 0 for pid in ids)
+        return total or None
+
+    subtree_ids = [root_id, *descendants]
+    root_totals, _ = branch(root_id)
+    rollup = _stats_from_totals(
+        root_totals,
+        summed_target(subtree_ids, "target_count"),
+        summed_target(subtree_ids, "target_parts_count"),
+    )
+
+    previews: list[ProjectChildPreview] = []
+    for child_id in sorted(children.get(root_id, ()), key=lambda cid: by_id[cid].name):
+        child = by_id[child_id]
+        child_totals, child_ids = branch(child_id)
+        # Progress here is runs-against-plate-target, matching what the child's
+        # own page reports. It used to be completed *quantities* against the
+        # same target, so a row's percentage disagreed with the page it linked
+        # to.
+        child_stats = _stats_from_totals(child_totals, summed_target(child_ids, "target_count"))
+        previews.append(
+            ProjectChildPreview(
+                id=child.id,
+                name=child.name,
+                color=child.color,
+                status=child.status,
+                progress_percent=child_stats.progress_percent,
+                descendant_count=len(child_ids) - 1,
+                total_archives=child_stats.total_archives,
+                completed_prints=child_stats.completed_prints,
+                total_print_time_hours=child_stats.total_print_time_hours,
+                total_filament_grams=child_stats.total_filament_grams,
+                total_cost=round(child_stats.estimated_cost + child_stats.total_energy_cost + child_stats.bom_cost, 2),
+            )
+        )
+
+    return _SubtreeReport(descendant_count=len(descendants), rollup=rollup, child_previews=previews)
+
+
 @router.get("", response_model=list[ProjectListResponse])
 @router.get("/", response_model=list[ProjectListResponse])
 async def list_projects(
@@ -206,6 +397,20 @@ async def list_projects(
     result = await db.execute(query)
     projects = result.scalars().all()
 
+    # Direct sub-project counts for every project in one pass (#1264). Counted
+    # across all projects rather than the filtered page: a sub-project hidden
+    # by the status filter is still a sub-project, and a parent that claimed
+    # none would invite deleting it as if nothing hung off it.
+    child_counts = dict(
+        (
+            await db.execute(
+                select(Project.parent_id, func.count(Project.id))
+                .where(Project.parent_id.is_not(None))
+                .group_by(Project.parent_id)
+            )
+        ).all()
+    )
+
     # Compute quick stats for each project. Same per-run aggregation as
     # ``compute_project_stats`` — counts and quantities come from
     # ``print_log_entries`` joined to ``print_archives`` so reprints and
@@ -290,6 +495,8 @@ async def list_projects(
                 failed_count=failed_count,
                 queue_count=queue_count,
                 progress_percent=progress_percent,
+                parent_id=project.parent_id,
+                child_count=child_counts.get(project.id, 0),
                 archives=archive_previews,
                 url=project.url,
                 cover_image_filename=project.cover_image_filename,
@@ -501,38 +708,6 @@ async def create_project_from_template(
 # ============ Dynamic {project_id} Routes ============
 
 
-async def get_child_previews(db: AsyncSession, parent_id: int) -> list[ProjectChildPreview]:
-    """Get preview info for child projects."""
-    result = await db.execute(select(Project).where(Project.parent_id == parent_id).order_by(Project.name))
-    children = result.scalars().all()
-
-    previews = []
-    for child in children:
-        # Get completed count for progress (sum of quantities)
-        completed_result = await db.execute(
-            select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(
-                PrintArchive.project_id == child.id,
-                PrintArchive.status == "completed",
-                _LIVE_ARCHIVE,
-            )
-        )
-        completed_count = completed_result.scalar() or 0
-        progress = None
-        if child.target_count and child.target_count > 0:
-            progress = round((int(completed_count) / child.target_count) * 100, 1)
-
-        previews.append(
-            ProjectChildPreview(
-                id=child.id,
-                name=child.name,
-                color=child.color,
-                status=child.status,
-                progress_percent=progress,
-            )
-        )
-    return previews
-
-
 @router.get("/{project_id}", response_model=ProjectResponse)
 async def get_project(
     project_id: int,
@@ -552,8 +727,7 @@ async def get_project(
         parent_result = await db.execute(select(Project.name).where(Project.id == project.parent_id))
         parent_name = parent_result.scalar()
 
-    # Get children
-    children = await get_child_previews(db, project.id)
+    subtree = await compute_subtree_stats(db, project.id)
 
     stats = await compute_project_stats(db, project.id, project.target_count, project.target_parts_count)
 
@@ -578,10 +752,12 @@ async def get_project(
         template_source_id=project.template_source_id,
         parent_id=project.parent_id,
         parent_name=parent_name,
-        children=children,
+        children=subtree.child_previews,
+        descendant_count=subtree.descendant_count,
         created_at=project.created_at,
         updated_at=project.updated_at,
         stats=stats,
+        rollup_stats=subtree.rollup,
     )
 
 
@@ -644,6 +820,12 @@ async def update_project(
             parent_result = await db.execute(select(Project).where(Project.id == data.parent_id))
             if not parent_result.scalar_one_or_none():
                 raise HTTPException(status_code=400, detail="Parent project not found")
+            # Refusing only the project itself left A -> B -> A reachable in two
+            # calls, and a cycle has no root to roll figures up to — the walk in
+            # ``_descendants_of`` would revisit forever without its seen-set
+            # (#1264).
+            if data.parent_id in await _project_descendants(db, project_id):
+                raise HTTPException(status_code=400, detail="Project cannot be moved under one of its own sub-projects")
             project.parent_id = data.parent_id
         else:
             project.parent_id = None
@@ -657,8 +839,7 @@ async def update_project(
         parent_result = await db.execute(select(Project.name).where(Project.id == project.parent_id))
         parent_name = parent_result.scalar()
 
-    # Get children
-    children = await get_child_previews(db, project.id)
+    subtree = await compute_subtree_stats(db, project.id)
 
     stats = await compute_project_stats(db, project.id, project.target_count, project.target_parts_count)
 
@@ -683,10 +864,12 @@ async def update_project(
         template_source_id=project.template_source_id,
         parent_id=project.parent_id,
         parent_name=parent_name,
-        children=children,
+        children=subtree.child_previews,
+        descendant_count=subtree.descendant_count,
         created_at=project.created_at,
         updated_at=project.updated_at,
         stats=stats,
+        rollup_stats=subtree.rollup,
     )
 
 
@@ -703,6 +886,12 @@ async def delete_project(
     if not project:
         raise HTTPException(status_code=404, detail="Project not found")
 
+    # Sub-projects move up to the deleted project's own parent rather than
+    # being cut loose at the top level, so deleting a middle layer collapses
+    # the tree by one instead of scattering a branch (#1264). Left to the ORM
+    # this would null their parent_id instead, which loses the grandparent.
+    await db.execute(update(Project).where(Project.parent_id == project_id).values(parent_id=project.parent_id))
+
     await db.delete(project)
 
     return {"message": "Project deleted"}

+ 4 - 0
backend/app/api/routes/webhook.py

@@ -115,6 +115,10 @@ async def webhook_add_to_queue(
         scheduled_time=scheduled_time,
         require_previous_success=data.require_previous_success,
         auto_off_after=data.auto_off_after,
+        # Attribute to the key's owner so the item shows up under `queue:read_own`
+        # for the person whose key it is. Legacy keys predating per-user ownership
+        # have no `user_id`, and those rows stay ownerless.
+        created_by_id=api_key.user_id,
     )
     db.add(queue_item)
     await db.flush()

+ 20 - 1
backend/app/schemas/project.py

@@ -91,13 +91,24 @@ class ProjectStats(BaseModel):
 
 
 class ProjectChildPreview(BaseModel):
-    """Minimal project data for child preview."""
+    """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):
@@ -122,9 +133,13 @@ class ProjectResponse(BaseModel):
     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
 
@@ -177,6 +192,10 @@ class ProjectListResponse(BaseModel):
     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

+ 312 - 0
backend/tests/integration/test_projects_api.py

@@ -1820,3 +1820,315 @@ class TestSoftDeletedArchivesLeaveTheProject:
         db_session.expire_all()
         result = await db_session.execute(select(PrintArchive.project_id).where(PrintArchive.id == gone_id))
         assert result.scalar_one() is None
+
+
+class TestSubProjectRollup:
+    """Tests for #1264 — nesting projects and rolling their figures up.
+
+    The parent/child columns predate this; what these cover is the roll-up,
+    the cycle guard that a roll-up needs to terminate, and what a delete does
+    to the branch hanging off it.
+    """
+
+    @pytest.fixture
+    async def project_factory(self, db_session):
+        async def _create_project(**kwargs):
+            from backend.app.models.project import Project
+
+            defaults = {"name": "Rollup Project", "color": "#FF0000"}
+            defaults.update(kwargs)
+            project = Project(**defaults)
+            db_session.add(project)
+            await db_session.commit()
+            await db_session.refresh(project)
+            return project
+
+        return _create_project
+
+    @pytest.fixture
+    async def run_factory(self, db_session):
+        """One completed run against a project, with figures worth summing."""
+
+        async def _create_run(project_id, *, grams=100.0, cost=5.0, seconds=3600, status="completed", quantity=1):
+            from backend.app.models.archive import PrintArchive
+            from backend.app.models.print_log import PrintLogEntry
+
+            archive = PrintArchive(
+                filename="test.3mf",
+                file_path="test/test.3mf",
+                file_size=1000,
+                print_name="Run",
+                status=status,
+                quantity=quantity,
+                project_id=project_id,
+            )
+            db_session.add(archive)
+            await db_session.commit()
+            await db_session.refresh(archive)
+
+            db_session.add(
+                PrintLogEntry(
+                    archive_id=archive.id,
+                    print_name=archive.print_name,
+                    status=status,
+                    duration_seconds=seconds,
+                    filament_used_grams=grams,
+                    cost=cost,
+                )
+            )
+            await db_session.commit()
+            return archive
+
+        return _create_run
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_master_project_rolls_up_every_sub_project(
+        self, async_client: AsyncClient, project_factory, run_factory
+    ):
+        """The whole point of the feature: one number for the programme."""
+        master = await project_factory(name="Airframe")
+        wing = await project_factory(name="Wing", parent_id=master.id)
+        tail = await project_factory(name="Tail", parent_id=master.id)
+
+        await run_factory(master.id, grams=10.0, cost=1.0, seconds=3600)
+        await run_factory(wing.id, grams=20.0, cost=2.0, seconds=7200)
+        await run_factory(tail.id, grams=30.0, cost=3.0, seconds=1800)
+
+        body = (await async_client.get(f"/api/v1/projects/{master.id}")).json()
+
+        assert body["descendant_count"] == 2
+        assert body["rollup_stats"]["total_archives"] == 3
+        assert body["rollup_stats"]["total_filament_grams"] == 60.0
+        assert body["rollup_stats"]["estimated_cost"] == 6.0
+        assert body["rollup_stats"]["total_print_time_hours"] == 3.5
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_the_masters_own_stats_still_mean_its_own_prints(
+        self, async_client: AsyncClient, project_factory, run_factory
+    ):
+        """``stats`` keeps its existing meaning — anyone who nested projects
+        over the API before this shipped must not see their figures restated."""
+        master = await project_factory(name="Airframe")
+        wing = await project_factory(name="Wing", parent_id=master.id)
+        await run_factory(master.id, grams=10.0)
+        await run_factory(wing.id, grams=20.0)
+
+        body = (await async_client.get(f"/api/v1/projects/{master.id}")).json()
+
+        assert body["stats"]["total_archives"] == 1
+        assert body["stats"]["total_filament_grams"] == 10.0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_the_roll_up_reaches_past_the_first_generation(
+        self, async_client: AsyncClient, project_factory, run_factory
+    ):
+        """Nesting is arbitrary depth, so a grandchild has to count too."""
+        master = await project_factory(name="Airframe")
+        wing = await project_factory(name="Wing", parent_id=master.id)
+        spar = await project_factory(name="Spar", parent_id=wing.id)
+        await run_factory(spar.id, grams=50.0)
+
+        body = (await async_client.get(f"/api/v1/projects/{master.id}")).json()
+
+        assert body["descendant_count"] == 2
+        assert body["rollup_stats"]["total_filament_grams"] == 50.0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_childless_project_reports_no_roll_up_at_all(
+        self, async_client: AsyncClient, project_factory, run_factory
+    ):
+        """Null, not a copy of ``stats`` — the page uses the absence to stay
+        quiet rather than printing the same figures twice."""
+        lonely = await project_factory(name="Solo")
+        await run_factory(lonely.id)
+
+        body = (await async_client.get(f"/api/v1/projects/{lonely.id}")).json()
+
+        assert body["rollup_stats"] is None
+        assert body["descendant_count"] == 0
+        assert body["children"] == []
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_each_listed_child_carries_its_own_branch_total(
+        self, async_client: AsyncClient, project_factory, run_factory
+    ):
+        """Otherwise the listed rows do not add up to the master's total and
+        the page contradicts itself."""
+        master = await project_factory(name="Airframe")
+        wing = await project_factory(name="Wing", parent_id=master.id)
+        spar = await project_factory(name="Spar", parent_id=wing.id)
+        await run_factory(wing.id, grams=20.0, cost=2.0)
+        await run_factory(spar.id, grams=30.0, cost=3.0)
+
+        body = (await async_client.get(f"/api/v1/projects/{master.id}")).json()
+
+        assert len(body["children"]) == 1
+        row = body["children"][0]
+        assert row["name"] == "Wing"
+        assert row["descendant_count"] == 1
+        assert row["total_archives"] == 2
+        assert row["total_filament_grams"] == 50.0
+        assert row["total_cost"] == 5.0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_roll_up_progress_measures_against_the_summed_targets(
+        self, async_client: AsyncClient, project_factory, run_factory
+    ):
+        """A target on each part of the tree is a target for the whole."""
+        master = await project_factory(name="Airframe", target_count=2)
+        wing = await project_factory(name="Wing", parent_id=master.id, target_count=2)
+        await run_factory(master.id)
+        await run_factory(wing.id)
+        await run_factory(wing.id)
+
+        body = (await async_client.get(f"/api/v1/projects/{master.id}")).json()
+
+        assert body["stats"]["progress_percent"] == 50.0  # 1 of its own 2
+        assert body["rollup_stats"]["progress_percent"] == 75.0  # 3 of the tree's 4
+        assert body["rollup_stats"]["remaining_prints"] == 1
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_project_cannot_be_moved_under_its_own_sub_project(
+        self, async_client: AsyncClient, project_factory
+    ):
+        """Rejecting only the direct self-parent left A -> B -> A reachable in
+        two calls, and a cycle has no root to roll anything up to."""
+        master = await project_factory(name="Airframe")
+        wing = await project_factory(name="Wing", parent_id=master.id)
+
+        response = await async_client.patch(f"/api/v1/projects/{master.id}", json={"parent_id": wing.id})
+
+        assert response.status_code == 400
+        assert "sub-projects" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_the_guard_reaches_a_distant_descendant_too(self, async_client: AsyncClient, project_factory):
+        """A three-deep loop is no more legal than a two-deep one."""
+        master = await project_factory(name="Airframe")
+        wing = await project_factory(name="Wing", parent_id=master.id)
+        spar = await project_factory(name="Spar", parent_id=wing.id)
+
+        response = await async_client.patch(f"/api/v1/projects/{master.id}", json={"parent_id": spar.id})
+
+        assert response.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_an_unrelated_project_is_still_a_legal_parent(self, async_client: AsyncClient, project_factory):
+        """The guard must not refuse ordinary nesting."""
+        master = await project_factory(name="Airframe")
+        wing = await project_factory(name="Wing", parent_id=master.id)
+        other = await project_factory(name="Ground Station")
+
+        response = await async_client.patch(f"/api/v1/projects/{other.id}", json={"parent_id": wing.id})
+
+        assert response.status_code == 200
+        assert response.json()["parent_id"] == wing.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_cycle_already_in_the_database_does_not_hang_the_roll_up(
+        self, async_client: AsyncClient, project_factory, db_session
+    ):
+        """Databases written before the guard was widened can hold A -> B -> A.
+        Reading one has to terminate, not spin."""
+        from sqlalchemy import update as sa_update
+
+        from backend.app.models.project import Project
+
+        first = await project_factory(name="First")
+        second = await project_factory(name="Second", parent_id=first.id)
+        # Straight to the table: the API now refuses to write this.
+        await db_session.execute(sa_update(Project).where(Project.id == first.id).values(parent_id=second.id))
+        await db_session.commit()
+
+        response = await async_client.get(f"/api/v1/projects/{first.id}")
+
+        assert response.status_code == 200
+        assert response.json()["descendant_count"] == 1
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_deleting_a_middle_layer_promotes_its_children(
+        self, async_client: AsyncClient, project_factory, db_session
+    ):
+        """Collapse the tree by one rather than scattering the branch."""
+        from sqlalchemy import select
+
+        from backend.app.models.project import Project
+
+        master = await project_factory(name="Airframe")
+        wing = await project_factory(name="Wing", parent_id=master.id)
+        spar = await project_factory(name="Spar", parent_id=wing.id)
+        # Read the ids out before expiring: an expired instance refreshes itself
+        # on attribute access, which is a lazy load in a sync frame.
+        master_id, spar_id = master.id, spar.id
+
+        response = await async_client.delete(f"/api/v1/projects/{wing.id}")
+        assert response.status_code == 200
+
+        db_session.expire_all()
+        parent_id = (await db_session.execute(select(Project.parent_id).where(Project.id == spar_id))).scalar_one()
+        assert parent_id == master_id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_deleting_a_top_level_project_frees_its_children(
+        self, async_client: AsyncClient, project_factory, db_session
+    ):
+        """Nothing to promote to, so the child becomes top-level — and the
+        delete has to succeed at all, which the bare FK would have refused."""
+        from sqlalchemy import select
+
+        from backend.app.models.project import Project
+
+        master = await project_factory(name="Airframe")
+        wing = await project_factory(name="Wing", parent_id=master.id)
+        wing_id = wing.id  # See the sibling test: expiring invalidates the instance.
+
+        response = await async_client.delete(f"/api/v1/projects/{master.id}")
+        assert response.status_code == 200
+
+        db_session.expire_all()
+        parent_id = (await db_session.execute(select(Project.parent_id).where(Project.id == wing_id))).scalar_one()
+        assert parent_id is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_the_grid_can_tell_a_sub_project_from_a_top_level_one(
+        self, async_client: AsyncClient, project_factory
+    ):
+        """Without these the list view shows eight sub-projects as eight
+        unrelated ones."""
+        master = await project_factory(name="Airframe")
+        await project_factory(name="Wing", parent_id=master.id)
+
+        rows = {p["name"]: p for p in (await async_client.get("/api/v1/projects/")).json()}
+
+        assert rows["Airframe"]["parent_id"] is None
+        assert rows["Airframe"]["child_count"] == 1
+        assert rows["Wing"]["parent_id"] == master.id
+        assert rows["Wing"]["child_count"] == 0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_filtered_listing_still_admits_to_its_hidden_children(
+        self, async_client: AsyncClient, project_factory
+    ):
+        """A parent that claimed no children would invite deleting it as if
+        nothing hung off it."""
+        master = await project_factory(name="Airframe", status="active")
+        await project_factory(name="Wing", parent_id=master.id, status="completed")
+
+        rows = {p["name"]: p for p in (await async_client.get("/api/v1/projects/?status=active")).json()}
+
+        assert "Wing" not in rows
+        assert rows["Airframe"]["child_count"] == 1

+ 211 - 0
backend/tests/integration/test_queue_creation_attribution.py

@@ -0,0 +1,211 @@
+"""Regression tests: queue items created outside POST /queue/ lost their owner.
+
+`PrintQueueItem.created_by_id` is what the `queue:read_own` / `queue:update_own` /
+`queue:delete_own` permissions filter on (`api/routes/print_queue.py`). Two
+creation paths never set it, so the rows they produced were ownerless:
+
+  - `POST /library/files/add-to-queue` — the bulk "Add to queue" action on the
+    Library page. The route already required `Permission.QUEUE_CREATE` but bound
+    the dependency to `_` and threw the user away, so a non-admin who queued
+    files from the Library could not then see them in their own queue.
+  - `POST /webhook/queue/add` — API-key inbound. `APIKey.user_id` records the
+    key's owner, which is the acting identity for everything else the key does.
+
+Ownerless rows are still legitimate for callers with no user behind them (auth
+disabled, virtual-printer FTP uploads, legacy keys minted before per-user
+ownership), so the tests pin those cases too — the fix must not invent a
+placeholder user id. Compare `test_queue_start_user_attribution.py`, which pins
+the same NULL-is-meaningful contract on the `/start` path.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+from backend.app.core.auth import generate_api_key
+from backend.app.core.config import settings as app_settings
+from backend.app.models.api_key import APIKey
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.user import User
+
+
+async def _read_item(test_engine, item_id: int) -> PrintQueueItem:
+    """Fresh-session DB read — the `db_session` fixture's connection can look
+    stale after a route call dispatches through its own `Depends(get_db)`
+    session. Same helper shape as test_queue_start_user_attribution.py."""
+    maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
+    async with maker() as fresh:
+        return (await fresh.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))).scalar_one()
+
+
+async def _enable_auth_with_admin(client: AsyncClient, username: str) -> tuple[str, dict]:
+    """Boot auth setup and return (bearer_token, user_dict)."""
+    await client.post(
+        "/api/v1/auth/setup",
+        json={
+            "auth_enabled": True,
+            "admin_username": username,
+            "admin_password": "AdminPass1!",
+        },
+    )
+    login = await client.post(
+        "/api/v1/auth/login",
+        json={"username": username, "password": "AdminPass1!"},
+    )
+    body = login.json()
+    return body["access_token"], body["user"]
+
+
+@pytest.fixture
+async def sliced_library_file(db_session):
+    """A library file that passes both of add-to-queue's gates: the filename
+    must look sliced, and the bytes must actually exist under `base_dir` (the
+    route rejects rows whose file is missing from disk)."""
+    from backend.app.models.library import LibraryFile
+
+    rel_path = "archive/library/files/attribution_probe.gcode.3mf"
+    abs_path = Path(app_settings.base_dir) / rel_path
+    abs_path.parent.mkdir(parents=True, exist_ok=True)
+    abs_path.write_bytes(b"probe")
+
+    lib_file = LibraryFile(
+        filename="attribution_probe.gcode.3mf",
+        file_path=rel_path,
+        file_size=5,
+        file_type="3mf",
+    )
+    db_session.add(lib_file)
+    await db_session.commit()
+    await db_session.refresh(lib_file)
+
+    yield lib_file
+
+    abs_path.unlink(missing_ok=True)
+
+
+class TestLibraryAddToQueueAttribution:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_credits_the_authenticated_user(self, async_client: AsyncClient, test_engine, sliced_library_file):
+        """The Library bulk-add is the path a user reaches for when queueing
+        many files at once — exactly the case where losing attribution hurts."""
+        token, user = await _enable_auth_with_admin(async_client, "libqueueadmin")
+
+        response = await async_client.post(
+            "/api/v1/library/files/add-to-queue",
+            json={"file_ids": [sliced_library_file.id]},
+            headers={"Authorization": f"Bearer {token}"},
+        )
+        assert response.status_code == 200
+        added = response.json()["added"]
+        assert len(added) == 1
+
+        item = await _read_item(test_engine, added[0]["queue_item_id"])
+        assert item.created_by_id == user["id"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_auth_disabled_leaves_item_ownerless(
+        self, async_client: AsyncClient, test_engine, sliced_library_file
+    ):
+        """With auth off the permission dep yields None. The row must stay
+        NULL rather than gaining a synthetic owner."""
+        response = await async_client.post(
+            "/api/v1/library/files/add-to-queue",
+            json={"file_ids": [sliced_library_file.id]},
+        )
+        assert response.status_code == 200
+        added = response.json()["added"]
+        assert len(added) == 1
+
+        item = await _read_item(test_engine, added[0]["queue_item_id"])
+        assert item.created_by_id is None
+
+
+class TestWebhookQueueAddAttribution:
+    @pytest.fixture
+    async def printer_and_archive(self, db_session):
+        from backend.app.models.archive import PrintArchive
+        from backend.app.models.printer import Printer
+
+        printer = Printer(
+            name="Webhook Target",
+            ip_address="192.168.2.202",
+            serial_number="00M00A9876543210",
+            access_code="12345678",
+            model="P1S",
+        )
+        archive = PrintArchive(
+            filename="Plate_1.gcode.3mf",
+            print_name="Plate 1",
+            file_path="/tmp/webhook_attribution.3mf",  # nosec B108
+            file_size=1024,
+            content_hash="webhookattributionhash",
+            status="completed",
+        )
+        db_session.add_all([printer, archive])
+        await db_session.commit()
+        await db_session.refresh(printer)
+        await db_session.refresh(archive)
+        return printer, archive
+
+    async def _mint_key(self, db_session, owner_id: int | None) -> str:
+        full_key, key_hash, key_prefix = generate_api_key()
+        db_session.add(
+            APIKey(
+                name="attribution probe",
+                key_hash=key_hash,
+                key_prefix=key_prefix,
+                user_id=owner_id,
+                can_queue=True,
+            )
+        )
+        await db_session.commit()
+        return full_key
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_credits_the_key_owner(self, async_client: AsyncClient, db_session, test_engine, printer_and_archive):
+        printer, archive = printer_and_archive
+
+        owner = User(username="keyowner", password_hash="x", is_active=True)
+        db_session.add(owner)
+        await db_session.commit()
+        await db_session.refresh(owner)
+
+        key = await self._mint_key(db_session, owner.id)
+
+        response = await async_client.post(
+            "/api/v1/webhook/queue/add",
+            json={"printer_id": printer.id, "archive_id": archive.id},
+            headers={"X-API-Key": key},
+        )
+        assert response.status_code == 200
+
+        item = await _read_item(test_engine, response.json()["id"])
+        assert item.created_by_id == owner.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_legacy_ownerless_key_leaves_item_ownerless(
+        self, async_client: AsyncClient, db_session, test_engine, printer_and_archive
+    ):
+        """`APIKey.user_id` is nullable only for keys minted before per-user
+        ownership existed. Those must produce an ownerless row, not a crash."""
+        printer, archive = printer_and_archive
+        key = await self._mint_key(db_session, None)
+
+        response = await async_client.post(
+            "/api/v1/webhook/queue/add",
+            json={"printer_id": printer.id, "archive_id": archive.id},
+            headers={"X-API-Key": key},
+        )
+        assert response.status_code == 200
+
+        item = await _read_item(test_engine, response.json()["id"])
+        assert item.created_by_id is None

+ 123 - 0
frontend/src/__tests__/pages/ProjectDetailPage.test.tsx

@@ -304,4 +304,127 @@ describe('ProjectDetailPage', () => {
       expect(screen.queryByText('Complete Sets')).not.toBeInTheDocument();
     });
   });
+  describe('sub-project roll-up (#1264)', () => {
+    const withChildren = {
+      ...mockProject,
+      // Supplying `stats` opens the cost card, which reads `budget` — the API
+      // always sends the key, so the mock has to as well.
+      budget: null,
+      descendant_count: 2,
+      children: [
+        {
+          id: 2,
+          name: 'Wing',
+          color: '#ff0000',
+          status: 'active',
+          progress_percent: 50,
+          descendant_count: 1,
+          total_archives: 4,
+          completed_prints: 4,
+          total_print_time_hours: 6,
+          total_filament_grams: 250,
+          total_cost: 12.5,
+        },
+      ],
+      stats: {
+        total_archives: 1,
+        total_items: 1,
+        completed_prints: 1,
+        failed_prints: 0,
+        queued_prints: 0,
+        in_progress_prints: 0,
+        total_print_time_hours: 2,
+        total_filament_grams: 100,
+        progress_percent: null,
+        parts_progress_percent: null,
+        estimated_cost: 5,
+        total_energy_kwh: 0,
+        total_energy_cost: 0,
+        remaining_prints: null,
+        remaining_parts: null,
+        bom_total_items: 0,
+        bom_completed_items: 0,
+        bom_cost: 0,
+      },
+      rollup_stats: {
+        total_archives: 5,
+        total_items: 5,
+        completed_prints: 5,
+        failed_prints: 0,
+        queued_prints: 0,
+        in_progress_prints: 0,
+        total_print_time_hours: 8,
+        total_filament_grams: 350,
+        progress_percent: 40,
+        parts_progress_percent: null,
+        estimated_cost: 17.5,
+        total_energy_kwh: 0,
+        total_energy_cost: 0,
+        remaining_prints: 3,
+        remaining_parts: null,
+        bom_total_items: 0,
+        bom_completed_items: 0,
+        bom_cost: 0,
+      },
+    };
+
+    it('shows the whole programme alongside the project own numbers', async () => {
+      server.use(http.get('/api/v1/projects/:id', () => HttpResponse.json(withChildren)));
+
+      render(<ProjectDetailPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Including 2 sub-projects')).toBeInTheDocument();
+      });
+      // Both sets are on screen at once, which is the point — the roll-up must
+      // not quietly replace what the project itself printed.
+      expect(screen.getByText('350g')).toBeInTheDocument();
+      expect(screen.getByText('100g')).toBeInTheDocument();
+    });
+
+    it('stays quiet when the project has no sub-projects', async () => {
+      // Reversion-proof: if the roll-up were computed unconditionally it would
+      // print a second, identical set of figures under every ordinary project.
+      server.use(
+        http.get('/api/v1/projects/:id', () =>
+          HttpResponse.json({ ...withChildren, children: [], descendant_count: 0, rollup_stats: null })
+        )
+      );
+
+      render(<ProjectDetailPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('100g')).toBeInTheDocument();
+      });
+      expect(screen.queryByText(/Including .* sub-projects/)).not.toBeInTheDocument();
+    });
+
+    it('gives each listed sub-project its own branch total', async () => {
+      server.use(http.get('/api/v1/projects/:id', () => HttpResponse.json(withChildren)));
+
+      render(<ProjectDetailPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Wing')).toBeInTheDocument();
+      });
+      expect(screen.getByText('4 jobs')).toBeInTheDocument();
+      expect(screen.getByText('250g')).toBeInTheDocument();
+      expect(screen.getByText('50%')).toBeInTheDocument();
+    });
+
+    it('marks a sub-project that is itself a parent', async () => {
+      // Otherwise its figures look inflated for a single project rather than
+      // covering the branch under it.
+      server.use(http.get('/api/v1/projects/:id', () => HttpResponse.json(withChildren)));
+
+      render(<ProjectDetailPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Wing')).toBeInTheDocument();
+      });
+      const row = screen.getByText('Wing').closest('a');
+      expect(row).not.toBeNull();
+      expect(row!.textContent).toContain('1');
+    });
+  });
 });

+ 190 - 0
frontend/src/__tests__/pages/ProjectsPage.test.tsx

@@ -7,6 +7,7 @@ import { screen, waitFor } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { render } from '../utils';
 import { ProjectsPage, ProjectModal } from '../../pages/ProjectsPage';
+import { eligibleParents } from '../../utils/projectTree';
 import { http, HttpResponse } from 'msw';
 import { server } from '../mocks/server';
 
@@ -466,4 +467,193 @@ describe('ProjectsPage', () => {
       expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ tags: null }));
     });
   });
+  describe('nesting projects under a master project (#1264)', () => {
+    const listItem = (over: Record<string, unknown>) => ({
+      id: 1,
+      name: 'Airframe',
+      description: null,
+      color: '#00ae42',
+      status: 'active',
+      target_count: null,
+      target_parts_count: null,
+      target_sets: null,
+      budget: null,
+      tags: null,
+      due_date: null,
+      priority: 'normal',
+      created_at: '2024-01-01T00:00:00Z',
+      archive_count: 0,
+      total_items: 0,
+      completed_count: 0,
+      failed_count: 0,
+      queue_count: 0,
+      progress_percent: null,
+      parent_id: null,
+      child_count: 0,
+      archives: [],
+      url: null,
+      cover_image_filename: null,
+      ...over,
+    });
+
+    describe('eligibleParents', () => {
+      it('offers every other project when creating a new one', () => {
+        const projects = [listItem({ id: 1 }), listItem({ id: 2, name: 'Wing' })];
+
+        expect(eligibleParents(projects, undefined).map((p) => p.id)).toEqual([1, 2]);
+      });
+
+      it('never offers the project itself', () => {
+        const projects = [listItem({ id: 1 }), listItem({ id: 2, name: 'Wing' })];
+
+        expect(eligibleParents(projects, 1).map((p) => p.id)).toEqual([2]);
+      });
+
+      it('never offers a project already nested underneath', () => {
+        // The API rejects a cycle, so offering one would only produce an error
+        // the user can do nothing about.
+        const projects = [
+          listItem({ id: 1 }),
+          listItem({ id: 2, name: 'Wing', parent_id: 1 }),
+          listItem({ id: 3, name: 'Unrelated' }),
+        ];
+
+        expect(eligibleParents(projects, 1).map((p) => p.id)).toEqual([3]);
+      });
+
+      it('excludes a grandchild listed before its own parent', () => {
+        // Reversion-proof for a single-pass version: the list arrives in update
+        // order, so a grandchild can precede the parent that blocks it.
+        const projects = [
+          listItem({ id: 3, name: 'Spar', parent_id: 2 }),
+          listItem({ id: 2, name: 'Wing', parent_id: 1 }),
+          listItem({ id: 1 }),
+        ];
+
+        expect(eligibleParents(projects, 1)).toEqual([]);
+      });
+    });
+
+    it('sends 0 to clear a parent, because null would read as omitted', async () => {
+      const user = userEvent.setup();
+      const onSave = vi.fn();
+      server.use(http.get('/api/v1/projects/', () => HttpResponse.json([listItem({ id: 9, name: 'Other' })])));
+
+      render(
+        <ProjectModal
+          project={listItem({ id: 1, parent_id: 9 })}
+          onClose={() => {}}
+          onSave={onSave as never}
+          isLoading={false}
+          currencySymbol="EUR"
+          t={((k: string) => k) as never}
+        />,
+      );
+
+      const parentSelect = () =>
+        Array.from(document.querySelectorAll('select')).find((s) =>
+          s.querySelector('option[value=""]'),
+        ) as HTMLSelectElement;
+      await waitFor(() => expect(parentSelect().value).toBe('9'));
+
+      await user.selectOptions(parentSelect(), '');
+      await user.click(screen.getByRole('button', { name: 'common.save' }));
+
+      expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ parent_id: 0 }));
+    });
+
+    it('draws a sub-project inside its parent group, not loose in the grid', async () => {
+      server.use(
+        http.get('/api/v1/projects/', () =>
+          HttpResponse.json([
+            listItem({ id: 1, name: 'Airframe', child_count: 1 }),
+            listItem({ id: 2, name: 'Wing', parent_id: 1 }),
+          ]),
+        ),
+      );
+
+      render(<ProjectsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Sub-projects of Airframe')).toBeInTheDocument();
+      });
+      // Two cards sitting columns apart cannot show that they belong together,
+      // however they are captioned — the child has to be physically inside the
+      // parent's group.
+      const group = screen.getByText('Sub-projects of Airframe').closest('.col-span-full');
+      expect(group).not.toBeNull();
+      expect(group!.textContent).toContain('Wing');
+      // And the caption is then redundant.
+      expect(screen.queryByText('Part of Airframe')).not.toBeInTheDocument();
+    });
+
+    it('keeps a sub-project visible when the filter hides its parent', async () => {
+      server.use(
+        http.get('/api/v1/projects/', ({ request }) => {
+          const status = new URL(request.url).searchParams.get('status');
+          const all = [
+            listItem({ id: 1, name: 'Airframe', status: 'completed', child_count: 1 }),
+            listItem({ id: 2, name: 'Wing', parent_id: 1 }),
+          ];
+          return HttpResponse.json(status ? all.filter((p) => p.status === status) : all);
+        }),
+      );
+
+      // The page opens on the Active filter, which already hides the completed
+      // parent and leaves its active child with nowhere to nest.
+      render(<ProjectsPage />);
+
+      // Nothing to nest under, so it stays a top-level card — and then the
+      // caption is the only thing that can say where it belongs.
+      await waitFor(() => {
+        expect(screen.getByText('Part of Airframe')).toBeInTheDocument();
+      });
+      expect(screen.queryByText('Sub-projects of Airframe')).not.toBeInTheDocument();
+    });
+
+    it('still shows every project when the data holds a parent cycle', async () => {
+      // Grouping hides anything that has a visible parent, and a cycle has no
+      // root — so A -> B -> A would take both projects off the page entirely.
+      // A database written before the API refused a cycle can still hold one.
+      server.use(
+        http.get('/api/v1/projects/', () =>
+          HttpResponse.json([
+            listItem({ id: 1, name: 'Alpha', parent_id: 2 }),
+            listItem({ id: 2, name: 'Beta', parent_id: 1 }),
+          ]),
+        ),
+      );
+
+      render(<ProjectsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Alpha')).toBeInTheDocument();
+      });
+      // Exactly once each: following the cycle round would redraw them.
+      expect(screen.getAllByText('Alpha')).toHaveLength(1);
+      expect(screen.getAllByText('Beta')).toHaveLength(1);
+    });
+
+    it('nests a grandchild under its own parent, not under the top of the tree', async () => {
+      server.use(
+        http.get('/api/v1/projects/', () =>
+          HttpResponse.json([
+            listItem({ id: 1, name: 'Airframe', child_count: 1 }),
+            listItem({ id: 2, name: 'Wing', parent_id: 1, child_count: 1 }),
+            listItem({ id: 3, name: 'Spar', parent_id: 2 }),
+          ]),
+        ),
+      );
+
+      render(<ProjectsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Sub-projects of Wing')).toBeInTheDocument();
+      });
+      const wingGroup = screen.getByText('Sub-projects of Wing').closest('.col-span-full');
+      expect(wingGroup!.textContent).toContain('Spar');
+      // Flattening every descendant onto the root would lose the depth.
+      expect(wingGroup!.textContent).not.toContain('Airframe');
+    });
+  });
 });

+ 15 - 0
frontend/src/api/client.ts

@@ -908,12 +908,21 @@ export interface ProjectStats {
   bom_cost: number;
 }
 
+// A sub-project as listed on its parent's page. The figures cover the child's
+// own subtree, so the listed rows add up to the parent's roll-up minus the
+// parent's own prints (#1264).
 export interface ProjectChildPreview {
   id: number;
   name: string;
   color: string | null;
   status: string;
   progress_percent: number | null;
+  descendant_count: number;
+  total_archives: number;
+  completed_prints: number;
+  total_print_time_hours: number;
+  total_filament_grams: number;
+  total_cost: number;  // Filament + energy + BOM, matching the parent's cost card
 }
 
 export interface Project {
@@ -936,9 +945,13 @@ export interface Project {
   parent_id: number | null;
   parent_name: string | null;
   children: ProjectChildPreview[];
+  descendant_count: number;  // Sub-projects at any depth beneath this one (#1264)
   created_at: string;
   updated_at: string;
   stats?: ProjectStats;
+  // 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 | null;
   url: string | null;  // External link rendered next to project name on the card (#1155)
   cover_image_filename: string | null;  // Filename within project attachments dir (#1155)
 }
@@ -985,6 +998,8 @@ export interface ProjectListItem {
   failed_count: number;  // Sum of quantities for failed prints
   queue_count: number;
   progress_percent: number | null;  // Plates progress
+  parent_id: number | null;  // #1264 — set when this is a sub-project
+  child_count: number;  // #1264 — direct sub-projects only
   archives: ArchivePreview[];
   url: string | null;  // #1155
   cover_image_filename: string | null;  // #1155

+ 12 - 0
frontend/src/i18n/locales/de.ts

@@ -3952,6 +3952,12 @@ export default {
 
   // Projects
   projects: {
+    parentLabel: 'Übergeordnetes Projekt',
+    parentNone: 'Keines (Hauptprojekt)',
+    parentHint: 'Dieses Projekt einem anderen unterordnen, damit seine Zahlen in ein Hauptprojekt einfließen',
+    partOf: 'Teil von {{name}}',
+    subProjectCount: '{{count}} Unterprojekte',
+    subProjectsOf: 'Unterprojekte von {{name}}',
     title: 'Projekte',
     subtitle: 'Organisieren und verfolgen Sie Ihre 3D-Druckprojekte',
     newProject: 'Neues Projekt',
@@ -4099,6 +4105,12 @@ export default {
     },
     subProjects: {
       title: 'Unterprojekte ({{count}})',
+      jobs: '{{count}} Aufträge',
+    },
+    rollup: {
+      title: 'Inklusive {{count}} Unterprojekten',
+      progress: 'Gesamtfortschritt',
+      percentComplete: '{{percent}}% abgeschlossen',
     },
     notes: {
       title: 'Notizen',

+ 12 - 0
frontend/src/i18n/locales/en.ts

@@ -3981,6 +3981,12 @@ export default {
 
   // Projects
   projects: {
+    parentLabel: 'Parent project',
+    parentNone: 'None (top-level project)',
+    parentHint: 'Nest this project under another one so its figures roll up into a master project',
+    partOf: 'Part of {{name}}',
+    subProjectCount: '{{count}} sub-projects',
+    subProjectsOf: 'Sub-projects of {{name}}',
     title: 'Projects',
     subtitle: 'Organize and track your 3D printing projects',
     newProject: 'New Project',
@@ -4128,6 +4134,12 @@ export default {
     },
     subProjects: {
       title: 'Sub-projects ({{count}})',
+      jobs: '{{count}} jobs',
+    },
+    rollup: {
+      title: 'Including {{count}} sub-projects',
+      progress: 'Overall progress',
+      percentComplete: '{{percent}}% complete',
     },
     notes: {
       title: 'Notes',

+ 12 - 0
frontend/src/i18n/locales/es.ts

@@ -3954,6 +3954,12 @@ export default {
 
   // Projects
   projects: {
+    parentLabel: 'Proyecto principal',
+    parentNone: 'Ninguno (proyecto de nivel superior)',
+    parentHint: 'Anida este proyecto en otro para que sus cifras se sumen en un proyecto maestro',
+    partOf: 'Parte de {{name}}',
+    subProjectCount: '{{count}} subproyectos',
+    subProjectsOf: 'Subproyectos de {{name}}',
     title: 'Proyectos',
     subtitle: 'Organice y haga el seguimiento de sus proyectos de impresión 3D',
     newProject: 'Nuevo proyecto',
@@ -4101,6 +4107,12 @@ export default {
     },
     subProjects: {
       title: 'Subproyectos ({{count}})',
+      jobs: '{{count}} trabajos',
+    },
+    rollup: {
+      title: 'Incluyendo {{count}} subproyectos',
+      progress: 'Progreso general',
+      percentComplete: '{{percent}}% completado',
     },
     notes: {
       title: 'Notas',

+ 12 - 0
frontend/src/i18n/locales/fr.ts

@@ -3941,6 +3941,12 @@ export default {
 
   // Projects
   projects: {
+    parentLabel: 'Projet parent',
+    parentNone: 'Aucun (projet de premier niveau)',
+    parentHint: 'Imbriquer ce projet dans un autre pour que ses chiffres remontent vers un projet principal',
+    partOf: 'Fait partie de {{name}}',
+    subProjectCount: '{{count}} sous-projets',
+    subProjectsOf: 'Sous-projets de {{name}}',
     title: 'Projets',
     subtitle: 'Suivez vos projets d\'impression 3D',
     newProject: 'Nouveau Projet',
@@ -4088,6 +4094,12 @@ export default {
     },
     subProjects: {
       title: 'Sous-projets ({{count}})',
+      jobs: '{{count}} travaux',
+    },
+    rollup: {
+      title: 'Y compris {{count}} sous-projets',
+      progress: 'Progression globale',
+      percentComplete: '{{percent}}% terminé',
     },
     notes: {
       title: 'Notes',

+ 12 - 0
frontend/src/i18n/locales/it.ts

@@ -3940,6 +3940,12 @@ export default {
 
   // Projects
   projects: {
+    parentLabel: 'Progetto principale',
+    parentNone: 'Nessuno (progetto di primo livello)',
+    parentHint: 'Annida questo progetto in un altro perché i suoi numeri confluiscano in un progetto principale',
+    partOf: 'Parte di {{name}}',
+    subProjectCount: '{{count}} sotto-progetti',
+    subProjectsOf: 'Sotto-progetti di {{name}}',
     title: 'Progetti',
     subtitle: 'Organizza e traccia i tuoi progetti di stampa 3D',
     newProject: 'Nuovo progetto',
@@ -4087,6 +4093,12 @@ export default {
     },
     subProjects: {
       title: 'Sotto-progetti ({{count}})',
+      jobs: '{{count}} lavori',
+    },
+    rollup: {
+      title: 'Inclusi {{count}} sotto-progetti',
+      progress: 'Avanzamento complessivo',
+      percentComplete: '{{percent}}% completato',
     },
     notes: {
       title: 'Note',

+ 12 - 0
frontend/src/i18n/locales/ja.ts

@@ -3952,6 +3952,12 @@ export default {
 
   // Projects
   projects: {
+    parentLabel: '親プロジェクト',
+    parentNone: 'なし(トップレベルプロジェクト)',
+    parentHint: 'このプロジェクトを別のプロジェクトの下に配置すると、数値がマスタープロジェクトに集計されます',
+    partOf: '{{name}} の一部',
+    subProjectCount: 'サブプロジェクト {{count}} 件',
+    subProjectsOf: '{{name}} のサブプロジェクト',
     title: 'プロジェクト',
     subtitle: '印刷プロジェクトを管理',
     newProject: '新規プロジェクト',
@@ -4099,6 +4105,12 @@ export default {
     },
     subProjects: {
       title: 'サブプロジェクト ({{count}})',
+      jobs: 'ジョブ {{count}} 件',
+    },
+    rollup: {
+      title: 'サブプロジェクト {{count}} 件を含む',
+      progress: '全体の進捗',
+      percentComplete: '{{percent}}% 完了',
     },
     notes: {
       title: 'メモ',

+ 13 - 1
frontend/src/i18n/locales/ko.ts

@@ -3759,6 +3759,12 @@ export default {
     }
   },
   projects: {
+    parentLabel: '상위 프로젝트',
+    parentNone: '없음 (최상위 프로젝트)',
+    parentHint: '이 프로젝트를 다른 프로젝트 아래에 두면 수치가 마스터 프로젝트로 합산됩니다',
+    partOf: '{{name}}의 일부',
+    subProjectCount: '하위 프로젝트 {{count}}개',
+    subProjectsOf: '{{name}}의 하위 프로젝트',
     title: '프로젝트',
     subtitle: '3D 인쇄 프로젝트 정리 및 추적',
     newProject: '새 프로젝트',
@@ -3897,7 +3903,13 @@ export default {
       remaining: '남은 예산'
     },
     subProjects: {
-      title: '하위 프로젝트 ({{count}})'
+      title: '하위 프로젝트 ({{count}})',
+      jobs: '작업 {{count}}개',
+    },
+    rollup: {
+      title: '하위 프로젝트 {{count}}개 포함',
+      progress: '전체 진행률',
+      percentComplete: '{{percent}}% 완료',
     },
     notes: {
       title: '메모',

+ 12 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -3940,6 +3940,12 @@ export default {
 
   // Projects
   projects: {
+    parentLabel: 'Projeto principal',
+    parentNone: 'Nenhum (projeto de nível superior)',
+    parentHint: 'Aninhe este projeto em outro para que seus números sejam somados em um projeto mestre',
+    partOf: 'Parte de {{name}}',
+    subProjectCount: '{{count}} sub-projetos',
+    subProjectsOf: 'Sub-projetos de {{name}}',
     title: 'Projetos',
     subtitle: 'Organize e acompanhe seus projetos de impressão 3D',
     newProject: 'Novo Projeto',
@@ -4087,6 +4093,12 @@ export default {
     },
     subProjects: {
       title: 'Sub-projetos ({{count}})',
+      jobs: '{{count}} trabalhos',
+    },
+    rollup: {
+      title: 'Incluindo {{count}} sub-projetos',
+      progress: 'Progresso geral',
+      percentComplete: '{{percent}}% concluído',
     },
     notes: {
       title: 'Notas',

+ 12 - 0
frontend/src/i18n/locales/ru.ts

@@ -3751,6 +3751,12 @@ export default {
     },
   },
   projects: {
+    parentLabel: "Родительский проект",
+    parentNone: "Нет (проект верхнего уровня)",
+    parentHint: "Вложите этот проект в другой, чтобы его показатели суммировались в главном проекте",
+    partOf: "Часть проекта {{name}}",
+    subProjectCount: "Подпроектов: {{count}}",
+    subProjectsOf: "Подпроекты проекта {{name}}",
     title: "Проекты",
     subtitle: "Организация и отслеживание проектов 3D-печати",
     newProject: "Новый проект",
@@ -3890,6 +3896,12 @@ export default {
     },
     subProjects: {
       title: "Подпроекты ({{count}})",
+      jobs: "Заданий: {{count}}",
+    },
+    rollup: {
+      title: "Включая подпроекты: {{count}}",
+      progress: "Общий прогресс",
+      percentComplete: "Завершено {{percent}}%",
     },
     notes: {
       title: "Заметки",

+ 12 - 0
frontend/src/i18n/locales/tr.ts

@@ -3947,6 +3947,12 @@ export default {
 
   // Projeler
   projects: {
+    parentLabel: 'Üst proje',
+    parentNone: 'Yok (üst düzey proje)',
+    parentHint: 'Bu projeyi başka bir projenin altına yerleştirin, böylece rakamları ana projede toplanır',
+    partOf: '{{name}} projesinin parçası',
+    subProjectCount: '{{count}} alt proje',
+    subProjectsOf: '{{name}} alt projeleri',
     title: 'Projeler',
     subtitle: '3D baskı projelerinizi organize edin ve takip edin',
     newProject: 'Yeni Proje',
@@ -4088,6 +4094,12 @@ export default {
     },
     subProjects: {
       title: 'Alt projeler ({{count}})',
+      jobs: '{{count}} iş',
+    },
+    rollup: {
+      title: '{{count}} alt proje dahil',
+      progress: 'Genel ilerleme',
+      percentComplete: '%{{percent}} tamamlandı',
     },
     notes: {
       title: 'Notlar',

+ 12 - 0
frontend/src/i18n/locales/uk.ts

@@ -3980,6 +3980,12 @@ export default {
 
   // Projects
   projects: {
+    parentLabel: "Батьківський проєкт",
+    parentNone: "Немає (проєкт верхнього рівня)",
+    parentHint: "Вкладіть цей проєкт в інший, щоб його показники підсумовувалися в головному проєкті",
+    partOf: "Частина проєкту {{name}}",
+    subProjectCount: "Підпроєктів: {{count}}",
+    subProjectsOf: "Підпроєкти проєкту {{name}}",
     title: "Проєкти",
     subtitle: "Організовуйте та відстежуйте свої проєкти 3D-друку",
     newProject: "Новий проєкт",
@@ -4127,6 +4133,12 @@ export default {
     },
     subProjects: {
       title: "Підпроєкти ({{count}})",
+      jobs: "Завдань: {{count}}",
+    },
+    rollup: {
+      title: "Разом із підпроєктами: {{count}}",
+      progress: "Загальний прогрес",
+      percentComplete: "Завершено {{percent}}%",
     },
     notes: {
       title: "Примітки",

+ 12 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -3940,6 +3940,12 @@ export default {
 
   // Projects
   projects: {
+    parentLabel: '父项目',
+    parentNone: '无(顶级项目)',
+    parentHint: '将此项目嵌套在另一个项目下,其数据将汇总到主项目中',
+    partOf: '属于 {{name}}',
+    subProjectCount: '{{count}} 个子项目',
+    subProjectsOf: '{{name}} 的子项目',
     title: '项目',
     subtitle: '组织和跟踪您的 3D 打印项目',
     newProject: '新建项目',
@@ -4087,6 +4093,12 @@ export default {
     },
     subProjects: {
       title: '子项目 ({{count}})',
+      jobs: '{{count}} 个任务',
+    },
+    rollup: {
+      title: '包含 {{count}} 个子项目',
+      progress: '总体进度',
+      percentComplete: '已完成 {{percent}}%',
     },
     notes: {
       title: '备注',

+ 12 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -3940,6 +3940,12 @@ export default {
 
   // Projects
   projects: {
+    parentLabel: '父專案',
+    parentNone: '無(頂層專案)',
+    parentHint: '將此專案巢狀置於另一個專案下,其數據將彙總至主專案',
+    partOf: '屬於 {{name}}',
+    subProjectCount: '{{count}} 個子專案',
+    subProjectsOf: '{{name}} 的子專案',
     title: '專案',
     subtitle: '組織和追蹤您的 3D 列印專案',
     newProject: '新增專案',
@@ -4087,6 +4093,12 @@ export default {
     },
     subProjects: {
       title: '子專案 ({{count}})',
+      jobs: '{{count}} 個任務',
+    },
+    rollup: {
+      title: '包含 {{count}} 個子專案',
+      progress: '整體進度',
+      percentComplete: '已完成 {{percent}}%',
     },
     notes: {
       title: '備註',

+ 109 - 10
frontend/src/pages/ProjectDetailPage.tsx

@@ -682,6 +682,80 @@ export function ProjectDetailPage() {
         </div>
       )}
 
+      {/* #1264: the whole programme in one place. Deliberately a separate card
+          from the stats grid above — the figures there are this project's own
+          prints, and the two must not read as one set. The API sends
+          rollup_stats only when there is a sub-project, so there is never an
+          identical pair on screen. */}
+      {project.rollup_stats && (
+        <Card>
+          <CardContent className="p-4">
+            <h2 className="text-lg font-semibold text-white flex items-center gap-2 mb-3">
+              <FolderTree className="w-5 h-5" />
+              {t('projectDetail.rollup.title', { count: project.descendant_count })}
+            </h2>
+            <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
+              <div>
+                <p className="text-xs text-bambu-gray uppercase">{t('projectDetail.stats.printJobs')}</p>
+                <p className="text-lg font-semibold text-white">{project.rollup_stats.total_archives}</p>
+                <p className="text-sm text-bambu-gray">
+                  {t('projectDetail.stats.partsPrinted', { count: project.rollup_stats.completed_prints })}
+                </p>
+              </div>
+              <div>
+                <p className="text-xs text-bambu-gray uppercase">{t('projectDetail.stats.printTime')}</p>
+                <p className="text-lg font-semibold text-white">
+                  {formatDurationFromHours(project.rollup_stats.total_print_time_hours)}
+                </p>
+              </div>
+              <div>
+                <p className="text-xs text-bambu-gray uppercase">{t('projectDetail.stats.filamentUsed')}</p>
+                <p className="text-lg font-semibold text-white">
+                  {formatFilament(project.rollup_stats.total_filament_grams)}
+                </p>
+              </div>
+              {(() => {
+                const rollupCost =
+                  project.rollup_stats.estimated_cost +
+                  project.rollup_stats.total_energy_cost +
+                  project.rollup_stats.bom_cost;
+                if (rollupCost <= 0) return null;
+                return (
+                  <div>
+                    <p className="text-xs text-bambu-gray uppercase">{t('projectDetail.cost.totalCost')}</p>
+                    <p className="text-lg font-semibold text-bambu-green">
+                      {currency}{rollupCost.toFixed(2)}
+                    </p>
+                  </div>
+                );
+              })()}
+            </div>
+            {project.rollup_stats.progress_percent !== null && (
+              <div className="mt-4">
+                <div className="flex items-center justify-between mb-2">
+                  <span className="text-sm text-bambu-gray">{t('projectDetail.rollup.progress')}</span>
+                  <span className="text-sm font-medium text-white">
+                    {t('projectDetail.rollup.percentComplete', {
+                      percent: project.rollup_stats.progress_percent.toFixed(0),
+                    })}
+                  </span>
+                </div>
+                <div className="h-3 bg-bambu-dark rounded-full overflow-hidden">
+                  <div
+                    className="h-full transition-all duration-500"
+                    style={{
+                      width: `${Math.min(project.rollup_stats.progress_percent, 100)}%`,
+                      backgroundColor:
+                        project.rollup_stats.progress_percent >= 100 ? '#22c55e' : project.color || '#6b7280',
+                    }}
+                  />
+                </div>
+              </div>
+            )}
+          </CardContent>
+        </Card>
+      )}
+
       {/* Cost tracking */}
       {stats && (() => {
         const totalCost = stats.estimated_cost + stats.total_energy_cost + stats.bom_cost;
@@ -760,27 +834,48 @@ export function ProjectDetailPage() {
                 <Link
                   key={child.id}
                   to={`/projects/${child.id}`}
-                  className="flex items-center justify-between p-3 bg-bambu-dark rounded-lg hover:bg-bambu-dark-tertiary transition-colors"
+                  className="flex items-center justify-between gap-4 p-3 bg-bambu-dark rounded-lg hover:bg-bambu-dark-tertiary transition-colors"
                 >
-                  <div className="flex items-center gap-3">
+                  <div className="flex items-center gap-3 min-w-0">
                     <div
-                      className="w-3 h-3 rounded-full"
+                      className="w-3 h-3 rounded-full flex-shrink-0"
                       style={{ backgroundColor: child.color || '#6b7280' }}
                     />
-                    <span className="text-white">{child.name}</span>
-                    <span className={`text-xs px-2 py-0.5 rounded ${
+                    <span className="text-white truncate">{child.name}</span>
+                    <span className={`text-xs px-2 py-0.5 rounded flex-shrink-0 ${
                       child.status === 'completed' ? 'bg-status-ok/20 text-status-ok' :
                       child.status === 'archived' ? 'bg-bambu-gray/20 text-bambu-gray' :
                       'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400'
                     }`}>
                       {child.status}
                     </span>
+                    {/* A branch of its own, so its figures cover more than the
+                        one project named on this row (#1264). */}
+                    {child.descendant_count > 0 && (
+                      <span className="text-xs text-bambu-gray flex-shrink-0 inline-flex items-center gap-1">
+                        <FolderTree className="w-3 h-3" />
+                        {child.descendant_count}
+                      </span>
+                    )}
+                  </div>
+                  {/* Each row carries its own branch's roll-up, so the rows add
+                      up to the card above minus this project's own prints. */}
+                  <div className="flex items-center gap-4 text-sm text-bambu-gray flex-shrink-0">
+                    {child.total_archives > 0 && (
+                      <span className="hidden sm:inline">
+                        {t('projectDetail.subProjects.jobs', { count: child.total_archives })}
+                      </span>
+                    )}
+                    {child.total_filament_grams > 0 && (
+                      <span className="hidden md:inline">{formatFilament(child.total_filament_grams)}</span>
+                    )}
+                    {child.total_cost > 0 && (
+                      <span className="hidden md:inline">{currency}{child.total_cost.toFixed(2)}</span>
+                    )}
+                    {child.progress_percent !== null && (
+                      <span>{child.progress_percent.toFixed(0)}%</span>
+                    )}
                   </div>
-                  {child.progress_percent !== null && (
-                    <span className="text-sm text-bambu-gray">
-                      {child.progress_percent.toFixed(0)}%
-                    </span>
-                  )}
                 </Link>
               ))}
             </div>
@@ -1474,6 +1569,10 @@ export function ProjectDetailPage() {
             failed_count: stats?.failed_prints || 0,
             queue_count: stats?.queued_prints || 0,
             progress_percent: stats?.progress_percent || null,
+            // Required by ProjectListItem, but nothing in the dialog reads it —
+            // the parent picker works off the project list it fetches itself.
+            // Guarded like every other read of `children` on this page (#1264).
+            child_count: project.children?.length ?? 0,
             archives: [],
           }}
           onClose={() => setShowEditModal(false)}

+ 145 - 12
frontend/src/pages/ProjectsPage.tsx

@@ -13,6 +13,7 @@ import {
   ListTodo,
   Package,
   Layers,
+  FolderTree,
   Clock,
   CheckCircle2,
   AlertTriangle,
@@ -31,6 +32,7 @@ import { ConfirmModal } from '../components/ConfirmModal';
 import { useToast } from '../contexts/ToastContext';
 import { useAuth } from '../contexts/AuthContext';
 import { getCurrencySymbol } from '../utils/currency';
+import { eligibleParents } from '../utils/projectTree';
 
 const PROJECT_COLORS = [
   '#ef4444', // red
@@ -69,7 +71,17 @@ export function ProjectModal({ project, onClose, onSave, isLoading, currencySymb
   const [budget, setBudget] = useState(project?.budget?.toString() || '');
   const [url, setUrl] = useState(project?.url || '');
   const [urlError, setUrlError] = useState<string | null>(null);
+  const [parentId, setParentId] = useState<number | null>(project?.parent_id ?? null);
   const queryClient = useQueryClient();
+
+  // Unfiltered on purpose: a completed or archived project is still a legal
+  // parent, and the picker offering fewer options than the list does would be
+  // hard to explain.
+  const { data: allProjects } = useQuery({
+    queryKey: ['projects', undefined],
+    queryFn: () => api.getProjects(),
+  });
+  const parentOptions = eligibleParents(allProjects || [], project?.id);
   const [coverImageFilename, setCoverImageFilename] = useState(project?.cover_image_filename || null);
   const coverFileInputRef = useRef<HTMLInputElement>(null);
   const [coverUploading, setCoverUploading] = useState(false);
@@ -132,6 +144,9 @@ export function ProjectModal({ project, onClose, onSave, isLoading, currencySymb
       // Pydantic accepts null to clear the URL; an empty string would fail the
       // http(s) prefix validator.
       url: project ? (trimmedUrl || null) : (trimmedUrl || undefined),
+      // The API reads 0 as "remove the parent" — null would be indistinguishable
+      // from the field having been omitted (#1264).
+      parent_id: project ? (parentId ?? 0) : (parentId ?? undefined),
       ...(project && { status }),
     });
   };
@@ -195,6 +210,26 @@ export function ProjectModal({ project, onClose, onSave, isLoading, currencySymb
             {urlError && <p className="text-xs text-red-600 dark:text-red-400 mt-1">{urlError}</p>}
           </div>
 
+          {/* #1264: Nest this project under another one */}
+          <div>
+            <label className="block text-sm font-medium text-white mb-1">
+              {t('projects.parentLabel')}
+            </label>
+            <select
+              value={parentId ?? ''}
+              onChange={(e) => setParentId(e.target.value ? parseInt(e.target.value, 10) : null)}
+              className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white focus:outline-none focus:border-bambu-green"
+            >
+              <option value="">{t('projects.parentNone')}</option>
+              {parentOptions.map((option) => (
+                <option key={option.id} value={option.id}>
+                  {option.name}
+                </option>
+              ))}
+            </select>
+            <p className="text-xs text-bambu-gray mt-1">{t('projects.parentHint')}</p>
+          </div>
+
           {/* #1155: Cover image — only available when editing an existing project,
               since uploading needs a project_id. New projects can add it after save. */}
           {project && (
@@ -510,6 +545,7 @@ function ProjectCoverThumbnail({
 
 interface ProjectCardProps {
   project: ProjectListItem;
+  parentName?: string;  // #1264 — resolved by the caller, which holds the whole list
   onClick: () => void;
   onEdit: () => void;
   onDelete: () => void;
@@ -517,7 +553,7 @@ interface ProjectCardProps {
   t: TFunction;
 }
 
-function ProjectCard({ project, onClick, onEdit, onDelete, hasPermission, t }: ProjectCardProps) {
+function ProjectCard({ project, parentName, onClick, onEdit, onDelete, hasPermission, t }: ProjectCardProps) {
   // Plates progress: archive_count / target_count
   const platesProgressPercent = project.target_count
     ? Math.round((project.archive_count / project.target_count) * 100)
@@ -621,7 +657,24 @@ function ProjectCard({ project, onClick, onEdit, onDelete, hasPermission, t }: P
                     {t('projects.statusArchived')}
                   </span>
                 )}
+                {/* #1264: without these, eight sub-projects of one programme
+                    look like eight unrelated projects in the grid. */}
+                {project.child_count > 0 && (
+                  <span
+                    className="text-xs bg-bambu-dark text-bambu-gray px-2 py-0.5 rounded-full whitespace-nowrap inline-flex items-center gap-1"
+                    title={t('projects.subProjectCount', { count: project.child_count })}
+                  >
+                    <FolderTree className="w-3 h-3" />
+                    {project.child_count}
+                  </span>
+                )}
               </div>
+              {parentName && (
+                <p className="text-xs text-bambu-gray mt-1 flex items-center gap-1">
+                  <Layers className="w-3 h-3 flex-shrink-0" />
+                  <span className="truncate">{t('projects.partOf', { name: parentName })}</span>
+                </p>
+              )}
               {project.description && (
                 <p className="text-sm text-bambu-gray/70 mt-1 line-clamp-1">
                   {project.description}
@@ -890,6 +943,96 @@ export function ProjectsPage() {
     queryFn: () => api.getProjects(statusFilter === 'all' ? undefined : statusFilter),
   });
 
+  // Parent names come from the unfiltered list, so a sub-project still says
+  // what it belongs to when the status filter has hidden its parent (#1264).
+  // Same query key the parent picker uses — one request shared between them,
+  // and the same one as above whenever the filter is 'all'.
+  const { data: allProjects } = useQuery({
+    queryKey: ['projects', undefined],
+    queryFn: () => api.getProjects(),
+  });
+  const namesById = new Map((allProjects || []).map((p) => [p.id, p.name]));
+
+  // A sub-project is drawn inside its parent's group rather than as another
+  // card somewhere in the grid — two cards that belong together are not
+  // something a caption can convey when they sit columns apart (#1264).
+  const visible = projects || [];
+  const visibleIds = new Set(visible.map((p) => p.id));
+  const childrenByParent = new Map<number, ProjectListItem[]>();
+  for (const project of visible) {
+    // Parent hidden by the status filter: the child has nothing to nest under
+    // here, so it stays a top-level card and keeps its "part of" caption.
+    if (project.parent_id !== null && visibleIds.has(project.parent_id)) {
+      const siblings = childrenByParent.get(project.parent_id) || [];
+      siblings.push(project);
+      childrenByParent.set(project.parent_id, siblings);
+    }
+  }
+  // Everything drawn at the top of the grid, in order. Roots first, then any
+  // project the roots cannot reach: a database written before the API refused
+  // A -> B -> A can still hold a cycle, and a cycle has no root, so filtering
+  // on "has no visible parent" alone would drop every project in it off the
+  // page. Each entry point marks its whole branch as drawn, so nothing appears
+  // twice either.
+  const drawn = new Set<number>();
+  const markDrawn = (id: number) => {
+    if (drawn.has(id)) return;
+    drawn.add(id);
+    for (const child of childrenByParent.get(id) || []) markDrawn(child.id);
+  };
+  const topLevel: ProjectListItem[] = [];
+  for (const project of visible) {
+    if (project.parent_id !== null && visibleIds.has(project.parent_id)) continue;
+    topLevel.push(project);
+    markDrawn(project.id);
+  }
+  for (const project of visible) {
+    if (drawn.has(project.id)) continue;
+    topLevel.push(project);
+    markDrawn(project.id);
+  }
+
+  const renderProjectTree = (project: ProjectListItem, depth: number, seen: Set<number>) => {
+    const card = (
+      <ProjectCard
+        project={project}
+        // Only when it is detached from its parent — inside the group the
+        // nesting is already visible, and the caption would be noise.
+        parentName={depth === 0 && project.parent_id !== null ? namesById.get(project.parent_id) : undefined}
+        onClick={() => handleClick(project)}
+        onEdit={() => handleEdit(project)}
+        onDelete={() => handleDeleteClick(project.id)}
+        hasPermission={hasPermission}
+        t={t}
+      />
+    );
+
+    // Drop any child that is already an ancestor on this path. A database
+    // written before the API refused A -> B -> A can still hold a cycle, and
+    // following one would draw the same project over and over.
+    const descended = new Set(seen).add(project.id);
+    const children = (childrenByParent.get(project.id) || []).filter((c) => !descended.has(c.id));
+    if (children.length === 0) return <div key={project.id}>{card}</div>;
+
+    return (
+      <div key={project.id} className="col-span-full space-y-4">
+        {card}
+        <div
+          className="ml-4 md:ml-8 pl-4 md:pl-6 border-l-2 rounded-l space-y-4"
+          style={{ borderColor: project.color || '#6b7280' }}
+        >
+          <p className="text-xs uppercase tracking-wide text-bambu-gray flex items-center gap-1.5">
+            <FolderTree className="w-3.5 h-3.5" />
+            {t('projects.subProjectsOf', { name: project.name })}
+          </p>
+          <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
+            {children.map((child) => renderProjectTree(child, depth + 1, descended))}
+          </div>
+        </div>
+      </div>
+    );
+  };
+
   const createMutation = useMutation({
     mutationFn: (data: ProjectCreate) => api.createProject(data),
     onSuccess: () => {
@@ -1159,17 +1302,7 @@ export function ProjectsPage() {
         </div>
       ) : (
         <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-8">
-          {projects?.map((project) => (
-            <ProjectCard
-              key={project.id}
-              project={project}
-              onClick={() => handleClick(project)}
-              onEdit={() => handleEdit(project)}
-              onDelete={() => handleDeleteClick(project.id)}
-              hasPermission={hasPermission}
-              t={t}
-            />
-          ))}
+          {topLevel.map((project) => renderProjectTree(project, 0, new Set()))}
         </div>
       )}
 

+ 30 - 0
frontend/src/utils/projectTree.ts

@@ -0,0 +1,30 @@
+import type { ProjectListItem } from '../api/client';
+
+/**
+ * Projects that may legally become `projectId`'s parent (#1264).
+ *
+ * Its own descendants are excluded as well as itself: nesting a project under
+ * something already beneath it makes a cycle, which the API rejects anyway, so
+ * offering it would only produce an error the user cannot act on. Walked from
+ * the flat list rather than fetched, since every row carries its `parent_id`.
+ */
+export function eligibleParents(
+  projects: ProjectListItem[],
+  projectId: number | undefined,
+): ProjectListItem[] {
+  if (projectId === undefined) return projects;
+  const blocked = new Set([projectId]);
+  // Repeat until nothing new is blocked: the list is in no particular order, so
+  // a grandchild can appear before its parent has been blocked.
+  let grew = true;
+  while (grew) {
+    grew = false;
+    for (const candidate of projects) {
+      if (candidate.parent_id !== null && blocked.has(candidate.parent_id) && !blocked.has(candidate.id)) {
+        blocked.add(candidate.id);
+        grew = true;
+      }
+    }
+  }
+  return projects.filter((p) => !blocked.has(p.id));
+}