Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,19 @@ All notable changes to Turbo EA are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/).

## [2.158.11] - 2026-10-10

### Fixed
- Admin → Resources: the link from a file or link to its card opens the card's **Resources** tab; it opened the Card tab.
- Card detail: the open tab stays open when a tab before it (ADRs, Risks, Compliance) appears or disappears as its count loads; the selection used to move to the neighbouring tab.
- Reference catalogues: imported cards now record their creation in History, get their level in your hierarchy, a reference number when the type uses automatic references and a data-quality score; an entry whose name another card already uses at the same level is reported instead of creating a duplicate, and the capability level of a card imported without its parent follows your hierarchy.
- Reference catalogues: checking for or fetching a catalogue update no longer keeps a database connection out of the pool while PyPI answers.
- Reference catalogues: a catalogue update from the online store reports a release published without a wheel as such instead of failing with a generic fetch error.
- Value Stream Catalogue: the import is gated by the Create permission on Business Context cards, the type it creates.

### Changed
- Internal clean-up of card detail and its History tab: the rules that decide the tab strip, the section order and the history rows moved into modules with tests of their own, and the Card Layout editor takes its section order from the same function as the card.

## [2.158.10] - 2026-10-10

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
2.158.10
2.158.11
21 changes: 19 additions & 2 deletions backend/app/api/v1/capability_catalogue.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,12 +79,15 @@ async def import_capabilities(
always English-anchored so a localized import never duplicates a card.
"""
effective_locale = (payload.locale or user.locale or "en").strip() or "en"
return await svc.import_capabilities(
result = await svc.import_capabilities(
db,
user=user,
catalogue_ids=payload.catalogue_ids,
locale=effective_locale,
)
# The service stages its rows and never commits a session it was handed.
await db.commit()
return result


@router.get("/update-status")
Expand All @@ -93,6 +96,11 @@ async def update_status(
user: User = Depends(require_permission("admin.metamodel")),
):
"""Compare the bundled/cached catalogue against the public site."""
# The permission check used this request's session, so a pooled connection
# is already checked out. Hand it back before the PyPI round-trip:
# ``get_db`` is a yield-dependency that would otherwise pin it until the
# response is sent.
await db.commit()
return await svc.check_remote_version(db)


Expand All @@ -102,8 +110,17 @@ async def update_fetch(
user: User = Depends(require_permission("admin.metamodel")),
):
"""Download the latest catalogue from the public site and cache it."""
# The permission check used this request's session, so a pooled connection
# is already checked out. Hand it back before the PyPI round-trip:
# ``get_db`` is a yield-dependency that would otherwise pin it until the
# response is sent.
await db.commit()
try:
return await svc.fetch_remote_catalogue(db)
result = await svc.fetch_remote_catalogue(db)
# The helper stages the cache rows and never commits a session it was
# handed; the write is committed here, once the download is over.
await db.commit()
return result
except Exception:
logger.exception("Capability catalogue fetch failed")
raise HTTPException(status_code=502, detail="Catalogue fetch failed")
15 changes: 2 additions & 13 deletions backend/app/api/v1/cards.py
Original file line number Diff line number Diff line change
Expand Up @@ -822,7 +822,7 @@ async def bulk_create_cards(
# sharing a prefix stay contiguous, each seeded once via a global scan.
# Rolled-back rows leave gaps, which is acceptable by design.
_ref_types: dict[str, CardType | None] = {}
_ref_next: dict[str, int] = {}
allocator = card_reference.ReferenceAllocator()

# Fields schemas for every type in the batch, fetched once. The select-option
# check below runs per row, so it must not repeat a query per row the way
Expand All @@ -842,18 +842,7 @@ async def _bulk_assign_reference(card: Card, row) -> None:
_ref_types[row.type] = (
await db.execute(select(CardType).where(CardType.key == row.type))
).scalar_one_or_none()
ct = _ref_types[row.type]
if ct is None or card_reference.get_mode(ct) != "auto":
return
cfg = ct.reference_config or {}
prefix = str(cfg.get("prefix", "") or "")
start = int(cfg.get("start", card_reference.DEFAULT_START))
padding = int(cfg.get("padding", card_reference.DEFAULT_PADDING))
if prefix not in _ref_next:
_ref_next[prefix] = await card_reference.scan_highest_for_prefix(db, prefix, start)
n = _ref_next[prefix] + 1
_ref_next[prefix] = n
card.reference = card_reference.format_reference(prefix, padding, n)
await allocator.assign(db, card, _ref_types[row.type])

for row_idx in order:
r = rows_by_index[row_idx]
Expand Down
21 changes: 19 additions & 2 deletions backend/app/api/v1/process_catalogue.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,15 @@ async def import_processes(
if the target capability hasn't been imported yet).
"""
effective_locale = (payload.locale or user.locale or "en").strip() or "en"
return await svc.import_processes(
result = await svc.import_processes(
db,
user=user,
catalogue_ids=payload.catalogue_ids,
locale=effective_locale,
)
# The service stages its rows and never commits a session it was handed.
await db.commit()
return result


@router.get("/update-status")
Expand All @@ -76,6 +79,11 @@ async def update_status(
user: User = Depends(require_permission("admin.metamodel")),
):
"""Compare the bundled/cached process catalogue against PyPI."""
# The permission check used this request's session, so a pooled connection
# is already checked out. Hand it back before the PyPI round-trip:
# ``get_db`` is a yield-dependency that would otherwise pin it until the
# response is sent.
await db.commit()
return await svc.check_remote_version(db)


Expand All @@ -85,8 +93,17 @@ async def update_fetch(
user: User = Depends(require_permission("admin.metamodel")),
):
"""Download the latest wheel from PyPI. Hydrates all three caches."""
# The permission check used this request's session, so a pooled connection
# is already checked out. Hand it back before the PyPI round-trip:
# ``get_db`` is a yield-dependency that would otherwise pin it until the
# response is sent.
await db.commit()
try:
return await svc.fetch_remote_catalogue(db)
result = await svc.fetch_remote_catalogue(db)
# The helper stages the cache rows and never commits a session it was
# handed; the write is committed here, once the download is over.
await db.commit()
return result
except Exception:
logger.exception("Process catalogue fetch failed")
raise HTTPException(status_code=502, detail="Catalogue fetch failed")
25 changes: 22 additions & 3 deletions backend/app/api/v1/value_stream_catalogue.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,27 +51,37 @@ async def get_catalogue(
async def import_value_streams(
payload: ImportRequest,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_permission("inventory.create", card_type_key="ValueStream")),
# Gated on the type the import creates. It named a `ValueStream` type that
# does not exist, so a per-type Create deny on Business Context never applied.
user: User = Depends(require_permission("inventory.create", card_type_key="BusinessContext")),
):
"""Create BusinessContext / valueStream cards for selected streams or
stages. Selecting a stage automatically pulls in its parent stream so
`parent_id` is wired correctly. Auto-creates relBizCtxToBC and
relProcessToBizCtx relations to any matching capability/process cards.
"""
effective_locale = (payload.locale or user.locale or "en").strip() or "en"
return await svc.import_value_streams(
result = await svc.import_value_streams(
db,
user=user,
catalogue_ids=payload.catalogue_ids,
locale=effective_locale,
)
# The service stages its rows and never commits a session it was handed.
await db.commit()
return result


@router.get("/update-status")
async def update_status(
db: AsyncSession = Depends(get_db),
user: User = Depends(require_permission("admin.metamodel")),
):
# The permission check used this request's session, so a pooled connection
# is already checked out. Hand it back before the PyPI round-trip:
# ``get_db`` is a yield-dependency that would otherwise pin it until the
# response is sent.
await db.commit()
return await svc.check_remote_version(db)


Expand All @@ -80,8 +90,17 @@ async def update_fetch(
db: AsyncSession = Depends(get_db),
user: User = Depends(require_permission("admin.metamodel")),
):
# The permission check used this request's session, so a pooled connection
# is already checked out. Hand it back before the PyPI round-trip:
# ``get_db`` is a yield-dependency that would otherwise pin it until the
# response is sent.
await db.commit()
try:
return await svc.fetch_remote_catalogue(db)
result = await svc.fetch_remote_catalogue(db)
# The helper stages the cache rows and never commits a session it was
# handed; the write is committed here, once the download is over.
await db.commit()
return result
except Exception:
logger.exception("Value-stream catalogue fetch failed")
raise HTTPException(status_code=502, detail="Catalogue fetch failed")
31 changes: 23 additions & 8 deletions backend/app/services/capability_catalogue_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@
import turbo_ea_capabilities as catalogue_pkg
from sqlalchemy.ext.asyncio import AsyncSession

from app.models.card import Card
from app.models.user import User
from app.services import catalogue_common as common

Expand Down Expand Up @@ -272,6 +271,11 @@ async def import_capabilities(
same call so the catalogue hierarchy is reproduced.
- Re-parents existing children whose new catalogue parent was just
created in this batch.
- Creates every card through the shared write path
(``common.create_catalogue_card``); a row it refuses — a name another
card already uses at that level — is reported in ``failed`` together
with the entries below it, and the rest of the batch goes on. Stages
only: the route commits.
"""
flat, meta = await _resolve_active_catalogue(db, locale=locale)
by_id = {c["id"]: c for c in flat}
Expand All @@ -283,9 +287,12 @@ async def import_capabilities(

created: list[dict[str, str]] = []
skipped: list[dict[str, str]] = []
failed: list[dict[str, str]] = []
failed_ids: set[str] = set()
created_in_batch: set[str] = set()
now = common.now_iso()
user_id = user.id
allocator = common.ReferenceAllocator()

for cap in ordered:
if cap["id"] in pre_existing_ids:
Expand All @@ -299,22 +306,30 @@ async def import_capabilities(
continue

cat_parent = cap.get("parent_id")
if cat_parent in failed_ids:
failed.append({"catalogue_id": cap["id"], "reason": common.PARENT_NOT_IMPORTED})
failed_ids.add(cap["id"])
continue
level_key = (
MACRO_CAPABILITY_LEVEL_KEY if cap["level"] == MACRO_LEVEL else f"L{cap['level']}"
)
card = Card(
type=BUSINESS_CAPABILITY_TYPE,
card, reason = await common.create_catalogue_card(
db,
user,
type_key=BUSINESS_CAPABILITY_TYPE,
name=cap["name"],
subtype=None,
description=cap.get("description"),
parent_id=catalogue_id_to_card_id.get(cat_parent) if cat_parent else None,
attributes=common.catalogue_attributes(
cap, meta, now, ("capabilityLevel", level_key), _COPIED_ATTRIBUTES
),
created_by=user_id,
updated_by=user_id,
allocator=allocator,
)
db.add(card)
await db.flush()
if card is None:
failed.append({"catalogue_id": cap["id"], "reason": reason})
failed_ids.add(cap["id"])
continue
catalogue_id_to_card_id[cap["id"]] = str(card.id)
created.append({"catalogue_id": cap["id"], "card_id": str(card.id)})
created_in_batch.add(cap["id"])
Expand All @@ -328,11 +343,11 @@ async def import_capabilities(
user_id=user_id,
)

await db.commit()
macro_warnings = list(meta.get("macro_warnings") or [])
return {
"created": created,
"skipped": skipped,
"failed": failed,
"relinked": relinked,
"catalogue_version": meta.get("catalogue_version"),
"warnings": macro_warnings,
Expand Down
27 changes: 27 additions & 0 deletions backend/app/services/card_reference.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,33 @@ async def next_reference(db: AsyncSession, card_type: CardType) -> str | None:
return await next_reference_for_prefix(db, _prefix(cfg), _start(cfg), _padding(cfg))


class ReferenceAllocator:
"""Hand out consecutive references across a batch with one global scan per prefix.

``next_reference`` scans every ``cards.reference`` on each call, which is
right for one card and wrong for a 500-card import. The Excel importer
(``POST /cards/bulk-create``) and the reference-catalogue imports share
this instead: cards sharing a prefix stay contiguous, and a row rolled
back after its number was taken leaves a gap, which is acceptable by
design.
"""

def __init__(self) -> None:
self._next: dict[str, int] = {}

async def assign(self, db: AsyncSession, card: Card, card_type: CardType | None) -> None:
"""Set ``card.reference`` for an ``auto``-mode type; leave it NULL otherwise."""
if card_type is None or get_mode(card_type) != "auto":
return
cfg = _cfg(card_type)
prefix = _prefix(cfg)
if prefix not in self._next:
self._next[prefix] = await scan_highest_for_prefix(db, prefix, _start(cfg))
n = self._next[prefix] + 1
self._next[prefix] = n
card.reference = format_reference(prefix, _padding(cfg), n)


async def backfill_references_for_type(db: AsyncSession, card_type: CardType) -> int:
"""Assign references to existing ID-less cards of an ``auto``-mode type.

Expand Down
13 changes: 11 additions & 2 deletions backend/app/services/card_write_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -740,9 +740,15 @@ async def create_card(
alias: str | None = None,
strict_attributes: bool = False,
dry_run: bool = False,
reference_allocator: card_reference.ReferenceAllocator | None = None,
) -> Card:
"""Create one card with full validation and side effects; returns the
flushed (uncommitted) row. Caller owns permission checks + the commit."""
flushed (uncommitted) row. Caller owns permission checks + the commit.

``reference_allocator`` lets a batch (the reference-catalogue imports)
number its cards with one global scan per prefix instead of one per
card; without it the reference is allocated exactly as before.
"""
await _validate_url_attributes(db, type_key, attributes or {})
await _validate_percentage_attributes(db, type_key, attributes or {})
await _validate_cost_attributes(db, type_key, attributes or {})
Expand Down Expand Up @@ -775,7 +781,10 @@ async def create_card(
card_type_row = (
await db.execute(select(CardType).where(CardType.key == type_key))
).scalar_one_or_none()
await _assign_reference_on_create(db, card, card_type_row)
if reference_allocator is not None:
await reference_allocator.assign(db, card, card_type_row)
else:
await _assign_reference_on_create(db, card, card_type_row)

# Guard: hierarchy depth limit for BusinessCapability
if card.parent_id:
Expand Down
Loading
Loading