mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 15:35:39 +02:00
Nest projects under a master project and roll their figures up (#1264)
Projects were flat. The parent_id column and the sub-project list existed but nothing could set a parent outside the API, and a master project's stats only ever covered its own prints. The project dialog gets a parent picker, and a project with sub-projects gets a second card covering the whole tree -- jobs, parts, time, filament, cost, and progress against every target in the tree added together. That card is separate from the project's own stats, which keep their existing meaning; widening them would have restated the figures of anyone who had already nested projects over the API. Each listed sub-project carries its own branch's roll-up, so the rows add up to the card above them. On the Projects page a sub-project is drawn inside its parent's group rather than as another card in the grid -- two cards columns apart cannot show that they belong together, whatever the caption says. A sub-project whose parent the status filter has hidden stays put and names its parent instead. compute_project_stats now goes through the same grouped aggregation as the roll-up rather than its own copy of the SQL, since the two must agree. Three things the interface made reachable: - PATCH refused only a project as its own direct parent, so A -> B -> A was two calls away. A cycle has no root to roll up to, and the walk keeps its seen-set for databases that already contain one. - A sub-project's percentage was completed quantities against the plate target, disagreeing with the page it linked to. - Deleting a mid-tree project orphaned its children at top level; they now move up to its own parent.
This commit is contained in:
@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
## [1.2.6b1] - Unreleased
|
||||
|
||||
### Added
|
||||
- **Nest projects under a master project and see the whole programme in one place (#1264, reporter @enjoylifenow)** — A project has always been a flat thing: a build with fifty parts and a build with two got the same single row, and the only way to keep a large job legible was to split it into separate projects that then knew nothing about each other. Projects can now be nested. The project dialog has a **Parent project** picker, so an assembly can sit under the build it belongs to, at whatever depth suits the work; the picker leaves out the project itself and anything already beneath it, because nesting a project inside its own branch is a loop rather than a hierarchy. A project that has sub-projects gains a second card reporting the whole tree at once — print jobs, parts, time, filament and total cost, with progress measured against every target in the tree added together. That card is deliberately separate from the project's own figures, which keep meaning exactly what they meant before: what this project printed, not what its sub-projects did. Each sub-project listed underneath now carries its own branch's totals rather than only a percentage, so the rows add up to the card above them instead of contradicting it. On the Projects page a sub-project is drawn inside its parent's group rather than as another card somewhere in the grid, because two cards that belong together cannot show it while they sit columns apart, however they are captioned; the group is ruled in the parent's own colour and nests as deep as the projects do. A sub-project whose parent is hidden by the status filter stays where it is and says which project it belongs to instead. Two things that only became reachable once the interface could reach them were fixed along the way: a project could be moved under its own sub-project, which the API refused only when a project was made its own direct parent, and a percentage shown against a sub-project was measured differently from the same percentage on the page it linked to. Deleting a project in the middle of a tree now lifts its sub-projects up to its own parent instead of cutting them loose at the top level.
|
||||
- **Restore selected categories from a Git backup commit (#2714, contributor @jmoore-skild, requested in #2656)** — Bambuddy has pushed backups to GitHub, GitLab, Gitea and Forgejo for a long time, and every one of those commits was a restore point that nothing could read back. Recovering from a bad settings change, a rebuilt instance or a lost database meant opening the repository by hand and copying JSON into the right places, if you knew which places those were. **Settings → Backup & Restore → Restore from Git** now picks any of the twenty most recent commits and pulls back the categories you tick — K-profiles, app settings, spool inventory and print history — without touching anything you did not select. The modal previews the commit before anything is written: it shows how many items each category holds and greys out the ones that commit does not contain, so a category you only enabled last week is visibly absent from older commits rather than silently restoring nothing. **Overwrite existing entries** decides what happens when something already exists locally — off, it fills in what is missing and leaves the rest alone; on, it makes the local row match the backup. The result panel reports what actually happened per category as restored, skipped and failed, and those three always add up to the number the preview showed you, so a count that does not match the preview is a bug rather than something to interpret. Restoring never resurrects a credential: the backup carries MQTT, LDAP, Home Assistant and Prometheus secrets so that a repository is a complete record, but the restore refuses every one of them, and refuses along with them any switch that would be left pointing at a service it can no longer authenticate to — an exposed Prometheus endpoint with no token is worse than one that stays off. The keys that decide who can reach the instance at all are refused outright for the same reason — the four authentication-policy switches, and the whole LDAP family alongside them, since those name *which directory server decides who you are* rather than how the instance behaves. Authentication is reconfigured through the auth UI, which has the guards that a JSON file does not. Print archives come back as history only, since a Git backup holds metadata and never the 3MF or thumbnail bytes, and each one is returned to its owner by username rather than by user id — an id means nothing on a rebuilt instance, where it would hand one person's print history to whoever now holds that number. An archive whose owner this instance does not have lands unowned with a note saying so, rather than being attributed to a stranger; one that already exists locally keeps the owner it already has, because an owner the backup cannot name is not an instruction to take one away. K-profiles are the one category that leaves the database: they are sent to the printer over MQTT, which means the printer must be online, and writing a slot is always an overwrite there regardless of the toggle — the modal says so before you click rather than in the summary afterwards. Cloud profiles are backed up but deliberately not restorable, as writing them means writing to a Bambu or Orca account rather than to this instance. **Restoring is permissioned per category**: `github:restore` opens the dialog, and each category additionally requires the permission that owns its rows — `settings:update`, `inventory:update`, `archives:update_all` and `kprofiles:update` — so a role cannot write through a restore what it cannot write through the page that owns it. Administrators hold all of them already; a custom role built around the Backup permissions alone can open the dialog and preview a commit, but needs the owning permission for each category you want it to be able to write. Each category is committed as it completes rather than at the end, so a large restore does not hold the database against the rest of Bambuddy for the length of the run; the trade is that a failure part-way through leaves the categories that already finished in place, which the result panel reports rather than claiming nothing was restored. Translated in all locales; wiki updated. Covered by backend and frontend tests.
|
||||
- **Home Assistant sensors on the printer card, with an optional print interlock (#1148, reporter @bsaunder; #448, reporter @baudneo)** — Bambuddy could already switch a Home Assistant entity as a smart plug, but it had no way to *read* one. A printer in a home-built enclosure with a door contact, or an A1 with an aftermarket chamber thermometer, had all that data in Home Assistant and none of it in Bambuddy — the reporter's actual problem being that he could not tell whether he had left the enclosure open before starting a print from his phone. **Settings → Smart Plugs → Home Assistant Sensors** now binds any `binary_sensor` — a door, window, smoke or moisture contact — or any `sensor` that carries a reading to a printer, and its state appears on that printer's card. The wording follows Home Assistant's own device class, so a door reads Open or Closed rather than On or Off, and a thermometer reads `41.2 °C`; entities with no device class fall back to on/off, exactly as Home Assistant shows them. A sensor can be given an alert condition — on, off, above a value, below a value — which highlights it on the card and unlocks two things it would otherwise be pointless to offer. **Notify on alert** sends a notification the moment the sensor enters that state, once on the way in rather than on every poll, and not again when a flaky contact drops off the network and comes back still alerting. **Hold prints while alerting** is the part that answers the original question without anyone having to look: queued jobs for that printer wait, with a reason on the Queue page you can read at a glance ("Waiting on Enclosure Door"), and start by themselves as soon as the door shuts. Nothing is ever cancelled, and a job queued as "Any X1C" simply goes to a sibling whose sensors are clear instead of waiting behind the one that is held. The interlock is deliberately one-directional: it holds only on a sensor that was read successfully and *is* alerting, so a Home Assistant that is unreachable holds nothing and the queue keeps running as though no interlock were configured. Sensors are their own thing rather than a smart plug with a wider entity filter — a plug carries auto-on, schedules, power alerts and "controls printer power", and the printer card's power button would have happily tried to switch a door contact. One backend poller reads every bound entity every 15 seconds and the cards serve that cached reading, so the cost to Home Assistant does not grow with the number of printers on screen or browser tabs open. Off by default in every respect: a newly bound sensor is display-only until you give it an alert condition, and both the notification and the interlock are separate opt-ins on top of that.
|
||||
- **Open a File Manager model in your desktop slicer, and pick which one from the 3D preview (#2725, contributor @pascalheidmann)** — The **Slice** action on a file card only existed when the optional slicer sidecar was running. Turn the sidecar off — which is the default, and how most installs run — and the File Manager offered no way to get a model into a slicer at all, even though the Archives page has handed files to a locally-installed Bambu Studio or OrcaSlicer over the URI scheme for a long time. The File Manager was simply the one place that never got it. **Slice** now appears on every unsliced model (`.3mf`, `.stl`, `.step`, `.stp`) in both the card menu and the list view, and does whichever of the two things your configuration means: with the sidecar on it opens Bambuddy's slice modal and the work happens on the server, and with it off it hands the file to your desktop slicer. The icon says which you will get before you click — a cog for server-side slicing, an external-link arrow for the handoff — and which slicer receives the handoff comes from **Settings → Workflow → Slicer → Open in Slicer**, falling back to the preferred slicer as it always has. The 3D preview goes further, because that is where you are actually looking at the model and deciding: its slicer button is now a split button, and the chevron beside it offers the alternatives without changing any setting. With the sidecar off that is the slicer you did *not* pick as your desktop target; with it on, the primary button still slices server-side and the menu offers a one-off desktop handoff to either slicer. Two things that used to be silent now are not: a handoff refused for want of permission raises an error toast rather than launching the slicer at a URL it cannot fetch, where a permission problem looked exactly like "no slicer installed"; and the file-type rule is shared between the card menu and the 3D preview, so a file can no longer offer **Slice** in one place while showing it greyed out in the other. Permissions follow the endpoint each mode calls — the handoff is a download and needs the same library read permission that lets you see the file, server-side slicing writes a new file and needs upload rights — and the action is shown disabled with the missing permission named rather than hidden. Translated in all locales; wiki updated. Covered by frontend tests.
|
||||
|
||||
+302
-113
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3845,6 +3845,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',
|
||||
@@ -3992,6 +3998,12 @@ export default {
|
||||
},
|
||||
subProjects: {
|
||||
title: 'Unterprojekte ({{count}})',
|
||||
jobs: '{{count}} Aufträge',
|
||||
},
|
||||
rollup: {
|
||||
title: 'Inklusive {{count}} Unterprojekten',
|
||||
progress: 'Gesamtfortschritt',
|
||||
percentComplete: '{{percent}}% abgeschlossen',
|
||||
},
|
||||
notes: {
|
||||
title: 'Notizen',
|
||||
|
||||
@@ -3874,6 +3874,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',
|
||||
@@ -4021,6 +4027,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',
|
||||
|
||||
@@ -3848,6 +3848,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',
|
||||
@@ -3995,6 +4001,12 @@ export default {
|
||||
},
|
||||
subProjects: {
|
||||
title: 'Subproyectos ({{count}})',
|
||||
jobs: '{{count}} trabajos',
|
||||
},
|
||||
rollup: {
|
||||
title: 'Incluyendo {{count}} subproyectos',
|
||||
progress: 'Progreso general',
|
||||
percentComplete: '{{percent}}% completado',
|
||||
},
|
||||
notes: {
|
||||
title: 'Notas',
|
||||
|
||||
@@ -3834,6 +3834,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',
|
||||
@@ -3981,6 +3987,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',
|
||||
|
||||
@@ -3833,6 +3833,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',
|
||||
@@ -3980,6 +3986,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',
|
||||
|
||||
@@ -3845,6 +3845,12 @@ export default {
|
||||
|
||||
// Projects
|
||||
projects: {
|
||||
parentLabel: '親プロジェクト',
|
||||
parentNone: 'なし(トップレベルプロジェクト)',
|
||||
parentHint: 'このプロジェクトを別のプロジェクトの下に配置すると、数値がマスタープロジェクトに集計されます',
|
||||
partOf: '{{name}} の一部',
|
||||
subProjectCount: 'サブプロジェクト {{count}} 件',
|
||||
subProjectsOf: '{{name}} のサブプロジェクト',
|
||||
title: 'プロジェクト',
|
||||
subtitle: '印刷プロジェクトを管理',
|
||||
newProject: '新規プロジェクト',
|
||||
@@ -3992,6 +3998,12 @@ export default {
|
||||
},
|
||||
subProjects: {
|
||||
title: 'サブプロジェクト ({{count}})',
|
||||
jobs: 'ジョブ {{count}} 件',
|
||||
},
|
||||
rollup: {
|
||||
title: 'サブプロジェクト {{count}} 件を含む',
|
||||
progress: '全体の進捗',
|
||||
percentComplete: '{{percent}}% 完了',
|
||||
},
|
||||
notes: {
|
||||
title: 'メモ',
|
||||
|
||||
@@ -3654,6 +3654,12 @@ export default {
|
||||
}
|
||||
},
|
||||
projects: {
|
||||
parentLabel: '상위 프로젝트',
|
||||
parentNone: '없음 (최상위 프로젝트)',
|
||||
parentHint: '이 프로젝트를 다른 프로젝트 아래에 두면 수치가 마스터 프로젝트로 합산됩니다',
|
||||
partOf: '{{name}}의 일부',
|
||||
subProjectCount: '하위 프로젝트 {{count}}개',
|
||||
subProjectsOf: '{{name}}의 하위 프로젝트',
|
||||
title: '프로젝트',
|
||||
subtitle: '3D 인쇄 프로젝트 정리 및 추적',
|
||||
newProject: '새 프로젝트',
|
||||
@@ -3792,7 +3798,13 @@ export default {
|
||||
remaining: '남은 예산'
|
||||
},
|
||||
subProjects: {
|
||||
title: '하위 프로젝트 ({{count}})'
|
||||
title: '하위 프로젝트 ({{count}})',
|
||||
jobs: '작업 {{count}}개',
|
||||
},
|
||||
rollup: {
|
||||
title: '하위 프로젝트 {{count}}개 포함',
|
||||
progress: '전체 진행률',
|
||||
percentComplete: '{{percent}}% 완료',
|
||||
},
|
||||
notes: {
|
||||
title: '메모',
|
||||
|
||||
@@ -3833,6 +3833,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',
|
||||
@@ -3980,6 +3986,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',
|
||||
|
||||
@@ -3646,6 +3646,12 @@ export default {
|
||||
},
|
||||
},
|
||||
projects: {
|
||||
parentLabel: "Родительский проект",
|
||||
parentNone: "Нет (проект верхнего уровня)",
|
||||
parentHint: "Вложите этот проект в другой, чтобы его показатели суммировались в главном проекте",
|
||||
partOf: "Часть проекта {{name}}",
|
||||
subProjectCount: "Подпроектов: {{count}}",
|
||||
subProjectsOf: "Подпроекты проекта {{name}}",
|
||||
title: "Проекты",
|
||||
subtitle: "Организация и отслеживание проектов 3D-печати",
|
||||
newProject: "Новый проект",
|
||||
@@ -3785,6 +3791,12 @@ export default {
|
||||
},
|
||||
subProjects: {
|
||||
title: "Подпроекты ({{count}})",
|
||||
jobs: "Заданий: {{count}}",
|
||||
},
|
||||
rollup: {
|
||||
title: "Включая подпроекты: {{count}}",
|
||||
progress: "Общий прогресс",
|
||||
percentComplete: "Завершено {{percent}}%",
|
||||
},
|
||||
notes: {
|
||||
title: "Заметки",
|
||||
|
||||
@@ -3841,6 +3841,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',
|
||||
@@ -3982,6 +3988,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',
|
||||
|
||||
@@ -3874,6 +3874,12 @@ export default {
|
||||
|
||||
// Projects
|
||||
projects: {
|
||||
parentLabel: "Батьківський проєкт",
|
||||
parentNone: "Немає (проєкт верхнього рівня)",
|
||||
parentHint: "Вкладіть цей проєкт в інший, щоб його показники підсумовувалися в головному проєкті",
|
||||
partOf: "Частина проєкту {{name}}",
|
||||
subProjectCount: "Підпроєктів: {{count}}",
|
||||
subProjectsOf: "Підпроєкти проєкту {{name}}",
|
||||
title: "Проєкти",
|
||||
subtitle: "Організовуйте та відстежуйте свої проєкти 3D-друку",
|
||||
newProject: "Новий проєкт",
|
||||
@@ -4021,6 +4027,12 @@ export default {
|
||||
},
|
||||
subProjects: {
|
||||
title: "Підпроєкти ({{count}})",
|
||||
jobs: "Завдань: {{count}}",
|
||||
},
|
||||
rollup: {
|
||||
title: "Разом із підпроєктами: {{count}}",
|
||||
progress: "Загальний прогрес",
|
||||
percentComplete: "Завершено {{percent}}%",
|
||||
},
|
||||
notes: {
|
||||
title: "Примітки",
|
||||
|
||||
@@ -3833,6 +3833,12 @@ export default {
|
||||
|
||||
// Projects
|
||||
projects: {
|
||||
parentLabel: '父项目',
|
||||
parentNone: '无(顶级项目)',
|
||||
parentHint: '将此项目嵌套在另一个项目下,其数据将汇总到主项目中',
|
||||
partOf: '属于 {{name}}',
|
||||
subProjectCount: '{{count}} 个子项目',
|
||||
subProjectsOf: '{{name}} 的子项目',
|
||||
title: '项目',
|
||||
subtitle: '组织和跟踪您的 3D 打印项目',
|
||||
newProject: '新建项目',
|
||||
@@ -3980,6 +3986,12 @@ export default {
|
||||
},
|
||||
subProjects: {
|
||||
title: '子项目 ({{count}})',
|
||||
jobs: '{{count}} 个任务',
|
||||
},
|
||||
rollup: {
|
||||
title: '包含 {{count}} 个子项目',
|
||||
progress: '总体进度',
|
||||
percentComplete: '已完成 {{percent}}%',
|
||||
},
|
||||
notes: {
|
||||
title: '备注',
|
||||
|
||||
@@ -3833,6 +3833,12 @@ export default {
|
||||
|
||||
// Projects
|
||||
projects: {
|
||||
parentLabel: '父專案',
|
||||
parentNone: '無(頂層專案)',
|
||||
parentHint: '將此專案巢狀置於另一個專案下,其數據將彙總至主專案',
|
||||
partOf: '屬於 {{name}}',
|
||||
subProjectCount: '{{count}} 個子專案',
|
||||
subProjectsOf: '{{name}} 的子專案',
|
||||
title: '專案',
|
||||
subtitle: '組織和追蹤您的 3D 列印專案',
|
||||
newProject: '新增專案',
|
||||
@@ -3980,6 +3986,12 @@ export default {
|
||||
},
|
||||
subProjects: {
|
||||
title: '子專案 ({{count}})',
|
||||
jobs: '{{count}} 個任務',
|
||||
},
|
||||
rollup: {
|
||||
title: '包含 {{count}} 個子專案',
|
||||
progress: '整體進度',
|
||||
percentComplete: '已完成 {{percent}}%',
|
||||
},
|
||||
notes: {
|
||||
title: '備註',
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -26,8 +26,8 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-BPSw6nnF.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Db2rfQf-.css">
|
||||
<script type="module" crossorigin src="/assets/index-FVTa4dYE.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DJ8Q_OV9.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Reference in New Issue
Block a user