diff --git a/docs/spec/web-ui.md b/docs/spec/web-ui.md index 38d5d2f..4228ddd 100644 --- a/docs/spec/web-ui.md +++ b/docs/spec/web-ui.md @@ -167,6 +167,7 @@ Beyond the always-present fields, these **conditional / relational sections** ap | **Referenced by (other institutions)** / 参照元(他機関) | CFItem | ✓ | ✓ | other-tenant associations whose destination is this item. **Public origins only** (private adopters never surfaced); current tenant excluded; excludes `isChildOf` | | **Referring criteria** | CFItem | ✓ | ✓ | a CFRubricCriterion links to this item via `CFItemURI` | | **Items setting this subject** / この分野を設定している項目 | CFSubject | ✓ **tenant-wide** (all docs) | ✓ **current document only** (案B) | items whose `subjectURI` references the subject. Top-20 + count + "Show more". Scope = `doc` presence (page None → tenant-wide; pane → that doc). `subject` string-only refs not matched | +| **Items of this type** / この種別の項目 | CFItemType | ✓ **tenant-wide** | ✓ **current document only** (案B) | items whose `cf_item_type_id` FK is this type. Same shape/scope as the subject row; matched by FK (no JSONB). Fragment route `/item-type/{id}/items` | | **Definitions** (Item Types / Concepts / Subjects / Licenses / Groupings) | CFDocument (tree) | — | ✓ | the lookups this document references (CFPackage `CFDefinitions` scope); each is a navigable tree node | | **Effective license** | CFItem / CFDocument | ✓ | ✓ | item's own license, else inherited from document (labeled "from document") | @@ -244,7 +245,9 @@ When `/uri/{uuid}` resolves to a lookup, show common + specific fields: - **Standalone `/uri/{subject}` page** — **tenant-wide**: every item in the tenant setting this subject, across all documents. (A subject is a tenant-owned lookup with no owning document, so the page has no document context — `doc` is None.) - **Tree right pane** (subject selected via the current document's Definitions) — **restricted to the current document**: only that document's items. This keeps the pane consistent with its document-scoped tree (every listed item is a node in the same tree, so links never jump to another document), keeps the list small, and keeps the query cheap. The `doc` query param threads this scope through "Show more"; `subject_items.scope_doc` carries the document identifier (None = tenant-wide). The discriminator is simply whether `_detail_extras` receives a `doc` (pane) or not (page). -Other scope notes: **within-tenant only** (no cross-tenant/private gating); items referencing the subject only via the plain `subject` string (no `subjectURI`) are **not** matched; the reusable `fragments/subject_items.html` partial backs both the inline SSR and the fragment route (and is intended for reuse by a future search results list). Other lookups (CFItemType/Concept/License) do not yet have an equivalent reverse list. +Other scope notes: **within-tenant only** (no cross-tenant/private gating); items referencing the subject only via the plain `subject` string (no `subjectURI`) are **not** matched; the reusable `fragments/subject_items.html` partial backs both the inline SSR and the fragment route (and is intended for reuse by a future search results list). + +**CFItemType — "Items of this type" (reverse lookup):** the CFItemType analogue, with the **same** behaviour, surface-based scope (案B), pagination, and `subject_items.html` partial. The only differences: matched by the **`cf_item.cf_item_type_id` FK** (not JSONB; `cf_item_repository.list_items_by_item_type` / `count_items_by_item_type`, keyed by the type's internal PK) — backed by the btree index **`ix_cf_items_tenant_item_type (tenant_id, cf_item_type_id)`** (Postgres does not auto-index FK columns, so the tenant-wide query would otherwise seq-scan), the section label is "Items of this type" / 「この種別の項目」, and the "Show more" fragment route is `GET /{tenant}/item-type/{item-type-id}/items?offset=&limit=[&doc=]`. The shared partial takes an `items_endpoint` so the same markup serves both subject and item-type. CFConcept / CFLicense / CFAssociationGrouping do not yet have an equivalent reverse list (Concept/License would follow the same FK pattern; Grouping lists associations, not items). ### CFRubric diff --git a/migrations/versions/e2b3c4d5f6a7_index_cf_items_tenant_item_type.py b/migrations/versions/e2b3c4d5f6a7_index_cf_items_tenant_item_type.py new file mode 100644 index 0000000..d23348a --- /dev/null +++ b/migrations/versions/e2b3c4d5f6a7_index_cf_items_tenant_item_type.py @@ -0,0 +1,36 @@ +"""add btree index on cf_items (tenant_id, cf_item_type_id) + +Speeds up the CFItemType reverse lookup ("items of this type"): +``WHERE tenant_id = ? AND cf_item_type_id = ?``. PostgreSQL does not create an +index on FK columns automatically, so without this the tenant-wide list / count +would seq-scan. The (tenant_id, cf_item_type_id) prefix also serves the count; +the document-scoped pane query adds cf_document_id (covered by the existing +ix_cf_items_tenant_document_coding). + +Revision ID: e2b3c4d5f6a7 +Revises: d1a2b3c4e5f6 +Create Date: 2026-06-20 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "e2b3c4d5f6a7" +down_revision: Union[str, None] = "d1a2b3c4e5f6" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_index( + "ix_cf_items_tenant_item_type", + "cf_items", + ["tenant_id", "cf_item_type_id"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index("ix_cf_items_tenant_item_type", table_name="cf_items") diff --git a/src/locales/en.json b/src/locales/en.json index 7f8ecbc..c55a7e3 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -66,6 +66,7 @@ "related_other_institution_in": "Other institution ↩", "incoming_refs_label": "Referenced by (other institutions)", "subject_items_label": "Items setting this subject", + "item_type_items_label": "Items of this type", "subject_items_count": "{count} total", "show_more": "Show more", "related_external": "External", diff --git a/src/locales/ja.json b/src/locales/ja.json index fd487a2..3e285f0 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -66,6 +66,7 @@ "related_other_institution_in": "他機関 ↩", "incoming_refs_label": "参照元(他機関)", "subject_items_label": "この分野を設定している項目", + "item_type_items_label": "この種別の項目", "subject_items_count": "全 {count} 件", "show_more": "もっと見る", "related_external": "外部", diff --git a/src/models/cf_item.py b/src/models/cf_item.py index 9a6e027..d24ff5d 100644 --- a/src/models/cf_item.py +++ b/src/models/cf_item.py @@ -21,6 +21,10 @@ class CFItem(Base): postgresql_using="gin", postgresql_ops={"subject_uri": "jsonb_path_ops"}, ), + # Reverse lookup "items of this type" (CFItemType). Postgres does not + # auto-index FK columns, so the tenant-wide `WHERE tenant_id=? AND + # cf_item_type_id=?` query would seq-scan without this. + Index("ix_cf_items_tenant_item_type", "tenant_id", "cf_item_type_id"), ) id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) diff --git a/src/repositories/cf_item_repository.py b/src/repositories/cf_item_repository.py index e3dd87b..3ff89ae 100644 --- a/src/repositories/cf_item_repository.py +++ b/src/repositories/cf_item_repository.py @@ -107,29 +107,13 @@ async def get_cf_item_by_identifier( return result.scalar_one_or_none() -async def list_items_by_subject( - session: AsyncSession, - tenant_id: uuid.UUID, - subject_identifier: str, - *, - document_id: uuid.UUID | None = None, - offset: int = 0, - limit: int = 20, -) -> list[dict]: - """CFItems in this tenant whose ``subject_uri`` references the given subject. +async def _list_items_where(session: AsyncSession, conditions: list, offset: int, limit: int) -> list[dict]: + """Shared body of the reverse-lookup list queries (items using a definition). - Reverse lookup for the CFSubject detail page ("items setting this subject"). - Matches via JSONB containment ``subject_uri @> '[{"identifier": }]'`` - (GIN-indexed). Tenant-scoped. When ``document_id`` is given, additionally - restricts to that one document (used by the tree right pane, which is - document-scoped; the standalone /uri/ page passes None for the tenant-wide - list). Ordered by human_coding_scheme → full_statement → identifier for a - stable offset-pagination boundary. Returns + Selects the label/link columns, joins the owning document, and applies a + stable order for offset pagination. Returns ``[{identifier, human_coding_scheme, full_statement, doc_identifier, doc_title}]``. """ - conditions = [CFItem.tenant_id == tenant_id, _subject_uri_contains(subject_identifier)] - if document_id is not None: - conditions.append(CFItem.cf_document_id == document_id) rows = await session.execute( select( CFItem.identifier, @@ -160,6 +144,45 @@ async def list_items_by_subject( ] +async def _count_items_where(session: AsyncSession, conditions: list) -> int: + result = await session.execute(select(func.count()).select_from(CFItem).where(*conditions)) + return int(result.scalar_one()) + + +def _subject_conditions(tenant_id: uuid.UUID, subject_identifier: str, document_id: uuid.UUID | None) -> list: + conditions = [CFItem.tenant_id == tenant_id, _subject_uri_contains(subject_identifier)] + if document_id is not None: + conditions.append(CFItem.cf_document_id == document_id) + return conditions + + +def _item_type_conditions(tenant_id: uuid.UUID, item_type_id: uuid.UUID, document_id: uuid.UUID | None) -> list: + conditions = [CFItem.tenant_id == tenant_id, CFItem.cf_item_type_id == item_type_id] + if document_id is not None: + conditions.append(CFItem.cf_document_id == document_id) + return conditions + + +async def list_items_by_subject( + session: AsyncSession, + tenant_id: uuid.UUID, + subject_identifier: str, + *, + document_id: uuid.UUID | None = None, + offset: int = 0, + limit: int = 20, +) -> list[dict]: + """CFItems in this tenant whose ``subject_uri`` references the given subject. + + Reverse lookup for the CFSubject detail page ("items setting this subject"). + Matches via JSONB containment ``subject_uri @> '[{"identifier": }]'`` + (GIN-indexed). Tenant-scoped. When ``document_id`` is given, additionally + restricts to that one document (the tree right pane is document-scoped; the + standalone /uri/ page passes None for the tenant-wide list).""" + conditions = _subject_conditions(tenant_id, subject_identifier, document_id) + return await _list_items_where(session, conditions, offset, limit) + + async def count_items_by_subject( session: AsyncSession, tenant_id: uuid.UUID, @@ -168,12 +191,35 @@ async def count_items_by_subject( document_id: uuid.UUID | None = None, ) -> int: """Total CFItems referencing the given subject (for the count label). - Tenant-scoped; restricted to one document when ``document_id`` is given - (same scoping as ``list_items_by_subject``). Call once on the SSR page; the - "load more" fragment derives has_more from a limit+1 fetch instead of - recounting.""" - conditions = [CFItem.tenant_id == tenant_id, _subject_uri_contains(subject_identifier)] - if document_id is not None: - conditions.append(CFItem.cf_document_id == document_id) - result = await session.execute(select(func.count()).select_from(CFItem).where(*conditions)) - return int(result.scalar_one()) + Same scoping as ``list_items_by_subject``.""" + return await _count_items_where(session, _subject_conditions(tenant_id, subject_identifier, document_id)) + + +async def list_items_by_item_type( + session: AsyncSession, + tenant_id: uuid.UUID, + item_type_id: uuid.UUID, + *, + document_id: uuid.UUID | None = None, + offset: int = 0, + limit: int = 20, +) -> list[dict]: + """CFItems of the given CFItemType ("items of this type"). + + Reverse lookup for the CFItemType detail page. Matches the FK + ``cf_item.cf_item_type_id`` (indexed via the FK) — ``item_type_id`` is the + CFItemType's internal PK (``CFItemType.id``), not its CASE identifier. + Tenant-scoped; ``document_id`` restricts to one document (pane scope).""" + return await _list_items_where(session, _item_type_conditions(tenant_id, item_type_id, document_id), offset, limit) + + +async def count_items_by_item_type( + session: AsyncSession, + tenant_id: uuid.UUID, + item_type_id: uuid.UUID, + *, + document_id: uuid.UUID | None = None, +) -> int: + """Total CFItems of the given CFItemType (count label). Same scoping as + ``list_items_by_item_type``.""" + return await _count_items_where(session, _item_type_conditions(tenant_id, item_type_id, document_id)) diff --git a/src/routers/web.py b/src/routers/web.py index b93a590..c49cdad 100644 --- a/src/routers/web.py +++ b/src/routers/web.py @@ -465,6 +465,16 @@ async def _detail_extras( "limit": SUBJECT_ITEMS_PAGE, "scope_doc": None, } + # CFItemType reverse lookup: items of this type (same shape / scope rules as + # subject_items, but matched by the cf_item_type_id FK instead of JSONB). + item_type_items: dict = { + "rows": [], + "total": 0, + "has_more": False, + "next_offset": 0, + "limit": SUBJECT_ITEMS_PAGE, + "scope_doc": None, + } if resource_type == "CFDocument": rubrics = await cf_rubric_repository.list_by_document(session, resource.id) elif resource_type == "CFAssociation": @@ -589,6 +599,25 @@ async def _detail_extras( # scope (the pane keeps restricting to this document). "scope_doc": str(doc.identifier) if doc is not None else None, } + elif resource_type == "CFItemType": + # "Items of this type". Same surface-based scope as CFSubject (doc=None on + # the standalone page → tenant-wide; doc set in the pane → that document). + # Matched by the cf_item_type_id FK; `resource.id` is the type's PK. + scope_doc_id = doc.id if doc is not None else None + rows = await cf_item_repository.list_items_by_item_type( + session, tenant_id, resource.id, document_id=scope_doc_id, offset=0, limit=SUBJECT_ITEMS_PAGE + ) + total = await cf_item_repository.count_items_by_item_type( + session, tenant_id, resource.id, document_id=scope_doc_id + ) + item_type_items = { + "rows": rows, + "total": total, + "has_more": total > len(rows), + "next_offset": len(rows), + "limit": SUBJECT_ITEMS_PAGE, + "scope_doc": str(doc.identifier) if doc is not None else None, + } return { "rubrics": rubrics, "referring_criteria": referring_criteria, @@ -603,6 +632,7 @@ async def _detail_extras( "hierarchy_lower": hierarchy_lower, "incoming_refs": incoming_refs, "subject_items": subject_items, + "item_type_items": item_type_items, } @@ -621,6 +651,7 @@ async def _detail_extras( "hierarchy_lower", "incoming_refs", "subject_items", + "item_type_items", ) @@ -778,6 +809,14 @@ async def _render_tree_page( "limit": SUBJECT_ITEMS_PAGE, "scope_doc": None, } + item_type_items: dict = { + "rows": [], + "total": 0, + "has_more": False, + "next_offset": 0, + "limit": SUBJECT_ITEMS_PAGE, + "scope_doc": None, + } # Which Definitions subgroup (if any) holds the selected node — so the # template can auto-open that section/subgroup on a direct load/reload. selected_def_group: str | None = None @@ -841,6 +880,10 @@ async def _render_tree_page( # list (doc is None there). extras = await _detail_extras(session, tenant_obj.id, "CFSubject", pane_resource, doc) subject_items = extras["subject_items"] + elif pane_type == "CFItemType": + # Same document-scoped pane treatment for "items of this type". + extras = await _detail_extras(session, tenant_obj.id, "CFItemType", pane_resource, doc) + item_type_items = extras["item_type_items"] # Full-detail pane context (shared partial). `resource` is the # relationship-loaded selected resource, or the document by default. @@ -860,9 +903,10 @@ async def _render_tree_page( "hierarchy_upper": hierarchy_upper, "hierarchy_lower": hierarchy_lower, "incoming_refs": incoming_refs, - # Document-scoped in the pane (a CFSubject selected in this doc's tree - # lists only this document's items setting it); empty for non-subjects. + # Document-scoped in the pane (a CFSubject/CFItemType selected in this + # doc's tree lists only this document's items); empty for other types. "subject_items": subject_items, + "item_type_items": item_type_items, } ctx = _detail_pane_context( pane_extras, @@ -1149,6 +1193,25 @@ async def children_fragment( return response +def _items_fragment_ctx(rows, offset, limit, items_endpoint, scope_doc_ident, tenant_url, t) -> dict: + """Context for the shared `subject_items.html` "load more" fragment. `rows` + is a limit+1 fetch; has_more is derived from it (no recount). `items_endpoint` + is the base URL the partial appends `/items?...` to (keeps subject vs + item-type generic).""" + has_more = len(rows) > limit + rows = rows[:limit] + return { + "rows": rows, + "next_offset": offset + len(rows), + "limit": limit, + "has_more": has_more, + "items_endpoint": items_endpoint, + "scope_doc": scope_doc_ident, + "tenant_url": tenant_url, + "t": t, + } + + @router.get( "/{tenant}/subject/{subject_id}/items", response_class=HTMLResponse, @@ -1203,18 +1266,63 @@ async def subject_items_fragment( rows = await cf_item_repository.list_items_by_subject( session, tenant_obj.id, str(subject_uuid), document_id=scope_doc_id, offset=offset, limit=limit + 1 ) - has_more = len(rows) > limit - rows = rows[:limit] - ctx = { - "rows": rows, - "next_offset": offset + len(rows), - "limit": limit, - "has_more": has_more, - "subject_id": str(subject_uuid), - "scope_doc": scope_doc_ident, - "tenant_url": _tenant_url_segment(tenant, tenant_obj), - "t": t, - } + tenant_url = _tenant_url_segment(tenant, tenant_obj) + ctx = _items_fragment_ctx( + rows, offset, limit, f"/{tenant_url}/subject/{subject_uuid}", scope_doc_ident, tenant_url, t + ) + response = templates.TemplateResponse(request, "fragments/subject_items.html", ctx) + response.headers["Cache-Control"] = CACHE_CONTROL_FRAGMENT + return response + + +@router.get( + "/{tenant}/item-type/{item_type_id}/items", + response_class=HTMLResponse, +) +async def item_type_items_fragment( + tenant: str, + item_type_id: str, + request: Request, + offset: int = 0, + limit: int = SUBJECT_ITEMS_PAGE, + doc: str | None = None, + session: AsyncSession = Depends(get_session), +) -> HTMLResponse: + """HTMX fragment: the next page of "items of this type" ("load more"), the + CFItemType analogue of `subject_items_fragment`. Same markup / scoping (`doc` + keeps the pane's document scope through pagination).""" + t = get_translator(_get_lang(request)) + tenant_obj = await tenant_service.resolve_tenant(session, tenant) + if tenant_obj is None: + return _error_fragment(404, t("error_tenant_not_found")) + + item_type_uuid = _parse_uuid(item_type_id) + if item_type_uuid is None: + return _error_fragment(400, t("error_bad_request")) + result = await uri_service.find_resource_by_identifier(session, tenant_obj.id, item_type_uuid) + if result is None or result.resource_type != "CFItemType": + return _error_fragment(404, t("error_not_found")) + + scope_doc_id, scope_doc_ident = None, None + if doc is not None: + doc_uuid = _parse_uuid(doc) + if doc_uuid is None: + return _error_fragment(400, t("error_bad_request")) + scope_doc = await tree_service.get_document_for_tree(session, tenant_obj.id, doc_uuid) + if scope_doc is None: + return _error_fragment(404, t("error_document_not_found")) + scope_doc_id = scope_doc.id + scope_doc_ident = str(scope_doc.identifier) + + offset = max(0, offset) + limit = max(1, min(limit, SUBJECT_ITEMS_PAGE)) + rows = await cf_item_repository.list_items_by_item_type( + session, tenant_obj.id, result.resource.id, document_id=scope_doc_id, offset=offset, limit=limit + 1 + ) + tenant_url = _tenant_url_segment(tenant, tenant_obj) + ctx = _items_fragment_ctx( + rows, offset, limit, f"/{tenant_url}/item-type/{item_type_uuid}", scope_doc_ident, tenant_url, t + ) response = templates.TemplateResponse(request, "fragments/subject_items.html", ctx) response.headers["Cache-Control"] = CACHE_CONTROL_FRAGMENT return response diff --git a/src/templates/fragments/resource_detail.html b/src/templates/fragments/resource_detail.html index 4a7fbbd..2f5e1c1 100644 --- a/src/templates/fragments/resource_detail.html +++ b/src/templates/fragments/resource_detail.html @@ -952,14 +952,30 @@

