diff --git a/backend/app/api/routes/library.py b/backend/app/api/routes/library.py index 59737894e..c338d61c2 100644 --- a/backend/app/api/routes/library.py +++ b/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) diff --git a/backend/app/api/routes/projects.py b/backend/app/api/routes/projects.py index 7b0d2e041..08414a591 100644 --- a/backend/app/api/routes/projects.py +++ b/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) + 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) - # Count queued items - queued_result = await db.execute( - select(func.count(PrintQueueItem.id)).where( - PrintQueueItem.project_id == project_id, PrintQueueItem.status == "pending" - ) - ) - queued_prints = queued_result.scalar() 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" - ) - ) - in_progress_prints = in_progress_result.scalar() or 0 - - # 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) - - # 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( + 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) + ) + 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) + + 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 == project_id) + ) + .where(ProjectBOMItem.project_id.in_(list(totals))) + .group_by(ProjectBOMItem.project_id) ) - bom_stats = bom_result.first() + 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((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((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"} diff --git a/backend/app/api/routes/webhook.py b/backend/app/api/routes/webhook.py index 8912b77f0..4804de011 100644 --- a/backend/app/api/routes/webhook.py +++ b/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() diff --git a/backend/app/schemas/project.py b/backend/app/schemas/project.py index 0bbbe9ad6..411620359 100644 --- a/backend/app/schemas/project.py +++ b/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 diff --git a/backend/tests/integration/test_projects_api.py b/backend/tests/integration/test_projects_api.py index 15792f40d..d387dafab 100644 --- a/backend/tests/integration/test_projects_api.py +++ b/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 diff --git a/backend/tests/integration/test_queue_creation_attribution.py b/backend/tests/integration/test_queue_creation_attribution.py new file mode 100644 index 000000000..8dab62d0e --- /dev/null +++ b/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 diff --git a/frontend/src/__tests__/pages/ProjectDetailPage.test.tsx b/frontend/src/__tests__/pages/ProjectDetailPage.test.tsx index 2c613cb35..16b81fe1f 100644 --- a/frontend/src/__tests__/pages/ProjectDetailPage.test.tsx +++ b/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(); + + 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(); + + 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(); + + 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(); + + await waitFor(() => { + expect(screen.getByText('Wing')).toBeInTheDocument(); + }); + const row = screen.getByText('Wing').closest('a'); + expect(row).not.toBeNull(); + expect(row!.textContent).toContain('1'); + }); + }); }); diff --git a/frontend/src/__tests__/pages/ProjectsPage.test.tsx b/frontend/src/__tests__/pages/ProjectsPage.test.tsx index 3d5bdd918..e1f7336f3 100644 --- a/frontend/src/__tests__/pages/ProjectsPage.test.tsx +++ b/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) => ({ + 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( + {}} + 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(); + + 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(); + + // 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(); + + 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(); + + 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'); + }); + }); }); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index faa437c83..d786ce158 100644 --- a/frontend/src/api/client.ts +++ b/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 diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 0de5d41cd..a85fe2719 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/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', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 05dc1e586..1845c230f 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/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', diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts index d567274b5..21a41218a 100644 --- a/frontend/src/i18n/locales/es.ts +++ b/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', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index fea62ea37..5bfde90a7 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/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', diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index 505d265dc..532c4029b 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/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', diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index 5d83574fb..22be478f5 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/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: 'メモ', diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts index 97baf79a7..ad8bcaa61 100644 --- a/frontend/src/i18n/locales/ko.ts +++ b/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: '메모', diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index 976644672..241787326 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/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', diff --git a/frontend/src/i18n/locales/ru.ts b/frontend/src/i18n/locales/ru.ts index 499b83d59..5a77302a4 100644 --- a/frontend/src/i18n/locales/ru.ts +++ b/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: "Заметки", diff --git a/frontend/src/i18n/locales/tr.ts b/frontend/src/i18n/locales/tr.ts index 3b6686adc..94e3c0d56 100644 --- a/frontend/src/i18n/locales/tr.ts +++ b/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', diff --git a/frontend/src/i18n/locales/uk.ts b/frontend/src/i18n/locales/uk.ts index c6dc50a75..e0513d59d 100644 --- a/frontend/src/i18n/locales/uk.ts +++ b/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: "Примітки", diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index c91fbed5c..f4d6139b9 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/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: '备注', diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index d092e4497..e125647fb 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/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: '備註', diff --git a/frontend/src/pages/ProjectDetailPage.tsx b/frontend/src/pages/ProjectDetailPage.tsx index 61ae6cc60..10113f1d0 100644 --- a/frontend/src/pages/ProjectDetailPage.tsx +++ b/frontend/src/pages/ProjectDetailPage.tsx @@ -682,6 +682,80 @@ export function ProjectDetailPage() { )} + {/* #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 && ( + + +

