Files
roboco/roboco/services/product.py
T
86d31bf3d3 [W9-3b] Enrich product table with cell mappings + task progress (#530)
Backend: ProductSummaryResponse gains cells: [{team, project_id, project_name}] and progress: {done, active, blocked}. ProductService.progress_for_products does one grouped query over tasks for every distinct project_id any product references, summed per product (monorepo case dedups a project once per product via a seen set). list_all eager-loads cells + each cell's project (selectinload + joinedload) so product_to_summary reads project.name without an N+1. No migration — reads existing tasks.status + product_projects.

Frontend: ProductTable renders a Cells column (team badges + project names, Unmapped when empty) and a Progress column (done/active/blocked counts + a health dot: amber at-risk when blocked>0). Both desktop Table and mobile ResponsiveTableCard variants. Mock products carry the new shape.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-15 04:34:01 +02:00

242 lines
9.9 KiB
Python

"""ProductService — CRUD + the per-cell project_for routing resolver."""
from typing import ClassVar
from typing import cast as typing_cast
from uuid import UUID
from sqlalchemy import case, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from roboco.db.tables import ProductProjectTable, ProductTable, TaskTable
from roboco.foundation.identity import Team
from roboco.models.base import TaskStatus
from roboco.models.product import ProductCellMapping, ProductCreate, ProductUpdate
from roboco.services.base import BaseService, ConflictError, NotFoundError
# Statuses that are NOT active progress: completed (done), cancelled
# (abandoned), blocked (its own bucket). Everything else counts as active.
_INACTIVE_STATUSES = (
TaskStatus.COMPLETED,
TaskStatus.CANCELLED,
TaskStatus.BLOCKED,
)
class ProductService(BaseService):
service_name: ClassVar[str] = "product"
async def create(self, data: ProductCreate, created_by: UUID) -> ProductTable:
if await self.get_by_slug(data.slug):
raise ConflictError(
f"Product with slug '{data.slug}' already exists",
resource_type="product",
)
product = ProductTable(
name=data.name,
slug=data.slug,
description=data.description,
created_by=created_by,
)
self.session.add(product)
await self.session.flush()
await self._replace_cells(product, data.cells)
await self.session.flush()
self.log.info("Product created", product_id=str(product.id), slug=data.slug)
return product
async def get(self, product_id: UUID) -> ProductTable | None:
result = await self.session.execute(
select(ProductTable).where(ProductTable.id == product_id)
)
return result.scalar_one_or_none()
async def get_by_slug(self, slug: str) -> ProductTable | None:
result = await self.session.execute(
select(ProductTable).where(ProductTable.slug == slug)
)
return result.scalar_one_or_none()
async def get_or_raise(self, product_id: UUID) -> ProductTable:
product = await self.get(product_id)
if not product:
raise NotFoundError("Product", str(product_id))
return product
async def update(
self, product_id: UUID, data: ProductUpdate
) -> ProductTable | None:
product = await self.get(product_id)
if not product:
return None
if data.name is not None:
product.name = data.name
if data.description is not None:
product.description = data.description
if data.cells is not None:
await self._replace_cells(product, data.cells)
await self.session.flush()
return product
async def delete(self, product_id: UUID) -> bool:
product = await self.get(product_id)
if not product:
return False
# tasks.product_id is ON DELETE RESTRICT — a referenced product cannot
# be deleted; the DB raises IntegrityError, the route maps it to 409.
await self.session.delete(product)
await self.session.flush()
return True
async def list_all(self, limit: int = 100, offset: int = 0) -> list[ProductTable]:
# Eager-load cells + each cell's project so product_to_summary can read
# project.name without an N+1 lazy join per cell.
query = (
select(ProductTable)
.options(
selectinload(ProductTable.cells).joinedload(ProductProjectTable.project)
)
.order_by(ProductTable.name)
.limit(limit)
.offset(offset)
)
result = await self.session.execute(query)
return list(result.scalars().all())
async def progress_for_products(
self, products: list[ProductTable]
) -> dict[UUID, dict[str, int]]:
"""Per-product task progress (done/active/blocked) across cell projects.
One grouped query over tasks for every distinct project_id any product
references, summed per product. A product's cells may point several teams
at the same project (the monorepo case), so each project is counted once
per product. Returns {product_id: {done, active, blocked}}.
"""
# distinct (product_id, project_id) pairs — dedup the monorepo case.
proj_to_products: dict[UUID, list[UUID]] = {}
for product in products:
seen: set[UUID] = set()
for cell in product.cells:
pid = typing_cast("UUID", cell.project_id)
if pid in seen:
continue
seen.add(pid)
proj_to_products.setdefault(pid, []).append(
typing_cast("UUID", product.id)
)
if not proj_to_products:
return {}
result = await self.session.execute(
select(
TaskTable.project_id,
func.coalesce(
func.sum(
case((TaskTable.status == TaskStatus.COMPLETED, 1), else_=0)
),
0,
).label("done"),
func.coalesce(
func.sum(
case((TaskTable.status == TaskStatus.BLOCKED, 1), else_=0)
),
0,
).label("blocked"),
func.coalesce(
func.sum(
case((TaskTable.status.in_(_INACTIVE_STATUSES), 0), else_=1)
),
0,
).label("active"),
)
.where(TaskTable.project_id.in_(list(proj_to_products.keys())))
.group_by(TaskTable.project_id)
)
per_project: dict[UUID, dict[str, int]] = {}
for row in result.fetchall():
per_project[typing_cast("UUID", row.project_id)] = {
"done": int(row.done or 0),
"active": int(row.active or 0),
"blocked": int(row.blocked or 0),
}
out: dict[UUID, dict[str, int]] = {}
for project_id, product_ids in proj_to_products.items():
counts = per_project.get(project_id)
if not counts:
continue
for pid in product_ids:
agg = out.setdefault(pid, {"done": 0, "active": 0, "blocked": 0})
agg["done"] += counts["done"]
agg["active"] += counts["active"]
agg["blocked"] += counts["blocked"]
return out
async def project_for(self, product_id: UUID, team: Team | str) -> UUID | None:
"""Resolve the Project a given cell maps to within a product.
Returns None when the product has no mapping for that cell (the caller
falls back to the parent task's project). This is the per-cell routing
keystone called from the gateway delegate path.
"""
team_value = team.value if isinstance(team, Team) else str(team)
result = await self.session.execute(
select(ProductProjectTable.project_id).where(
ProductProjectTable.product_id == product_id,
ProductProjectTable.team == team_value,
)
)
return result.scalar_one_or_none()
async def distinct_project_ids(self, product_id: UUID) -> list[UUID]:
"""Distinct repos a product spans — one Main-PM integration branch each.
The product's cell->project map may point several teams at the same
Project (the monorepo case) or at different ones (multi-repo). The
Main-PM root cuts one ``feature/main_pm/{root}`` integration branch per
DISTINCT project, so cells in that repo branch off it instead of master.
Ordered by the first team that references each project for determinism.
"""
result = await self.session.execute(
select(ProductProjectTable.project_id)
.where(ProductProjectTable.product_id == product_id)
.order_by(ProductProjectTable.team)
)
seen: dict[UUID, None] = {}
for project_id in result.scalars().all():
seen.setdefault(project_id, None)
return list(seen)
async def _replace_cells(
self, product: ProductTable, cells: list[ProductCellMapping]
) -> None:
"""Replace a product's full cell->project map (idempotent set semantics).
Mutates through the `cells` relationship (cascade=all,delete-orphan) so
the in-memory collection stays consistent with the DB — product_to_response
iterates product.cells, and direct session.add of child rows would leave
that collection stale. A freshly-constructed product was never loaded via
a query, so its `cells` collection is unloaded; refresh it inside the
async greenlet before mutating (touching it directly would trigger a
synchronous lazy load and raise MissingGreenlet).
"""
await self.session.refresh(product, ["cells"])
product.cells.clear() # delete-orphan marks the old mapping rows for delete
# Flush the DELETEs before appending the new rows. Within a single flush
# SQLAlchemy orders INSERTs before DELETEs for the same table, so without
# this the new (product_id, team) rows collide with the not-yet-deleted
# old ones on uq_product_projects_product_team (a 409 on any re-mapping of
# a team that already has a project). Flushing here issues the DELETEs
# first; the appended rows insert cleanly on the next flush.
await self.session.flush()
for mapping in cells:
product.cells.append(
ProductProjectTable(team=mapping.team, project_id=mapping.project_id)
)
def get_product_service(session: AsyncSession) -> ProductService:
"""Get a ProductService instance."""
return ProductService(session)