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
3 changes: 3 additions & 0 deletions docs/spec/web-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
| GET /{tenant-uuid}/cftree/doc/{doc-uuid}/item/{item-uuid} | Same tree view with an item selected via the **URL path** — the canonical, shareable, reload-safe form that in-tree navigation pushes (`hx-push-url`). Opening/reloading/sharing reconstructs the tree (the ancestor path expanded to the item) + the item's full detail via SSR. |
| GET /{tenant-uuid}/cftree/doc/{doc-uuid}/detail/{item-uuid} | HTML fragment of an item's detail (for the HTMX right pane). |
| GET /{tenant-uuid}/cftree/doc/{doc-uuid}/document | HTML fragment of the document's own detail (right pane). Separate from `/detail/{item}` to avoid identifier collisions. |
| GET /{tenant-uuid}/subject/{subject-uuid}/items | HTML fragment: the next page of "items setting this subject" for the CFSubject detail page ("Show more" pagination). Same `subject_items.html` markup as the inline SSR. 404 unless the id is a CFSubject in this tenant. |
| GET /{tenant-uuid}/uri/{uuid} | Resource detail page (HTML by default; **303 See Other** to the matching CASE API endpoint when the `Accept` header signals a JSON client — see [api-spec.md](api-spec.md#tenanturiuuid-content-negotiation)). |

**Manual list ordering (`display_order`)**: both lists honor an optional `display_order` integer (on `tenants` / `cf_documents`) so an operator can pin/arrange entries without renaming. Smaller = higher; `NULL` (the default) sinks below all explicitly-ordered entries and then sorts alphabetically. It's a compeito-local display field — not a CASE field, never emitted in CASE export, and preserved on re-import. Set it via `cli.py tenant update --display-order N` / `--clear-order` and `cli.py doc update --display-order N` / `--clear-order` (see [cli.md](cli.md)).
Expand Down Expand Up @@ -216,6 +217,8 @@ 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.

### CFRubric

When `/uri/{uuid}` resolves to a CFRubric, show the rubric detail along with Criteria/Levels in a table:
Expand Down
3 changes: 3 additions & 0 deletions src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@
"related_other_institution": "Other institution ↗",
"related_other_institution_in": "Other institution ↩",
"incoming_refs_label": "Referenced by (other institutions)",
"subject_items_label": "Items setting this subject",
"subject_items_count": "{count} total",
"show_more": "Show more",
"related_external": "External",
"f_subject": "Subject",
"f_subject_uri": "Subject URI",
Expand Down
3 changes: 3 additions & 0 deletions src/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@
"related_other_institution": "他機関 ↗",
"related_other_institution_in": "他機関 ↩",
"incoming_refs_label": "参照元(他機関)",
"subject_items_label": "この分野を設定している項目",
"subject_items_count": "全 {count} 件",
"show_more": "もっと見る",
"related_external": "外部",
"f_subject": "教科",
"f_subject_uri": "教科 URI",
Expand Down
74 changes: 73 additions & 1 deletion src/repositories/cf_item_repository.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
import uuid

from sqlalchemy import select
from sqlalchemy import cast, func, select
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload

from src.models.cf_document import CFDocument
from src.models.cf_item import CFItem


def _subject_uri_contains(subject_identifier: str):
"""JSONB containment predicate: subject_uri @> '[{"identifier": <id>}]'.

The operand is cast to JSONB explicitly so the GIN index
(ix_cf_items_subject_uri_gin, jsonb_path_ops) is used rather than a seq scan.
"""
return CFItem.subject_uri.contains(cast([{"identifier": subject_identifier}], JSONB))


async def map_identifiers_to_documents(
session: AsyncSession,
tenant_id: uuid.UUID,
Expand Down Expand Up @@ -95,3 +105,65 @@ async def get_cf_item_by_identifier(
.where(CFItem.tenant_id == tenant_id, CFItem.identifier == identifier)
)
return result.scalar_one_or_none()


async def list_items_by_subject(
session: AsyncSession,
tenant_id: uuid.UUID,
subject_identifier: str,
*,
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
``[{identifier, human_coding_scheme, full_statement, doc_identifier, doc_title}]``.
"""
rows = await session.execute(
select(
CFItem.identifier,
CFItem.human_coding_scheme,
CFItem.full_statement,
CFDocument.identifier,
CFDocument.title,
)
.join(CFDocument, CFItem.cf_document_id == CFDocument.id)
.where(CFItem.tenant_id == tenant_id, _subject_uri_contains(subject_identifier))
.order_by(
CFItem.human_coding_scheme.nullslast(),
CFItem.full_statement,
CFItem.identifier,
)
.offset(offset)
.limit(limit)
)
return [
{
"identifier": str(item_ident),
"human_coding_scheme": hcs,
"full_statement": full_statement,
"doc_identifier": str(doc_ident),
"doc_title": doc_title,
}
for item_ident, hcs, full_statement, doc_ident, doc_title in rows.all()
]


async def count_items_by_subject(
session: AsyncSession,
tenant_id: uuid.UUID,
subject_identifier: str,
) -> 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))
)
return int(result.scalar_one())
100 changes: 98 additions & 2 deletions src/routers/web.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ def _uri_other_tenant(uri: str | None, current_tenant_id) -> bool:
CACHE_CONTROL = "public, max-age=3600"
CACHE_CONTROL_FRAGMENT = "public, max-age=86400"

# Page size for the CFSubject "items setting this subject" reverse-lookup list
# (inline top-N on the subject page + "load more" HTMX pagination).
SUBJECT_ITEMS_PAGE = 20

# Map UriResult.resource_type to the CASE v1.1 API path segment.
# Resource types without an individual API endpoint (CFRubricCriterion,
# CFRubricCriterionLevel — nested only inside CFRubrics) are absent.
Expand Down Expand Up @@ -405,7 +409,14 @@ async def _incoming_refs(session: AsyncSession, tenant_id, item) -> list[dict]:


async def _detail_extras(
session: AsyncSession, tenant_id, resource_type: str, resource, doc=None, tree_index=None
session: AsyncSession,
tenant_id,
resource_type: str,
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 @@ -446,6 +457,15 @@ async def _detail_extras(
# this item's node_uri ("referenced by other institutions"). Private origins
# are excluded by `_incoming_refs` (case A — existence + count hidden).
incoming_refs: list[dict] = []
# CFSubject reverse lookup: items in THIS tenant that set this subject (first
# page + total + has_more). Within-tenant only (a subject is tenant-owned).
subject_items: dict = {
"rows": [],
"total": 0,
"has_more": False,
"next_offset": 0,
"limit": SUBJECT_ITEMS_PAGE,
}
if resource_type == "CFDocument":
rubrics = await cf_rubric_repository.list_by_document(session, resource.id)
elif resource_type == "CFAssociation":
Expand Down Expand Up @@ -547,6 +567,23 @@ 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.
sid = str(resource.identifier)
rows = await cf_item_repository.list_items_by_subject(
session, tenant_id, sid, offset=0, limit=SUBJECT_ITEMS_PAGE
)
total = await cf_item_repository.count_items_by_subject(session, tenant_id, sid)
subject_items = {
"rows": rows,
"total": total,
"has_more": total > len(rows),
"next_offset": len(rows),
"limit": SUBJECT_ITEMS_PAGE,
}
return {
"rubrics": rubrics,
"referring_criteria": referring_criteria,
Expand All @@ -560,6 +597,7 @@ async def _detail_extras(
"hierarchy_upper": hierarchy_upper,
"hierarchy_lower": hierarchy_lower,
"incoming_refs": incoming_refs,
"subject_items": subject_items,
}


Expand All @@ -577,6 +615,7 @@ async def _detail_extras(
"hierarchy_upper",
"hierarchy_lower",
"incoming_refs",
"subject_items",
)


Expand Down Expand Up @@ -801,6 +840,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},
}
ctx = _detail_pane_context(
pane_extras,
Expand Down Expand Up @@ -965,7 +1007,9 @@ 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)
extras = await _detail_extras(session, tenant_obj.id, resource_type, resource, doc)
# 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)
# in_pane=True → the redundant "Show in tree" link is hidden by the partial.
ctx = _detail_pane_context(
extras,
Expand Down Expand Up @@ -1084,6 +1128,58 @@ async def children_fragment(
return response


@router.get(
"/{tenant}/subject/{subject_id}/items",
response_class=HTMLResponse,
)
async def subject_items_fragment(
tenant: str,
subject_id: str,
request: Request,
offset: int = 0,
limit: int = SUBJECT_ITEMS_PAGE,
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)."""
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"))

subject_uuid = _parse_uuid(subject_id)
if subject_uuid is None:
return _error_fragment(400, t("error_bad_request"))
# The id must resolve to a CFSubject in THIS tenant (same "validate the
# target" invariant as children_fragment) — never drive this for arbitrary ids.
result = await uri_service.find_resource_by_identifier(session, tenant_obj.id, subject_uuid)
if result is None or result.resource_type != "CFSubject":
return _error_fragment(404, t("error_not_found"))

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
)
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),
"tenant_url": _tenant_url_segment(tenant, tenant_obj),
"t": t,
}
response = templates.TemplateResponse(request, "fragments/subject_items.html", ctx)
response.headers["Cache-Control"] = CACHE_CONTROL_FRAGMENT
return response


@router.get(
"/{tenant}/cftree/doc/{doc_id}/document",
response_class=HTMLResponse,
Expand Down
16 changes: 16 additions & 0 deletions src/templates/fragments/resource_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -952,6 +952,22 @@ <h3 class="text-sm font-semibold text-stone-600 mb-2">{{ t("rubric_levels") }}</
{% if resource.license_text is defined %}{{ optional_field(t("f_license_text"), resource.license_text) }}{% endif %}
</dl>

{# CFSubject reverse lookup: items in this tenant that set this subject. #}
{% if resource_type == "CFSubject" and subject_items.rows %}
{% call section(t("subject_items_label")) %}
<div>
<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 %}
{% include "fragments/subject_items.html" %}
{% endwith %}
</ul>
</dd>
</div>
{% endcall %}
{% endif %}

{% call section(t("sec_technical"), muted=true) %}
{{ code_field(t("f_identifier"), resource.identifier) }}
{{ uri_field(t("f_uri"), resource.uri) }}
Expand Down
23 changes: 23 additions & 0 deletions src/templates/fragments/subject_items.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{# Reusable list fragment: CFItems setting a subject.
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. Each row links to the item in its own document's tree (a
subject has no document, so always the cross-document deep-link form). #}
{% for row in rows %}
<li>
<a href="/{{ tenant_url }}/cftree/doc/{{ row.doc_identifier }}/item/{{ row.identifier }}"
class="text-violet-600 hover:text-violet-800 text-sm transition-colors"
{% if row.doc_title %}title="{{ row.doc_title }}"{% endif %}>
{% if row.human_coding_scheme %}<span class="font-medium">{{ row.human_coding_scheme }}</span> {% endif %}{{ row.full_statement[:100] }}{% if row.full_statement and row.full_statement|length > 100 %}…{% endif %}
</a>
</li>
{% endfor %}
{% if has_more %}
<li>
<button type="button"
hx-get="/{{ tenant_url }}/subject/{{ subject_id }}/items?offset={{ next_offset }}&limit={{ limit }}"
hx-target="closest li" hx-swap="outerHTML"
class="text-xs text-violet-600 hover:text-violet-800 underline transition-colors">{{ t("show_more") }}</button>
</li>
{% endif %}
Loading
Loading