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
29 changes: 28 additions & 1 deletion docs/spec/web-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,27 @@ Zones 3–5 render with a top separator and a small uppercase section title, and

The field tables below define each field's presence rule and rendering; the zone placement follows the mapping above.

### Detail card — what shows where, under what conditions

The same card (`fragments/resource_detail.html`) renders on two surfaces:
- **Standalone `/uri/{uuid}` page** — permalink for any resource; no tree context. `in_pane=False` (shows the "Show in tree" link).
- **Tree right pane** — the selected node's detail beside the document tree. `in_pane=True`. Reached two ways: the SSR deep-link `/cftree/doc/{doc}/item/{id}` (`_render_tree_page`) and the HTMX fragment `/cftree/doc/{doc}/detail/{id}` (`_pane_fragment_response`). Both render identical content.

Beyond the always-present fields, these **conditional / relational sections** appear per resource type and surface. All cross-tenant resolution is **public-only** (a private or nonexistent target tenant is dropped entirely — neither title, URI, link, nor count is surfaced; "case A"). Server-side builder: `web._detail_extras`.

| Section | On which resource | Standalone `/uri/` page | Tree right pane | Conditions / rules |
|---|---|---|---|---|
| **Rubrics** list | CFDocument | ✓ | ✓ (also the tree's "Rubrics" section) | document has ≥1 CFRubric |
| **Related** (grouped associations) | CFItem | ✓ | ✓ | item is an association origin/destination. Each target classified: same-doc → in-pane nav; **other doc, same tenant** → tree-switch link + "Other framework" badge; **other public tenant** → that tenant's tree + "Other institution ↗" badge; **private/external** → hidden or external-link (http(s) only) |
| **Cross-doc hierarchy** (上位/下位 別FW) | CFItem | ✓ | ✓ | `isChildOf` parent/child lives in another framework. Same public/private cross-tenant rules as Related |
| **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 |
| **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") |

**Pane-only invariant ("pane content ⟺ tree node"):** in the tree pane a selected **lookup** must be in the current document's Definitions, and a selected **rubric part** must belong to the current document — otherwise the pane route returns **404** (the standalone `/uri/` page has no such restriction; it resolves any resource). This is why a CFSubject is reachable in the pane only when the current document actually references it.

### CFItem

| Field | Required/Optional | Display |
Expand Down Expand Up @@ -217,7 +238,13 @@ When `/uri/{uuid}` resolves to a lookup, show common + specific fields:
| Specific fields | typeCode, hierarchyCode, licenseText, etc. (only when present) |
| lastChangeDateTime | ISO 8601 |

**CFSubject — "Items setting this subject" (reverse lookup):** a CFSubject's card adds a section listing CFItems **in the same tenant** whose `subjectURI` references this subject (the reverse of CFItem → CFSubject). It shows the total count + the first page (top 20) inline (SSR), then a **"Show more"** button that appends the next page via HTMX from `GET /{tenant}/subject/{subject-id}/items?offset=&limit=` (`Cache-Control: public, max-age=86400`; the button is self-perpetuating — each click swaps it for the next page + a fresh button, like the lazy tree). Resolved server-side by `cf_item_repository.list_items_by_subject` / `count_items_by_subject` via the JSONB containment query `subject_uri @> '[{"identifier": ...}]'` (GIN-indexed: `ix_cf_items_subject_uri_gin`). Each row links to the item in its own document's tree. Scope notes: **within-tenant only** (a subject is tenant-owned; no cross-tenant/private gating); items that reference 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). The section is **not** shown in the tree right pane (the pane is document-scoped). Other lookups (CFItemType/Concept/License) do not yet have an equivalent reverse list.
**CFSubject — "Items setting this subject" (reverse lookup):** a CFSubject's card adds a section listing CFItems whose `subjectURI` references this subject (the reverse of CFItem → CFSubject). It shows the total count + the first page (top 20) inline (SSR), then a **"Show more"** button that appends the next page via HTMX from `GET /{tenant}/subject/{subject-id}/items?offset=&limit=[&doc=]` (`Cache-Control: public, max-age=86400`; the button is self-perpetuating — each click swaps it for the next page + a fresh button, like the lazy tree). Resolved server-side by `cf_item_repository.list_items_by_subject` / `count_items_by_subject` via the JSONB containment query `subject_uri @> '[{"identifier": ...}]'` (GIN-indexed: `ix_cf_items_subject_uri_gin`). Each row links to the item in its own document's tree.