+ + {t('projectDetail.rollup.title', { count: project.descendant_count })} +

+
+
+

{t('projectDetail.stats.printJobs')}

+

{project.rollup_stats.total_archives}

+

+ {t('projectDetail.stats.partsPrinted', { count: project.rollup_stats.completed_prints })} +

+
+
+

{t('projectDetail.stats.printTime')}

+

+ {formatDurationFromHours(project.rollup_stats.total_print_time_hours)} +

+
+
+

{t('projectDetail.stats.filamentUsed')}

+

+ {formatFilament(project.rollup_stats.total_filament_grams)} +

+
+ {(() => { + const rollupCost = + project.rollup_stats.estimated_cost + + project.rollup_stats.total_energy_cost + + project.rollup_stats.bom_cost; + if (rollupCost <= 0) return null; + return ( +
+

{t('projectDetail.cost.totalCost')}

+

+ {currency}{rollupCost.toFixed(2)} +

+
+ ); + })()} +
+ {project.rollup_stats.progress_percent !== null && ( +
+
+ {t('projectDetail.rollup.progress')} + + {t('projectDetail.rollup.percentComplete', { + percent: project.rollup_stats.progress_percent.toFixed(0), + })} + +
+
+
= 100 ? '#22c55e' : project.color || '#6b7280', + }} + /> +
+
+ )} + + + )} + {/* Cost tracking */} {stats && (() => { const totalCost = stats.estimated_cost + stats.total_energy_cost + stats.bom_cost; @@ -760,27 +834,48 @@ export function ProjectDetailPage() { -
+
- {child.name} - {child.name} + {child.status} + {/* A branch of its own, so its figures cover more than the + one project named on this row (#1264). */} + {child.descendant_count > 0 && ( + + + {child.descendant_count} + + )} +
+ {/* Each row carries its own branch's roll-up, so the rows add + up to the card above minus this project's own prints. */} +
+ {child.total_archives > 0 && ( + + {t('projectDetail.subProjects.jobs', { count: child.total_archives })} + + )} + {child.total_filament_grams > 0 && ( + {formatFilament(child.total_filament_grams)} + )} + {child.total_cost > 0 && ( + {currency}{child.total_cost.toFixed(2)} + )} + {child.progress_percent !== null && ( + {child.progress_percent.toFixed(0)}% + )}
- {child.progress_percent !== null && ( - - {child.progress_percent.toFixed(0)}% - - )} ))}
@@ -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)} diff --git a/frontend/src/pages/ProjectsPage.tsx b/frontend/src/pages/ProjectsPage.tsx index 33bce12df..e78574809 100644 --- a/frontend/src/pages/ProjectsPage.tsx +++ b/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(null); + const [parentId, setParentId] = useState(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(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 &&

{urlError}

}
+ {/* #1264: Nest this project under another one */} +
+ + +

{t('projects.parentHint')}

+
+ {/* #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')} )} + {/* #1264: without these, eight sub-projects of one programme + look like eight unrelated projects in the grid. */} + {project.child_count > 0 && ( + + + {project.child_count} + + )}
+ {parentName && ( +

+ + {t('projects.partOf', { name: parentName })} +

+ )} {project.description && (

{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(); + 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(); + 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) => { + const card = ( + 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

{card}
; + + return ( +
+ {card} +
+

+ + {t('projects.subProjectsOf', { name: project.name })} +

+
+ {children.map((child) => renderProjectTree(child, depth + 1, descended))} +
+
+
+ ); + }; + const createMutation = useMutation({ mutationFn: (data: ProjectCreate) => api.createProject(data), onSuccess: () => { @@ -1159,17 +1302,7 @@ export function ProjectsPage() { ) : (
- {projects?.map((project) => ( - handleClick(project)} - onEdit={() => handleEdit(project)} - onDelete={() => handleDeleteClick(project.id)} - hasPermission={hasPermission} - t={t} - /> - ))} + {topLevel.map((project) => renderProjectTree(project, 0, new Set()))}
)} diff --git a/frontend/src/utils/projectTree.ts b/frontend/src/utils/projectTree.ts new file mode 100644 index 000000000..381ffdaf9 --- /dev/null +++ b/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)); +}