| """routes_pages.py β X2/Y1's read of a whole dashboard page: `GET /api/v1/pages/{key}`. |
| |
| ONE route serves every ported page. That is the wave-3 promise made mechanical: Collections and |
| Procurement arrive as a builder registered in `pages.py`, and they inherit this route's session |
| wall, grant wall, BU resolution, caching and error shape without a line being written here. |
| |
| WHY THE GATE IS INSIDE THE HANDLER and not `Depends(module_gate(...))`. `module_gate` closes over a |
| FIXED module key at import; the key here is a PATH PARAMETER, so the check has to happen once the |
| key is known. It is the same predicate (`Session.require` β `perms.may_open`), reached from |
| `pages.build_envelope` so a future builder cannot be added without it. |
| |
| FAIL-CLOSED, and the order matters: |
| no cookie -> 401 (require_session) |
| unknown / unported page key -> 404 (a fact about our roadmap, not about this user) |
| no grant for the page -> 403 (never an empty 200 β an empty page reads as "no data") |
| a BU the account may not see -> 403 (never coerced to their own BU) |
| a malformed bu -> 400 |
| """ |
| from fastapi import APIRouter, Depends, Query |
|
|
| import pages |
| |
| |
| |
| |
| |
| |
| |
| from deps import Session, require_session |
|
|
| router = APIRouter(prefix="/api/v1") |
|
|
|
|
| @router.get("/pages/{key}") |
| def page_data(key: str, |
| bu: str = Query(default=None, |
| description="'all' or a business-unit id. NARROWS the session's own " |
| "scope; it can never widen it."), |
| period: str = Query(default=None, |
| description="Page-specific display parameter. The resolved value " |
| "is echoed in controls[].value."), |
| session: Session = Depends(require_session)): |
| """The Y1 envelope for one page, scoped to this session's BU + own-book grant.""" |
| return pages.build_envelope(session, key, bu=bu, period=period) |
|
|
|
|
| @router.get("/pages") |
| def page_index(session: Session = Depends(require_session)): |
| """Which pages have published data, filtered to what this session may open. |
| |
| The shell needs this to know whether a nav entry leads to a ported page or to the Streamlit |
| host during the strangler period β and it is `may_open`-filtered for the same reason |
| `/api/v1/nav` is: the client renders what it is given and never decides who may see what. |
| |
| β AN EMPTY LIST IS A LEGITIMATE 200 HERE, and that is not a hole in the never-an-empty-200 rule. |
| `/api/v1/nav` 403s on empty because a session that may open NOTHING is a misconfigured account. |
| This route answers "which of your pages have been PORTED yet", and during the strangler period |
| "none of them" is the ordinary, truthful answer for a user granted only surfaces that still |
| live in the Streamlit host. Returning 403 would say they lack access to pages they can see. |
| """ |
| from deps import perms |
| keys = [k for k in pages.known_pages() |
| if perms.may_open(session.user, (pages.builder_for(k).MODULE))] |
| return {"pages": keys} |
|
|