{{ t("rubric_levels") }} - {# CFSubject reverse lookup: items in this tenant that set this subject. #} + {# Reverse lookup: items using this definition. CFSubject → "items setting + this subject" (JSONB subjectURI); CFItemType → "items of this type" (FK). + Same partial; only the data dict, label, and items_endpoint differ. #} {% if resource_type == "CFSubject" and subject_items.rows %} {% call section(t("subject_items_label")) %}

{{ t("subject_items_count", count=subject_items.total|string) }}

    - {% with rows=subject_items.rows, next_offset=subject_items.next_offset, limit=subject_items.limit, has_more=subject_items.has_more, subject_id=resource.identifier, scope_doc=subject_items.scope_doc %} + {% with rows=subject_items.rows, next_offset=subject_items.next_offset, limit=subject_items.limit, has_more=subject_items.has_more, scope_doc=subject_items.scope_doc, items_endpoint="/" ~ tenant_url ~ "/subject/" ~ resource.identifier|string %} + {% include "fragments/subject_items.html" %} + {% endwith %} +
+
+
+ {% endcall %} + {% endif %} + {% if resource_type == "CFItemType" and item_type_items.rows %} + {% call section(t("item_type_items_label")) %} +
+
+

{{ t("subject_items_count", count=item_type_items.total|string) }}

+
    + {% with rows=item_type_items.rows, next_offset=item_type_items.next_offset, limit=item_type_items.limit, has_more=item_type_items.has_more, scope_doc=item_type_items.scope_doc, items_endpoint="/" ~ tenant_url ~ "/item-type/" ~ resource.identifier|string %} {% include "fragments/subject_items.html" %} {% endwith %}
diff --git a/src/templates/fragments/subject_items.html b/src/templates/fragments/subject_items.html index 844b922..4811cff 100644 --- a/src/templates/fragments/subject_items.html +++ b/src/templates/fragments/subject_items.html @@ -2,10 +2,12 @@ Used both as an SSR include on the CFSubject detail card and as the response of the /{tenant}/subject/{id}/items "load more" route (same markup, like tree_nodes.html). Context: rows, next_offset, limit, has_more, subject_id, - tenant_url, t, scope_doc. Each row links to the item in its own document's - tree. `scope_doc` (a document identifier or None) is threaded into the - "load more" URL so pagination keeps the pane's document scope; None → - tenant-wide (standalone /uri/ page). #} + tenant_url, t, scope_doc, items_endpoint. Each row links to the item in its + own document's tree. `items_endpoint` is the base URL the "load more" button + appends `/items?…` to (e.g. `/{tenant}/subject/{id}` or + `/{tenant}/item-type/{id}`), so this partial serves any definition type. + `scope_doc` (a document identifier or None) is threaded into the load-more URL + so pagination keeps the pane's document scope; None → tenant-wide. #} {% for row in rows %}
  • diff --git a/tests/unit/test_item_type_items.py b/tests/unit/test_item_type_items.py new file mode 100644 index 0000000..1e2e4c4 --- /dev/null +++ b/tests/unit/test_item_type_items.py @@ -0,0 +1,210 @@ +"""Tests for the CFItemType reverse lookup: "items of this type". + +Mirrors the CFSubject reverse lookup but matches the `cf_item_type_id` FK +(not JSONB). Repository (FK filter, tenant scope, document scope, pagination) ++ Web UI (item-type detail section, "load more" fragment, validation). +""" + +import uuid +from datetime import datetime, timezone + +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.cf_document import CFDocument +from src.models.cf_item import CFItem +from src.models.cf_item_type import CFItemType +from src.models.tenant import Tenant +from src.repositories import cf_item_repository +from src.routers.web import _detail_extras + +_TS = datetime(2025, 1, 1, tzinfo=timezone.utc) + + +def _make_item_type(tenant: Tenant, *, title: str = "Knowledge") -> CFItemType: + ident = uuid.uuid4() + return CFItemType( + tenant_id=tenant.id, + identifier=ident, + uri=f"https://example.com/uri/{ident}", + title=title, + last_change_date_time=_TS, + ) + + +def _make_item( + tenant: Tenant, + doc: CFDocument, + *, + item_type: CFItemType | None = None, + hcs: str | None = None, + full_statement: str = "stmt", +) -> CFItem: + ident = uuid.uuid4() + return CFItem( + tenant_id=tenant.id, + cf_document_id=doc.id, + cf_item_type_id=item_type.id if item_type is not None else None, + identifier=ident, + uri=f"https://example.com/uri/{ident}", + full_statement=full_statement, + human_coding_scheme=hcs, + depth=0, + last_change_date_time=_TS, + ) + + +def _make_document(tenant: Tenant, *, title: str = "Doc B") -> CFDocument: + ident = uuid.uuid4() + return CFDocument( + id=uuid.uuid4(), + tenant_id=tenant.id, + identifier=ident, + uri=f"https://example.com/uri/{ident}", + title=title, + creator="x", + language="ja", + last_change_date_time=_TS, + ) + + +# --------------------------------------------------------------------------- +# Repository +# --------------------------------------------------------------------------- + + +class TestListItemsByItemType: + async def test_fk_match_and_exclusion(self, db_session: AsyncSession, tenant: Tenant, sample_document: CFDocument): + it = _make_item_type(tenant) + other = _make_item_type(tenant, title="Skill") + db_session.add_all([it, other]) + await db_session.flush() + db_session.add(_make_item(tenant, sample_document, item_type=it, full_statement="of type")) + db_session.add(_make_item(tenant, sample_document, item_type=other, full_statement="other type")) + db_session.add(_make_item(tenant, sample_document, item_type=None, full_statement="no type")) + await db_session.flush() + + rows = await cf_item_repository.list_items_by_item_type(db_session, tenant.id, it.id) + assert {r["full_statement"] for r in rows} == {"of type"} + assert await cf_item_repository.count_items_by_item_type(db_session, tenant.id, it.id) == 1 + + async def test_document_scope(self, db_session: AsyncSession, tenant: Tenant, sample_document: CFDocument): + it = _make_item_type(tenant) + db_session.add(it) + await db_session.flush() + db_session.add(_make_item(tenant, sample_document, item_type=it, full_statement="in A")) + doc_b = _make_document(tenant) + db_session.add(doc_b) + await db_session.flush() + db_session.add(_make_item(tenant, doc_b, item_type=it, full_statement="in B")) + await db_session.flush() + + assert await cf_item_repository.count_items_by_item_type(db_session, tenant.id, it.id) == 2 + scoped = await cf_item_repository.list_items_by_item_type( + db_session, tenant.id, it.id, document_id=sample_document.id + ) + assert {r["full_statement"] for r in scoped} == {"in A"} + + async def test_pagination(self, db_session: AsyncSession, tenant: Tenant, sample_document: CFDocument): + it = _make_item_type(tenant) + db_session.add(it) + await db_session.flush() + for i in range(25): + db_session.add( + _make_item(tenant, sample_document, item_type=it, hcs=f"{i:03d}", full_statement=f"i{i:03d}") + ) + await db_session.flush() + page1 = await cf_item_repository.list_items_by_item_type(db_session, tenant.id, it.id, offset=0, limit=20) + page2 = await cf_item_repository.list_items_by_item_type(db_session, tenant.id, it.id, offset=20, limit=20) + assert len(page1) == 20 and len(page2) == 5 + assert set(r["identifier"] for r in page1).isdisjoint(r["identifier"] for r in page2) + + +# --------------------------------------------------------------------------- +# Web UI +# --------------------------------------------------------------------------- + + +class TestItemTypeDetailAndPane: + async def test_detail_extras_scopes_by_doc( + self, db_session: AsyncSession, tenant: Tenant, sample_document: CFDocument + ): + it = _make_item_type(tenant) + db_session.add(it) + await db_session.flush() + db_session.add(_make_item(tenant, sample_document, item_type=it, full_statement="A item")) + doc_b = _make_document(tenant) + db_session.add(doc_b) + await db_session.flush() + db_session.add(_make_item(tenant, doc_b, item_type=it, full_statement="B item")) + await db_session.flush() + + page = await _detail_extras(db_session, tenant.id, "CFItemType", it) + assert page["item_type_items"]["total"] == 2 + assert page["item_type_items"]["scope_doc"] is None + + pane = await _detail_extras(db_session, tenant.id, "CFItemType", it, sample_document) + assert pane["item_type_items"]["total"] == 1 + assert pane["item_type_items"]["scope_doc"] == str(sample_document.identifier) + + async def test_standalone_page_shows_items( + self, db_session: AsyncSession, db_client, tenant: Tenant, sample_document: CFDocument + ): + it = _make_item_type(tenant, title="Knowledge & Skills") + db_session.add(it) + await db_session.flush() + db_session.add(_make_item(tenant, sample_document, item_type=it, full_statement="typed item one")) + await db_session.flush() + + resp = await db_client.get(f"/{tenant.id}/uri/{it.identifier}") + assert resp.status_code == 200 + assert "この種別の項目" in resp.text + assert "typed item one" in resp.text + + async def test_pane_fragment_shows_doc_scoped( + self, db_session: AsyncSession, db_client, tenant: Tenant, sample_document: CFDocument + ): + # An item type used by an item in this doc is a tree node (Definitions), + # so its detail fragment renders in the pane with the doc-scoped list. + it = _make_item_type(tenant, title="Pane Type") + db_session.add(it) + await db_session.flush() + db_session.add(_make_item(tenant, sample_document, item_type=it, full_statement="typed in pane")) + await db_session.flush() + + resp = await db_client.get(f"/{tenant.id}/cftree/doc/{sample_document.identifier}/detail/{it.identifier}") + assert resp.status_code == 200 + assert "typed in pane" in resp.text + + async def test_load_more_url_uses_item_type_endpoint( + self, db_session: AsyncSession, db_client, tenant: Tenant, sample_document: CFDocument + ): + it = _make_item_type(tenant) + db_session.add(it) + await db_session.flush() + for i in range(25): + db_session.add( + _make_item(tenant, sample_document, item_type=it, hcs=f"{i:03d}", full_statement=f"t{i:03d}") + ) + await db_session.flush() + + resp = await db_client.get(f"/{tenant.id}/uri/{it.identifier}") + assert resp.status_code == 200 + assert f"/{tenant.id}/item-type/{it.identifier}/items?offset=20" in resp.text + + async def test_fragment_filters_and_validates( + self, db_session: AsyncSession, db_client, tenant: Tenant, sample_document: CFDocument + ): + it = _make_item_type(tenant) + db_session.add(it) + await db_session.flush() + db_session.add(_make_item(tenant, sample_document, item_type=it, full_statement="frag item")) + await db_session.flush() + + ok = await db_client.get(f"/{tenant.id}/item-type/{it.identifier}/items?offset=0&limit=20") + assert ok.status_code == 200 + assert "frag item" in ok.text + + # A subject/non-itemtype id → 404; bad uuid → 400. + bad = await db_client.get(f"/{tenant.id}/item-type/{uuid.uuid4()}/items") + assert bad.status_code == 404 + assert (await db_client.get(f"/{tenant.id}/item-type/not-a-uuid/items")).status_code == 400