**Scope differs by surface (案B):**
- **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.

### CFRubric

Expand Down
32 changes: 21 additions & 11 deletions src/repositories/cf_item_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,17 +112,24 @@ async def list_items_by_subject(
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": <id>}]'``
(GIN-indexed). Tenant-scoped. Ordered by human_coding_scheme → full_statement
→ identifier for a stable offset-pagination boundary. Returns
(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
``[{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,
Expand All @@ -132,7 +139,7 @@ async def list_items_by_subject(
CFDocument.title,
)
.join(CFDocument, CFItem.cf_document_id == CFDocument.id)
.where(CFItem.tenant_id == tenant_id, _subject_uri_contains(subject_identifier))
.where(*conditions)
.order_by(
CFItem.human_coding_scheme.nullslast(),
CFItem.full_statement,
Expand All @@ -157,13 +164,16 @@ async def count_items_by_subject(
session: AsyncSession,
tenant_id: uuid.UUID,
subject_identifier: str,
*,
document_id: uuid.UUID | None = None,
) -> int:
"""Total CFItems in this tenant referencing the given subject (for the count
label). Call once on the SSR page; the "load more" fragment derives has_more
from a limit+1 fetch instead of recounting."""
result = await session.execute(
select(func.count())
.select_from(CFItem)
.where(CFItem.tenant_id == tenant_id, _subject_uri_contains(subject_identifier))
)
"""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())
74 changes: 57 additions & 17 deletions src/routers/web.py
Original file line number Diff line number Diff line change
Expand Up @@ -415,8 +415,6 @@ async def _detail_extras(
resource,
doc=None,
tree_index=None,
*,
include_subject_items: bool = True,
) -> dict:
"""Extra context the resource-detail card needs beyond the resource itself:
rubrics (CFDocument), referring criteria + related groupings (CFItem).
Expand Down Expand Up @@ -465,6 +463,7 @@ async def _detail_extras(
"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)
Expand Down Expand Up @@ -567,22 +566,28 @@ async def _detail_extras(
related_other_tenant = await _resolve_cross_tenant(session, tenant_id, unresolved)
hierarchy_upper, hierarchy_lower = await _cross_doc_hierarchy(session, tenant_id, resource, doc)
incoming_refs = await _incoming_refs(session, tenant_id, resource)
elif resource_type == "CFSubject" and include_subject_items:
# Only the standalone /uri/ page lists "items setting this subject". The
# tree right pane is document-scoped, so pane callers pass
# include_subject_items=False — keeping it hidden there AND skipping the
# (potentially large) reverse-lookup query.
elif resource_type == "CFSubject":
# "Items setting this subject". Scope follows the surface (`doc`):
# - standalone /uri/ page: `doc` is None (a subject is a tenant-owned
# lookup with no owning document) → tenant-wide list.
# - tree right pane: `doc` is the current document → restrict to it, so
# the pane stays document-scoped (every listed item is a node in the
# current tree; no jumping to another doc, and the query is cheap).
sid = str(resource.identifier)
scope_doc_id = doc.id if doc is not None else None
rows = await cf_item_repository.list_items_by_subject(
session, tenant_id, sid, offset=0, limit=SUBJECT_ITEMS_PAGE
session, tenant_id, sid, document_id=scope_doc_id, offset=0, limit=SUBJECT_ITEMS_PAGE
)
total = await cf_item_repository.count_items_by_subject(session, tenant_id, sid)
total = await cf_item_repository.count_items_by_subject(session, tenant_id, sid, document_id=scope_doc_id)
subject_items = {
"rows": rows,
"total": total,
"has_more": total > len(rows),
"next_offset": len(rows),
"limit": SUBJECT_ITEMS_PAGE,
# Carried into the "load more" URL so pagination stays in the same
# scope (the pane keeps restricting to this document).
"scope_doc": str(doc.identifier) if doc is not None else None,
}
return {
"rubrics": rubrics,
Expand Down Expand Up @@ -765,6 +770,14 @@ async def _render_tree_page(
hierarchy_upper: list[dict] = []
hierarchy_lower: list[dict] = []
incoming_refs: list[dict] = []
subject_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
Expand Down Expand Up @@ -821,6 +834,13 @@ async def _render_tree_page(
hierarchy_upper = extras["hierarchy_upper"]
hierarchy_lower = extras["hierarchy_lower"]
incoming_refs = extras["incoming_refs"]
elif pane_type == "CFSubject":
# Pane is document-scoped: list only this document's items that
# set the subject (passing `doc` scopes the query — see
# _detail_extras). The standalone /uri/ page shows the tenant-wide
# list (doc is None there).
extras = await _detail_extras(session, tenant_obj.id, "CFSubject", pane_resource, doc)
subject_items = extras["subject_items"]

# Full-detail pane context (shared partial). `resource` is the
# relationship-loaded selected resource, or the document by default.
Expand All @@ -840,9 +860,9 @@ async def _render_tree_page(
"hierarchy_upper": hierarchy_upper,
"hierarchy_lower": hierarchy_lower,
"incoming_refs": incoming_refs,
# The tree pane is document-scoped; a subject's cross-document "items
# setting this subject" list is shown only on the standalone /uri/ page.
"subject_items": {"rows": [], "total": 0, "has_more": False, "next_offset": 0, "limit": SUBJECT_ITEMS_PAGE},
# 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.
"subject_items": subject_items,
}
ctx = _detail_pane_context(
pane_extras,
Expand Down Expand Up @@ -1007,9 +1027,10 @@ async def _pane_fragment_response(
"""Render the shared full-detail card as a right-pane HTMX fragment."""
lang = _get_lang(request)
t = get_translator(lang)
# in_pane: the tree pane is document-scoped, so suppress the CFSubject
# "items setting this subject" reverse list here (don't compute or render it).
extras = await _detail_extras(session, tenant_obj.id, resource_type, resource, doc, include_subject_items=False)
# Passing `doc` document-scopes the CFSubject "items setting this subject"
# list to the current tree (see _detail_extras); the standalone /uri/ page
# has no doc and shows the tenant-wide list.
extras = await _detail_extras(session, tenant_obj.id, resource_type, resource, doc)
# in_pane=True → the redundant "Show in tree" link is hidden by the partial.
ctx = _detail_pane_context(
extras,
Expand Down Expand Up @@ -1138,13 +1159,18 @@ async def subject_items_fragment(
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 setting this subject", appended on
the CFSubject detail page ("load more"). Mirrors the tree's lazy-load shape —
returns the same `subject_items.html` markup (rows + a self-perpetuating
button), so each click swaps the button for the next page + a fresh button.
Within-tenant only. has_more is derived from a limit+1 fetch (no recount)."""
Within-tenant only. has_more is derived from a limit+1 fetch (no recount).

Optional `doc` keeps the pane's document scope across pagination: when given,
only that document's items are listed (matching the tree-pane view); omitted
on the standalone /uri/ page → tenant-wide."""
t = get_translator(_get_lang(request))
tenant_obj = await tenant_service.resolve_tenant(session, tenant)
if tenant_obj is None:
Expand All @@ -1159,10 +1185,23 @@ async def subject_items_fragment(
if result is None or result.resource_type != "CFSubject":
return _error_fragment(404, t("error_not_found"))

# Optional document scope (pane). Validate it's a document in this tenant.
scope_doc_id = None
scope_doc_ident = 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_subject(
session, tenant_obj.id, str(subject_uuid), offset=offset, limit=limit + 1
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]
Expand All @@ -1172,6 +1211,7 @@ async def subject_items_fragment(
"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,
}
Expand Down
2 changes: 1 addition & 1 deletion src/templates/fragments/resource_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -959,7 +959,7 @@ <h3 class="text-sm font-semibold text-stone-600 mb-2">{{ t("rubric_levels") }}</
<dd class="text-gray-800">
<p class="text-xs text-stone-400 mb-2">{{ t("subject_items_count", count=subject_items.total|string) }}</p>
<ul class="ml-4 space-y-1">
{% 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 %}
{% 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 %}
{% include "fragments/subject_items.html" %}
{% endwith %}
</ul>
Expand Down
Loading
Loading