feat(slice3): collection list endpoint returns facets + honours filters (§22.4a)
This commit is contained in:
+50
-9
@@ -41,6 +41,7 @@ from . import (
|
|||||||
docs_specs,
|
docs_specs,
|
||||||
entry as entry_mod,
|
entry as entry_mod,
|
||||||
cache,
|
cache,
|
||||||
|
facets,
|
||||||
funder,
|
funder,
|
||||||
health,
|
health,
|
||||||
notify,
|
notify,
|
||||||
@@ -737,9 +738,33 @@ def make_router(
|
|||||||
raise HTTPException(404, "Not found")
|
raise HTTPException(404, "Not found")
|
||||||
|
|
||||||
def _list_rfcs_for_collection(
|
def _list_rfcs_for_collection(
|
||||||
collection_id: str, viewer, unreviewed: str | None
|
collection_id: str, viewer, unreviewed: str | None,
|
||||||
|
query_params=None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
viewer_id = viewer.user_id if viewer else None
|
viewer_id = viewer.user_id if viewer else None
|
||||||
|
# §22.4a SLICE-3: the collection's declared field schema drives the facet
|
||||||
|
# set (None when undeclared → no facets, INV-5).
|
||||||
|
col = collections_mod.get_collection(collection_id)
|
||||||
|
fields_schema = (col or {}).get("fields") or None
|
||||||
|
|
||||||
|
# Parse + validate filter selections from the query string. Unknown
|
||||||
|
# field → 400 (§6.4). `unreviewed` keeps its existing meaning; an
|
||||||
|
# empty-valued selection is ignored, not an error (plan decision 6).
|
||||||
|
selections: dict[str, set[str]] = {}
|
||||||
|
only_malformed = False
|
||||||
|
if query_params is not None:
|
||||||
|
allowed = facets.allowed_filter_keys(fields_schema)
|
||||||
|
facet_names = {n for n, _ in facets.facet_fields(fields_schema)}
|
||||||
|
for key in query_params.keys():
|
||||||
|
if key not in allowed:
|
||||||
|
raise HTTPException(400, f"unknown filter field {key!r}")
|
||||||
|
if (query_params.get("malformed") or "").lower() in ("1", "true", "yes"):
|
||||||
|
only_malformed = True
|
||||||
|
for name in facet_names:
|
||||||
|
vals = {v for v in query_params.getlist(name) if v != ""}
|
||||||
|
if vals:
|
||||||
|
selections[name] = vals
|
||||||
|
|
||||||
unreviewed_clause = ""
|
unreviewed_clause = ""
|
||||||
if unreviewed is not None and unreviewed.lower() in ("1", "true", "yes"):
|
if unreviewed is not None and unreviewed.lower() in ("1", "true", "yes"):
|
||||||
unreviewed_clause = " AND unreviewed = 1 AND state = 'active'"
|
unreviewed_clause = " AND unreviewed = 1 AND state = 'active'"
|
||||||
@@ -747,6 +772,7 @@ def make_router(
|
|||||||
f"""
|
f"""
|
||||||
SELECT slug, title, state, rfc_id, repo,
|
SELECT slug, title, state, rfc_id, repo,
|
||||||
owners_json, arbiters_json, tags_json, metadata_malformed,
|
owners_json, arbiters_json, tags_json, metadata_malformed,
|
||||||
|
meta_json,
|
||||||
last_main_commit_at, last_entry_commit_at, updated_at
|
last_main_commit_at, last_entry_commit_at, updated_at
|
||||||
FROM cached_rfcs
|
FROM cached_rfcs
|
||||||
WHERE state IN ('super-draft', 'active')
|
WHERE state IN ('super-draft', 'active')
|
||||||
@@ -764,8 +790,16 @@ def make_router(
|
|||||||
(viewer_id, collection_id),
|
(viewer_id, collection_id),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
items = [
|
|
||||||
{
|
# Build entry dicts the facet helper understands (state + malformed +
|
||||||
|
# parsed meta), preserving SQL order.
|
||||||
|
entries = []
|
||||||
|
for r in rows:
|
||||||
|
try:
|
||||||
|
meta = json.loads(r["meta_json"]) if r["meta_json"] else {}
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
meta = {}
|
||||||
|
entries.append({
|
||||||
"slug": r["slug"],
|
"slug": r["slug"],
|
||||||
"title": r["title"],
|
"title": r["title"],
|
||||||
"state": r["state"],
|
"state": r["state"],
|
||||||
@@ -778,10 +812,13 @@ def make_router(
|
|||||||
"starred_by_me": r["slug"] in starred,
|
"starred_by_me": r["slug"] in starred,
|
||||||
"has_open_prs": False,
|
"has_open_prs": False,
|
||||||
"metadata_malformed": bool(r["metadata_malformed"]),
|
"metadata_malformed": bool(r["metadata_malformed"]),
|
||||||
}
|
"meta": meta,
|
||||||
for r in rows
|
})
|
||||||
]
|
|
||||||
return {"items": items}
|
filtered, facet_counts = facets.filter_and_count(
|
||||||
|
entries, fields_schema, selections, only_malformed=only_malformed
|
||||||
|
)
|
||||||
|
return {"items": filtered, "facets": facet_counts}
|
||||||
|
|
||||||
def _get_rfc_for_collection(collection_id: str, slug: str, viewer) -> dict[str, Any]:
|
def _get_rfc_for_collection(collection_id: str, slug: str, viewer) -> dict[str, Any]:
|
||||||
row = db.conn().execute(
|
row = db.conn().execute(
|
||||||
@@ -813,7 +850,9 @@ def make_router(
|
|||||||
auth.require_project_readable(viewer, project_id)
|
auth.require_project_readable(viewer, project_id)
|
||||||
# §22 S1: the project-scoped route serves the default collection.
|
# §22 S1: the project-scoped route serves the default collection.
|
||||||
collection_id = collections_mod.default_collection_id(project_id)
|
collection_id = collections_mod.default_collection_id(project_id)
|
||||||
return _list_rfcs_for_collection(collection_id, viewer, unreviewed)
|
return _list_rfcs_for_collection(
|
||||||
|
collection_id, viewer, unreviewed, query_params=request.query_params
|
||||||
|
)
|
||||||
|
|
||||||
@router.get("/api/projects/{project_id}/rfcs/{slug}")
|
@router.get("/api/projects/{project_id}/rfcs/{slug}")
|
||||||
async def get_project_rfc(project_id: str, slug: str, request: Request) -> dict[str, Any]:
|
async def get_project_rfc(project_id: str, slug: str, request: Request) -> dict[str, Any]:
|
||||||
@@ -835,7 +874,9 @@ def make_router(
|
|||||||
_require_collection_in_project(collection_id, project_id)
|
_require_collection_in_project(collection_id, project_id)
|
||||||
# §22.5 (S3): a hidden/gated collection 404s to a non-scope-role viewer.
|
# §22.5 (S3): a hidden/gated collection 404s to a non-scope-role viewer.
|
||||||
auth.require_collection_readable(viewer, collection_id)
|
auth.require_collection_readable(viewer, collection_id)
|
||||||
return _list_rfcs_for_collection(collection_id, viewer, unreviewed)
|
return _list_rfcs_for_collection(
|
||||||
|
collection_id, viewer, unreviewed, query_params=request.query_params
|
||||||
|
)
|
||||||
|
|
||||||
@router.get("/api/projects/{project_id}/collections/{collection_id}/rfcs/{slug}")
|
@router.get("/api/projects/{project_id}/collections/{collection_id}/rfcs/{slug}")
|
||||||
async def get_collection_rfc(
|
async def get_collection_rfc(
|
||||||
|
|||||||
Reference in New Issue
Block a user