diff --git a/docs/superpowers/plans/2026-08-07-ngviews-02-api.md b/docs/superpowers/plans/2026-08-07-ngviews-02-api.md new file mode 100644 index 00000000..80d4d817 --- /dev/null +++ b/docs/superpowers/plans/2026-08-07-ngviews-02-api.md @@ -0,0 +1,656 @@ +# Neuroglancer Views — PR 2 (`ngviews-02-api`) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add the HTTP layer for Neuroglancer Views — owner CRUD, a public read-link state endpoint, dependent-Views discovery, and the Data-Link deletion guard with `mark_broken`/`cascade` modes — on top of PR 1's data model. + +**Architecture:** All routes are `@app.(...)` decorators inside the single `create_app(settings)` in `fileglancer/server.py` (no APIRouter modules). Mirror the existing `neuroglancer` and `proxied-path` route idioms exactly. The client builds `ng_state`; the backend only stores it. Two new DB helpers back the delete modes. **No Alembic migration** — PR 1's schema already has nullable `data_link_id` + `broken`. + +**Tech Stack:** Python 3.12, FastAPI, SQLAlchemy (sync), Pydantic v2, pytest + Starlette `TestClient`. All commands run through **pixi**. + +## Global Constraints + +- **Always use pixi.** Backend tests: `pixi run -e test test-backend -- `. Never call `pytest` directly. +- **Branch:** all commits land on `ngviews-02-api`, branched off **`ngviews-01-model`** (this is stacked PR 2; its base is PR 1, not `main`). Create it before Task 1: `git checkout ngviews-01-model && git checkout -b ngviews-02-api`. +- **No schema change / no migration** in this PR. If a task seems to need one, stop and report — it means a design error. +- **Auth pattern:** every owner route takes `username: str = Depends(get_current_user)` (import: `from fileglancer.server` — it's already defined at `server.py:173`). The public read route takes **no** `username` and **no** `Depends`, and is marked `include_in_schema=False`, exactly like `GET /ng/{short_key}` (`server.py:1257`). +- **DB session pattern:** open `with db.get_db_session(settings.db_url) as session:` inside the handler body (`settings` is the `create_app` closure variable). Build any Pydantic response model **inside** the `with` block (so ORM relationship attributes like `.layers` load before the session closes). +- **Error pattern:** `raise HTTPException(status_code=404, detail="...")` for not-found; `400` for bad input; `409` for the delete-guard conflict. Mirror the neuroglancer/proxied-path routes. +- **Route registration:** add routes inside `create_app` **before** the SPA catch-all `@app.get("/{full_path:path}")` (`server.py:~2722`, "must be the LAST route"). Views CRUD go after the `GET /api/neuroglancer/nglinks` route (`server.py:~1309`); the public `GET /ngview/{key}` goes near the `/ng/...` routes (`server.py:~1278`); the dependent-views route and the delete-mode change go with the proxied-path routes (`server.py:~1247`). +- **`View` responses carry `read_key`; do NOT add URL fields to the `View`/`ViewLayer` Pydantic models.** The frontend builds the read link and the iframe `ng_state` URL from `read_key`. +- **Read-key sessions never write.** The public `GET /ngview/{key}` is read-only. + +**Interfaces already provided by PR 1 (in `fileglancer/database.py`):** +- `create_view(session, username, name, ng_state, layers, sharing_mode='read') -> ViewDB` — each layer dict is `{data_link_id, layer_index, channel, opts}`. +- `get_view_by_short_key(session, short_key) -> ViewDB | None` (owner-facing) +- `get_view_by_read_key(session, read_key) -> ViewDB | None` (public read) +- `get_views(session, username) -> List[ViewDB]` (newest first) +- `update_view(session, username, short_key, name=None, ng_state=None) -> ViewDB | None` (None if not owned) +- `delete_view(session, username, short_key) -> int` (0/1, cascades layers) +- `get_views_for_data_link(session, data_link_id) -> List[ViewDB]` +- `get_proxied_path_by_sharing_key(session, sharing_key) -> ProxiedPathDB | None` (`database.py:547`) +- Pydantic `View`, `ViewLayer`, `ViewResponse` (`model.py`), all `from_attributes=True`. + +--- + +### Task 1: Request models + PR-1 carry-item fixes + +**Files:** +- Modify: `fileglancer/model.py` (add request models after `ViewResponse`; tighten `View.sharing_mode`) +- Modify: `fileglancer/database.py` (reword `get_view_by_short_key` docstring) +- Test: `tests/test_database.py` + +**Interfaces:** +- Consumes: `BaseModel`, `Field`, `ConfigDict`, `Literal`, `Optional`, `List`, `Dict` (all already imported in `model.py` — `Literal` is used in ~5 existing places). +- Produces: + - `class ViewLayerInput(BaseModel)` — `sharing_key: Optional[str] = None`, `layer_index: int`, `channel: Optional[str] = None`, `opts: Optional[Dict] = None`. + - `class ViewCreateRequest(BaseModel)` — `name: str`, `ng_state: Dict`, `sharing_mode: Literal['private', 'read'] = 'read'`, `layers: List[ViewLayerInput] = []`. + - `class ViewUpdateRequest(BaseModel)` — `name: Optional[str] = None`, `ng_state: Optional[Dict] = None`. + - `View.sharing_mode` retyped from `str` to `Literal['private', 'read']`. + +- [ ] **Step 1: Write the failing test** + +Add to `tests/test_database.py`: + +```python +def test_view_request_models_validate_sharing_mode(): + from pydantic import ValidationError + from fileglancer.model import ViewCreateRequest, ViewLayerInput + + req = ViewCreateRequest( + name="demo", + ng_state={"layers": []}, + sharing_mode="read", + layers=[ViewLayerInput(sharing_key="abc", layer_index=0)], + ) + assert req.sharing_mode == "read" + assert req.layers[0].sharing_key == "abc" + assert req.layers[0].channel is None + + # default sharing_mode + assert ViewCreateRequest(name="d", ng_state={}).sharing_mode == "read" + + # invalid sharing_mode is rejected at the boundary + import pytest + with pytest.raises(ValidationError): + ViewCreateRequest(name="d", ng_state={}, sharing_mode="public") +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pixi run -e test test-backend -- tests/test_database.py::test_view_request_models_validate_sharing_mode` +Expected: FAIL with `ImportError: cannot import name 'ViewCreateRequest'`. + +- [ ] **Step 3: Add the request models and tighten `View.sharing_mode`** + +In `fileglancer/model.py`, change the `sharing_mode` field on `View` from: + +```python + sharing_mode: str = Field(description="'private' or 'read'") +``` +to: +```python + sharing_mode: Literal['private', 'read'] = Field(description="'private' or 'read'") +``` + +Then add after `ViewResponse`: + +```python +class ViewLayerInput(BaseModel): + """One layer in a create-View request. `sharing_key` names the Data Link + that backs this layer (resolved to an internal id server-side); null for a + layer with no Fileglancer Data Link (e.g. an external URL layer).""" + sharing_key: Optional[str] = Field(default=None, description="Data Link sharing key backing this layer") + layer_index: int = Field(description="Position of this layer within the View") + channel: Optional[str] = Field(default=None, description="Channel identifier, if this layer is one channel") + opts: Optional[Dict] = Field(default=None, description="Per-layer options") + + +class ViewCreateRequest(BaseModel): + """Request body for creating a View. The client builds `ng_state`.""" + name: str = Field(description="Display name of the View") + ng_state: Dict = Field(description="The Neuroglancer state JSON") + sharing_mode: Literal['private', 'read'] = Field(default='read', description="'private' or 'read'") + layers: List[ViewLayerInput] = Field(default_factory=list, description="Layers backing this View") + + +class ViewUpdateRequest(BaseModel): + """Request body for an owner update (rename / restate).""" + name: Optional[str] = Field(default=None, description="New display name") + ng_state: Optional[Dict] = Field(default=None, description="Replacement Neuroglancer state JSON") +``` + +- [ ] **Step 4: Reword the `get_view_by_short_key` docstring (PR-1 carry-item)** + +In `fileglancer/database.py`, change: + +```python +def get_view_by_short_key(session: Session, short_key: str) -> Optional[ViewDB]: + """Get an owned View by its short key.""" + return session.query(ViewDB).filter_by(short_key=short_key).first() +``` +to: +```python +def get_view_by_short_key(session: Session, short_key: str) -> Optional[ViewDB]: + """Get a View by its short key. No owner filter — callers scope ownership.""" + return session.query(ViewDB).filter_by(short_key=short_key).first() +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `pixi run -e test test-backend -- tests/test_database.py::test_view_request_models_validate_sharing_mode` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add fileglancer/model.py fileglancer/database.py tests/test_database.py +git commit -m "feat(views): add View request models; tighten sharing_mode to Literal" +``` + +--- + +### Task 2: DB helpers for the Data-Link delete modes + +**Files:** +- Modify: `fileglancer/database.py` (add after `get_views_for_data_link`) +- Test: `tests/test_database.py` + +**Interfaces:** +- Consumes: `ViewDB`, `ViewLayerDB`, `create_view`, `get_views_for_data_link`, `get_view_by_short_key` (all PR 1). +- Produces: + - `mark_view_layers_broken(session, data_link_id: int) -> int` — for every `ViewLayerDB` with this `data_link_id`, set `data_link_id = None` and `broken = True`; commit; return rows updated. + - `delete_views_for_data_link(session, data_link_id: int) -> int` — delete every View that has a layer backed by this Data Link (cascades to layers); commit; return Views deleted. + +- [ ] **Step 1: Write the failing tests** + +Add to `tests/test_database.py`: + +```python +def test_mark_view_layers_broken(db_session): + layers = [ + {"data_link_id": 7, "layer_index": 0, "channel": None, "opts": None}, + {"data_link_id": 7, "layer_index": 1, "channel": "Ch1", "opts": None}, + {"data_link_id": 8, "layer_index": 2, "channel": None, "opts": None}, + ] + v = create_view(db_session, "u", "mixed", {"layers": []}, layers, "read") + + updated = mark_view_layers_broken(db_session, 7) + assert updated == 2 + + db_session.refresh(v) + by_index = {l.layer_index: l for l in v.layers} + assert by_index[0].data_link_id is None and by_index[0].broken is True + assert by_index[1].data_link_id is None and by_index[1].broken is True + # the data_link_id=8 layer is untouched + assert by_index[2].data_link_id == 8 and by_index[2].broken is False + # the View itself still exists + assert get_view_by_short_key(db_session, v.short_key) is not None + + +def test_delete_views_for_data_link(db_session): + linked_a = create_view(db_session, "u", "a", {"layers": []}, + [{"data_link_id": 5, "layer_index": 0, "channel": None, "opts": None}], "read") + linked_b = create_view(db_session, "u", "b", {"layers": []}, + [{"data_link_id": 5, "layer_index": 0, "channel": None, "opts": None}], "read") + other = create_view(db_session, "u", "c", {"layers": []}, + [{"data_link_id": 6, "layer_index": 0, "channel": None, "opts": None}], "read") + + deleted = delete_views_for_data_link(db_session, 5) + assert deleted == 2 + assert get_view_by_short_key(db_session, linked_a.short_key) is None + assert get_view_by_short_key(db_session, linked_b.short_key) is None + assert get_view_by_short_key(db_session, other.short_key) is not None + # the deleted views' layers are gone; the surviving view's layer remains + assert db_session.query(ViewLayerDB).filter_by(data_link_id=5).count() == 0 + assert db_session.query(ViewLayerDB).filter_by(data_link_id=6).count() == 1 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pixi run -e test test-backend -- tests/test_database.py -k "mark_view_layers_broken or delete_views_for_data_link"` +Expected: FAIL with `NameError` on the new helpers. + +- [ ] **Step 3: Implement the helpers** + +Add after `get_views_for_data_link` in `database.py`: + +```python +def mark_view_layers_broken(session: Session, data_link_id: int) -> int: + """Detach a Data Link from all View layers that use it: null the + data_link_id and set broken=True. Returns the number of layers updated. + Leaves the Views themselves intact (degraded).""" + layers = session.query(ViewLayerDB).filter_by(data_link_id=data_link_id).all() + for layer in layers: + layer.data_link_id = None + layer.broken = True + session.commit() + return len(layers) + + +def delete_views_for_data_link(session: Session, data_link_id: int) -> int: + """Delete every View that has at least one layer backed by this Data Link + (cascades to its layers). Returns the number of Views deleted.""" + views = get_views_for_data_link(session, data_link_id) + for view in views: + session.delete(view) + session.commit() + return len(views) +``` + +Note: `delete_views_for_data_link` uses `session.delete(view)` (not a bulk delete) so PR 1's `cascade='all, delete-orphan'` on `ViewDB.layers` removes the join rows. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pixi run -e test test-backend -- tests/test_database.py -k "mark_view_layers_broken or delete_views_for_data_link"` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add fileglancer/database.py tests/test_database.py +git commit -m "feat(views): add mark-broken and cascade-delete helpers for data-link deletion" +``` + +--- + +### Task 3: Views owner CRUD routes + +**Files:** +- Modify: `fileglancer/server.py` (add 5 routes after the `GET /api/neuroglancer/nglinks` route, ~`server.py:1309`) +- Test: `tests/test_endpoints.py` + +**Interfaces:** +- Consumes: `get_current_user` (`server.py:173`), `db.get_db_session`, the PR-1 DB helpers, `db.get_proxied_path_by_sharing_key`, and Task 1's `ViewCreateRequest`/`ViewUpdateRequest`. Response models `View`, `ViewResponse` (`model.py`). +- Produces these routes (all `/api/neuroglancer/views`): + - `POST` → `View` (201-style 200), resolves each layer's `sharing_key` → `proxied_paths.id`. + - `GET` → `ViewResponse` (current user's Views). + - `GET /{short_key}` → `View` (owner-scoped; 404 if not owned). + - `PUT /{short_key}` → `View` (owner rename/restate; 404 if not owned). + - `DELETE /{short_key}` → `{"message": ...}` (404 if not owned). + +- [ ] **Step 1: Write the failing tests** + +Add to `tests/test_endpoints.py` (uses the existing `test_client` fixture, authenticated as `TEST_USERNAME`): + +```python +def test_views_crud(test_client): + # create + resp = test_client.post("/api/neuroglancer/views", json={ + "name": "seed6 overlay", + "ng_state": {"layers": [{"name": "img"}]}, + "sharing_mode": "read", + "layers": [{"layer_index": 0, "channel": "Ch0"}], + }) + assert resp.status_code == 200, resp.text + created = resp.json() + assert created["name"] == "seed6 overlay" + assert created["sharing_mode"] == "read" + assert created["owner"] == "testuser" + assert created["read_key"] and created["short_key"] + assert "edit_key" not in created # never exposed + assert len(created["layers"]) == 1 and created["layers"][0]["channel"] == "Ch0" + short_key = created["short_key"] + + # list + resp = test_client.get("/api/neuroglancer/views") + assert resp.status_code == 200 + names = [v["name"] for v in resp.json()["views"]] + assert "seed6 overlay" in names + + # get one + resp = test_client.get(f"/api/neuroglancer/views/{short_key}") + assert resp.status_code == 200 + assert resp.json()["short_key"] == short_key + + # update (rename) + resp = test_client.put(f"/api/neuroglancer/views/{short_key}", json={"name": "renamed"}) + assert resp.status_code == 200 + assert resp.json()["name"] == "renamed" + + # delete + resp = test_client.delete(f"/api/neuroglancer/views/{short_key}") + assert resp.status_code == 200 + assert test_client.get(f"/api/neuroglancer/views/{short_key}").status_code == 404 + + +def test_view_get_and_update_missing_returns_404(test_client): + assert test_client.get("/api/neuroglancer/views/nope").status_code == 404 + assert test_client.put("/api/neuroglancer/views/nope", json={"name": "x"}).status_code == 404 + assert test_client.delete("/api/neuroglancer/views/nope").status_code == 404 + + +def test_view_create_with_unknown_sharing_key_400(test_client): + resp = test_client.post("/api/neuroglancer/views", json={ + "name": "bad", + "ng_state": {}, + "layers": [{"layer_index": 0, "sharing_key": "does-not-exist"}], + }) + assert resp.status_code == 400 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pixi run -e test test-backend -- tests/test_endpoints.py -k "views_crud or view_get_and_update_missing or view_create_with_unknown_sharing_key"` +Expected: FAIL — 404 (route not registered) on the POST, so assertions fail. + +- [ ] **Step 3: Implement the routes** + +In `fileglancer/server.py`, immediately after the `GET /api/neuroglancer/nglinks` handler (~`:1309`), add. Ensure `View`, `ViewResponse`, `ViewCreateRequest`, `ViewUpdateRequest` are imported from `fileglancer.model` at the top of `server.py` (add to the existing `from fileglancer.model import (...)` block). + +```python + @app.post("/api/neuroglancer/views", response_model=View, + description="Create a Neuroglancer View from a client-built state and layer list") + async def create_view_endpoint(payload: ViewCreateRequest, + username: str = Depends(get_current_user)): + with db.get_db_session(settings.db_url) as session: + layers = [] + for layer in payload.layers: + data_link_id = None + if layer.sharing_key: + pp = db.get_proxied_path_by_sharing_key(session, layer.sharing_key) + if not pp: + raise HTTPException(status_code=400, + detail=f"Unknown data link sharing key: {layer.sharing_key}") + data_link_id = pp.id + layers.append({ + "data_link_id": data_link_id, + "layer_index": layer.layer_index, + "channel": layer.channel, + "opts": layer.opts, + }) + view = db.create_view(session, username, payload.name, payload.ng_state, + layers, payload.sharing_mode) + return View.model_validate(view) + + @app.get("/api/neuroglancer/views", response_model=ViewResponse, + description="List the current user's Neuroglancer Views") + async def list_views_endpoint(username: str = Depends(get_current_user)): + with db.get_db_session(settings.db_url) as session: + views = db.get_views(session, username) + return ViewResponse(views=[View.model_validate(v) for v in views]) + + @app.get("/api/neuroglancer/views/{short_key}", response_model=View, + description="Get one of the current user's Neuroglancer Views") + async def get_view_endpoint(short_key: str = Path(..., description="The View's short key"), + username: str = Depends(get_current_user)): + with db.get_db_session(settings.db_url) as session: + view = db.get_view_by_short_key(session, short_key) + if not view or view.owner != username: + raise HTTPException(status_code=404, detail="View not found") + return View.model_validate(view) + + @app.put("/api/neuroglancer/views/{short_key}", response_model=View, + description="Update (rename / restate) one of the current user's Views") + async def update_view_endpoint(payload: ViewUpdateRequest, + short_key: str = Path(..., description="The View's short key"), + username: str = Depends(get_current_user)): + with db.get_db_session(settings.db_url) as session: + view = db.update_view(session, username, short_key, + name=payload.name, ng_state=payload.ng_state) + if not view: + raise HTTPException(status_code=404, detail="View not found") + return View.model_validate(view) + + @app.delete("/api/neuroglancer/views/{short_key}", + description="Delete one of the current user's Views") + async def delete_view_endpoint(short_key: str = Path(..., description="The View's short key"), + username: str = Depends(get_current_user)): + with db.get_db_session(settings.db_url) as session: + deleted = db.delete_view(session, username, short_key) + if deleted == 0: + raise HTTPException(status_code=404, detail="View not found") + return {"message": f"View {short_key} deleted"} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pixi run -e test test-backend -- tests/test_endpoints.py -k "views_crud or view_get_and_update_missing or view_create_with_unknown_sharing_key"` +Expected: PASS (3 tests). + +- [ ] **Step 5: Run the full suite and commit** + +Run: `pixi run -e test test-backend` +Expected: PASS (2 pre-existing Alembic warnings only). + +```bash +git add fileglancer/server.py tests/test_endpoints.py +git commit -m "feat(views): add owner CRUD routes for /api/neuroglancer/views" +``` + +--- + +### Task 4: Public read-link state endpoint `GET /ngview/{key}` + +**Files:** +- Modify: `fileglancer/server.py` (add near the `/ng/...` routes, ~`server.py:1278`) +- Test: `tests/test_endpoints.py` + +**Interfaces:** +- Consumes: `db.get_view_by_read_key` (PR 1), `JSONResponse` (already imported in `server.py`). +- Produces: `GET /ngview/{key}` — public (no auth), `include_in_schema=False`, `name="get_view_state"`; resolves by **read_key**, serves the View's `ng_state` JSON with `Cache-Control: no-store`; 404 if no such read_key. This is what the embedded NG iframe (PR 6) fetches. + +- [ ] **Step 1: Write the failing test** + +Add to `tests/test_endpoints.py`: + +```python +def test_ngview_serves_state_by_read_key(test_client): + resp = test_client.post("/api/neuroglancer/views", json={ + "name": "readable", + "ng_state": {"layers": [{"name": "img"}], "position": [1, 2, 3]}, + "sharing_mode": "read", + }) + assert resp.status_code == 200, resp.text + created = resp.json() + read_key = created["read_key"] + + # public read endpoint serves the stored ng_state verbatim + resp = test_client.get(f"/ngview/{read_key}") + assert resp.status_code == 200 + assert resp.json() == {"layers": [{"name": "img"}], "position": [1, 2, 3]} + assert resp.headers.get("cache-control") == "no-store" + + # the short_key is NOT a valid read key + assert test_client.get(f"/ngview/{created['short_key']}").status_code == 404 + # unknown key 404s + assert test_client.get("/ngview/nope").status_code == 404 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pixi run -e test test-backend -- tests/test_endpoints.py::test_ngview_serves_state_by_read_key` +Expected: FAIL — `/ngview/{read_key}` falls through to the SPA catch-all (returns HTML / non-JSON), so the assertions fail. + +- [ ] **Step 3: Implement the route** + +In `fileglancer/server.py`, after the `GET /ng/{short_key}/{short_name}` handler (~`:1278`), add: + +```python + @app.get("/ngview/{key}", name="get_view_state", include_in_schema=False) + async def get_view_state(key: str = Path(..., description="A View's read key")): + with db.get_db_session(settings.db_url) as session: + view = db.get_view_by_read_key(session, key) + if not view: + raise HTTPException(status_code=404, detail="View not found") + return JSONResponse(content=view.ng_state, headers={"Cache-Control": "no-store"}) +``` + +(No change to the SPA catch-all is needed — an explicit route wins over `/{full_path:path}`. Note the catch-all does not exclude `/ngview/*`, so an *unmatched* `/ngview/...` would serve the SPA; the 404 above only fires for a registered path with a bad key, which is correct.) + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pixi run -e test test-backend -- tests/test_endpoints.py::test_ngview_serves_state_by_read_key` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add fileglancer/server.py tests/test_endpoints.py +git commit -m "feat(views): add public GET /ngview/{key} read-link state endpoint" +``` + +--- + +### Task 5: Dependent-Views endpoint + Data-Link delete modes + +**Files:** +- Modify: `fileglancer/server.py` (add the dependent-views route with the proxied-path routes ~`:1247`; modify the existing `DELETE /api/proxied-path/{sharing_key}` handler ~`:1247-1254`) +- Test: `tests/test_endpoints.py` + +**Interfaces:** +- Consumes: `db.get_proxied_path_by_sharing_key`, `db.get_views_for_data_link`, `db.mark_view_layers_broken` + `db.delete_views_for_data_link` (Task 2), `db.delete_proxied_path`, `ViewResponse`/`View`. +- Produces: + - `GET /api/proxied-path/{sharing_key}/views` → `ViewResponse` (owner-scoped; 404 if the proxied path isn't the user's). Powers the delete dialog and the Properties "Appears in N Views". + - Modified `DELETE /api/proxied-path/{sharing_key}` accepting `mode: Optional[str] = Query(None)`: + - No dependent Views → delete as before. + - Dependent Views and `mode` not in (`mark_broken`, `cascade`) → **409** with `detail = {"message": ..., "dependent_views": [{"short_key", "name"}, ...]}`. + - `mode == "mark_broken"` → `mark_view_layers_broken`, then delete the link. + - `mode == "cascade"` → `delete_views_for_data_link`, then delete the link. + +- [ ] **Step 1: Write the failing tests** + +Add to `tests/test_endpoints.py`. (Helper: create a real proxied path the way `test_create_proxied_path` does — POST `/api/proxied-path?fsp_name=tempdir&path=...` after making the dir — then a View whose layer references its `sharing_key`.) + +```python +import os + +def _make_proxied_path(test_client, test_app_temp_dir, subdir): + # The test FSP "tempdir" is mounted at test_app_temp_dir; create a real dir. + os.makedirs(os.path.join(test_app_temp_dir, subdir), exist_ok=True) + resp = test_client.post(f"/api/proxied-path?fsp_name=tempdir&path={subdir}") + assert resp.status_code == 200, resp.text + return resp.json()["sharing_key"] + + +def test_dependent_views_endpoint(test_client, temp_dir): + sk = _make_proxied_path(test_client, temp_dir, "dl1") + test_client.post("/api/neuroglancer/views", json={ + "name": "uses dl1", "ng_state": {}, "layers": [{"layer_index": 0, "sharing_key": sk}]}) + + resp = test_client.get(f"/api/proxied-path/{sk}/views") + assert resp.status_code == 200 + assert [v["name"] for v in resp.json()["views"]] == ["uses dl1"] + + +def test_delete_data_link_blocks_then_marks_broken(test_client, temp_dir): + sk = _make_proxied_path(test_client, temp_dir, "dl2") + created = test_client.post("/api/neuroglancer/views", json={ + "name": "v", "ng_state": {}, "layers": [{"layer_index": 0, "sharing_key": sk}]}).json() + + # no mode + dependents -> 409 listing the dependent view + resp = test_client.delete(f"/api/proxied-path/{sk}") + assert resp.status_code == 409 + detail = resp.json()["detail"] + assert detail["dependent_views"][0]["short_key"] == created["short_key"] + + # mark_broken -> link gone, view survives + resp = test_client.delete(f"/api/proxied-path/{sk}?mode=mark_broken") + assert resp.status_code == 200 + assert test_client.get(f"/api/proxied-path/{sk}").status_code == 404 + view = test_client.get(f"/api/neuroglancer/views/{created['short_key']}").json() + assert view["layers"][0]["broken"] is True + assert view["layers"][0]["data_link_id"] is None + + +def test_delete_data_link_cascade(test_client, temp_dir): + sk = _make_proxied_path(test_client, temp_dir, "dl3") + created = test_client.post("/api/neuroglancer/views", json={ + "name": "v", "ng_state": {}, "layers": [{"layer_index": 0, "sharing_key": sk}]}).json() + + resp = test_client.delete(f"/api/proxied-path/{sk}?mode=cascade") + assert resp.status_code == 200 + assert test_client.get(f"/api/neuroglancer/views/{created['short_key']}").status_code == 404 + + +def test_delete_data_link_no_dependents_still_works(test_client, temp_dir): + sk = _make_proxied_path(test_client, temp_dir, "dl4") + resp = test_client.delete(f"/api/proxied-path/{sk}") + assert resp.status_code == 200 + assert test_client.get(f"/api/proxied-path/{sk}").status_code == 404 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pixi run -e test test-backend -- tests/test_endpoints.py -k "dependent_views_endpoint or delete_data_link"` +Expected: FAIL — `/{sharing_key}/views` route missing (SPA/404), and the current DELETE returns 200 (no guard) so the 409 assertion fails. + +- [ ] **Step 3: Add the dependent-views route** + +In `fileglancer/server.py`, next to the proxied-path routes (after `GET /api/proxied-path/{sharing_key}`, ~`:1215`), add: + +```python + @app.get("/api/proxied-path/{sharing_key}/views", response_model=ViewResponse, + description="List Neuroglancer Views that depend on this Data Link") + async def get_views_for_proxied_path(sharing_key: str = Path(..., description="The sharing key of the proxied path"), + username: str = Depends(get_current_user)): + with db.get_db_session(settings.db_url) as session: + pp = db.get_proxied_path_by_sharing_key(session, sharing_key) + if not pp or pp.username != username: + raise HTTPException(status_code=404, detail="Proxied path not found") + views = db.get_views_for_data_link(session, pp.id) + return ViewResponse(views=[View.model_validate(v) for v in views]) +``` + +- [ ] **Step 4: Modify the DELETE route to add the guard + modes** + +Replace the existing `DELETE /api/proxied-path/{sharing_key}` handler (`server.py:~1247-1254`) with: + +```python + @app.delete("/api/proxied-path/{sharing_key}", description="Delete a proxied path by sharing key") + async def delete_proxied_path(sharing_key: str = Path(..., description="The sharing key of the proxied path"), + mode: Optional[str] = Query(None, description="How to resolve dependent Views: 'mark_broken' or 'cascade'"), + username: str = Depends(get_current_user)): + with db.get_db_session(settings.db_url) as session: + pp = db.get_proxied_path_by_sharing_key(session, sharing_key) + if not pp or pp.username != username: + raise HTTPException(status_code=404, detail="Proxied path not found") + dependents = db.get_views_for_data_link(session, pp.id) + if dependents and mode not in ("mark_broken", "cascade"): + raise HTTPException(status_code=409, detail={ + "message": "This data link backs one or more Neuroglancer Views. Choose how to proceed.", + "dependent_views": [{"short_key": v.short_key, "name": v.name} for v in dependents], + }) + if dependents and mode == "mark_broken": + db.mark_view_layers_broken(session, pp.id) + elif dependents and mode == "cascade": + db.delete_views_for_data_link(session, pp.id) + db.delete_proxied_path(session, username, sharing_key) + return {"message": f"Proxied path {sharing_key} deleted for user {username}"} +``` + +Note: the 404 now comes from the pre-fetch (`get_proxied_path_by_sharing_key` + owner check), which is more correct than the previous `deleted == 0` check (the `delete_proxied_path` helper returns `None`, so that branch never fired). `Query` is already imported in `server.py`. + +- [ ] **Step 5: Run the focused tests, then the full suite** + +Run: `pixi run -e test test-backend -- tests/test_endpoints.py -k "dependent_views_endpoint or delete_data_link"` +Expected: PASS (4 tests). + +Run: `pixi run -e test test-backend` +Expected: PASS (2 pre-existing Alembic warnings only). + +- [ ] **Step 6: Commit** + +```bash +git add fileglancer/server.py tests/test_endpoints.py +git commit -m "feat(views): add dependent-views endpoint and data-link delete modes (mark_broken/cascade)" +``` + +--- + +## Self-Review + +**Spec coverage (spec §5):** Views CRUD ✓ (Task 3); `GET /ngview/{key}` read-key state serving ✓ (Task 4); `GET /api/proxied-path/{sharing_key}/views` dependent-views ✓ (Task 5); Data-Link delete modes `mark_broken`/`cascade` ✓ (Tasks 2+5); client builds `ng_state`, backend stores (no server-side state gen) ✓ (Task 3 stores `payload.ng_state` verbatim); read-key never writes ✓ (Task 4 is read-only). PR-1 carry-items: docstring reword ✓ (Task 1), `sharing_mode` → `Literal` ✓ (Task 1), data-link-delete `view_layers` handling ✓ (Tasks 2+5). No migration ✓. + +**Placeholder scan:** none — every step has concrete code and exact test assertions. + +**Type consistency:** the layer dict shape `{data_link_id, layer_index, channel, opts}` passed to `create_view` in Task 3 matches PR 1's `create_view` contract. `ViewCreateRequest`/`ViewUpdateRequest`/`ViewLayerInput` (Task 1) are consumed unchanged in Tasks 3. `mark_view_layers_broken`/`delete_views_for_data_link` return `int` (Task 2) and are called for effect in Task 5. Response model `View` excludes `edit_key` (from PR 1) — asserted in Task 3's test. + +**Ambiguity check:** the POST resolves each layer's `sharing_key` to `proxied_paths.id` (clients hold sharing keys, not internal ids); a `null` sharing_key yields a layer with `data_link_id=None` (external-URL layer). The 409 delete-guard returns the dependent list as structured `detail` so the frontend (PR 5) can render the choice dialog. + +## Out of scope for this PR (next plans) + +- PR 3 `ngviews-03-multiselect`: file-browser multi-select. +- PR 4 `ngviews-04-views-page`: the Views page + `ViewsContext` + `CartContext` + nav rename. +- PR 5 `ngviews-05-browser-entry`: browser entry points, the Data-Link delete **dialog** (consumes this PR's 409 payload), "Appears in N Views" (consumes this PR's dependent-views endpoint). +- PR 6 `ngviews-06-embedded-readonly`: the read-only embedded viewer (consumes `GET /ngview/{key}`). diff --git a/docs/superpowers/specs/2026-08-07-neuroglancer-views-design.md b/docs/superpowers/specs/2026-08-07-neuroglancer-views-design.md index 20351a64..6763cfae 100644 --- a/docs/superpowers/specs/2026-08-07-neuroglancer-views-design.md +++ b/docs/superpowers/specs/2026-08-07-neuroglancer-views-design.md @@ -147,17 +147,29 @@ the backend only stores it — no server-side NG state generation. | `PUT /api/neuroglancer/views/{short_key}` | Owner rename / metadata update. | | `DELETE /api/neuroglancer/views/{short_key}` | Delete an owned View. | | `GET /ngview/{key}` | Resolve a View by **read_key**; serve `ng_state` JSON for the NG iframe (mirrors `/ng/{short_key}`, `Cache-Control: no-store`). | -| `GET /api/proxied-path/{sharing_key}/views` | Dependent Views for a Data Link (powers the delete dialog + "Appears in N Views"). | - -Data Link deletion (`DELETE /api/proxied-path/{sharing_key}`) gains a mode -parameter — `mark_broken` or `cascade`: - -- `mark_broken`: null `data_link_id` and set `broken = true` on each dependent - `view_layer`, then delete the link. Views survive, degraded. -- `cascade`: delete the dependent Views (and their layers), then delete the link. - -The frontend queries dependents first and drives the choice through one dialog -(see §7). +| `GET /api/proxied-path/{sharing_key}/views` | The **caller's own** dependent Views for a Data Link (powers the delete dialog + "Appears in N Views"). Owner-scoped — never lists other users' Views. | + +Data Link deletion (`DELETE /api/proxied-path/{sharing_key}`) takes a +`confirm` boolean and **never cascade-deletes Views** (decided against +cross-user data loss): + +- If the caller has **their own** dependent Views and `confirm` is false → + **409** with the list of *the caller's own* Views that will break (no other + user's Views are ever disclosed). +- On `confirm=true` (or when the caller has no own dependents) → null + `data_link_id` + set `broken = true` on **all** layers on that link (any + owner, for referential integrity — other users' Views degrade gracefully and + surface as broken when opened), then delete the link. + +`sharing_mode` is **not enforced** in this PR — every View is readable by its +`read_key` (bearer token); `'private'` is a stored label only. A future PR adds +`'public'` (unauthenticated / listed) viewing and real per-mode enforcement. +Other users learn a shared Data Link broke lazily (the proxied path 404s / the +layer's `broken` flag); a **follow-up** may surface a broken indicator in their +Data Links table. + +The frontend queries the caller's own dependents first and drives the confirm +through one dialog (see §7). Read-key sessions never write to the database. Owner CRUD above is authenticated as the owner and is not an "edit link" — it is basic management, @@ -222,8 +234,10 @@ and stays in read-only scope. their local tweaks — client-side only, never written to the DB. - **Selection granularity**: dataset + channel (two levels). Channels load lazily on expand. Maps onto the existing channel-per-layer code. -- **Data Link deletion**: one dialog — list dependent Views, user picks - {mark broken | delete those Views}, or Cancel. +- **Data Link deletion**: one dialog — list the caller's **own** dependent + Views that will break, user picks {Confirm (mark my Views broken) | Cancel}. + No cascade-delete; no cross-user disclosure (revised from an earlier + mark-broken/delete/cascade design — see §5). - **Scratch View lifetime**: client-only until saved. ## 8. The `gh stack` — six bottom-up PRs @@ -249,9 +263,9 @@ persist-on-change, the amber edit banner, the In-View Data Panel, and ## 9. Testing - **Backend** (`pixi run -e test test-backend`): model + migration round-trip; - Views CRUD; `GET /ngview/{key}` read-key resolution and 404s; dependent-views - query; both Data Link delete modes (mark-broken nulls the link + flags layers; - cascade removes Views). + Views CRUD; `GET /ngview/{key}` read-key resolution and 404s; owner-scoped + dependent-views query; Data Link delete confirm-guard (409 lists only the + caller's own Views; confirm marks all layers on the link broken + deletes it). - **Frontend unit** (`pixi run test-frontend`): `viewQueries` / `CartContext` reducers; Export menu URL construction; multi-select selection logic; consent-gate branching on `areDataLinksAutomatic`. diff --git a/fileglancer/database.py b/fileglancer/database.py index a07f0cfe..8cfd9724 100644 --- a/fileglancer/database.py +++ b/fileglancer/database.py @@ -992,7 +992,7 @@ def create_view( def get_view_by_short_key(session: Session, short_key: str) -> Optional[ViewDB]: - """Get an owned View by its short key.""" + """Get a View by its short key. No owner filter — callers scope ownership.""" return session.query(ViewDB).filter_by(short_key=short_key).first() @@ -1041,15 +1041,30 @@ def delete_view(session: Session, username: str, short_key: str) -> int: return 1 -def get_views_for_data_link(session: Session, data_link_id: int) -> List[ViewDB]: - """Distinct Views that have at least one layer backed by this Data Link.""" - return ( +def get_views_for_data_link(session: Session, data_link_id: int, owner: Optional[str] = None) -> List[ViewDB]: + """Distinct Views that have at least one layer backed by this Data Link. + If `owner` is given, restrict to Views owned by that user (used to avoid + disclosing other users' Views when guarding a Data Link deletion).""" + query = ( session.query(ViewDB) .join(ViewLayerDB, ViewLayerDB.view_id == ViewDB.id) .filter(ViewLayerDB.data_link_id == data_link_id) - .distinct() - .all() ) + if owner is not None: + query = query.filter(ViewDB.owner == owner) + return query.distinct().all() + + +def mark_view_layers_broken(session: Session, data_link_id: int) -> int: + """Detach a Data Link from all View layers that use it: null the + data_link_id and set broken=True. Returns the number of layers updated. + Leaves the Views themselves intact (degraded).""" + layers = session.query(ViewLayerDB).filter_by(data_link_id=data_link_id).all() + for layer in layers: + layer.data_link_id = None + layer.broken = True + session.commit() + return len(layers) def get_tickets(session: Session, username: str, fsp_name: str = None, path: str = None) -> List[TicketDB]: diff --git a/fileglancer/model.py b/fileglancer/model.py index 28b5bf7a..0ac64c85 100644 --- a/fileglancer/model.py +++ b/fileglancer/model.py @@ -188,7 +188,7 @@ class View(BaseModel): read_key: str = Field(description="Key that opens this View read-only") name: str = Field(description="Display name of the View") ng_state: Dict = Field(description="The Neuroglancer state JSON") - sharing_mode: str = Field(description="'private' or 'read'") + sharing_mode: Literal['private', 'read'] = Field(description="'private' or 'read'") owner: str = Field(description="Username of the View owner") created_at: datetime = Field(description="When this View was created") updated_at: datetime = Field(description="When this View was last updated") @@ -199,6 +199,30 @@ class ViewResponse(BaseModel): views: List[View] = Field(description="A list of Neuroglancer Views") +class ViewLayerInput(BaseModel): + """One layer in a create-View request. `sharing_key` names the Data Link + that backs this layer (resolved to an internal id server-side); null for a + layer with no Fileglancer Data Link (e.g. an external URL layer).""" + sharing_key: Optional[str] = Field(default=None, description="Data Link sharing key backing this layer") + layer_index: int = Field(description="Position of this layer within the View") + channel: Optional[str] = Field(default=None, description="Channel identifier, if this layer is one channel") + opts: Optional[Dict] = Field(default=None, description="Per-layer options") + + +class ViewCreateRequest(BaseModel): + """Request body for creating a View. The client builds `ng_state`.""" + name: str = Field(description="Display name of the View") + ng_state: Dict = Field(description="The Neuroglancer state JSON") + sharing_mode: Literal['private', 'read'] = Field(default='read', description="'private' or 'read'") + layers: List[ViewLayerInput] = Field(default_factory=list, description="Layers backing this View") + + +class ViewUpdateRequest(BaseModel): + """Request body for an owner update (rename / restate).""" + name: Optional[str] = Field(default=None, description="New display name") + ng_state: Optional[Dict] = Field(default=None, description="Replacement Neuroglancer state JSON") + + class ExternalBucket(BaseModel): """An external bucket for S3-compatible storage""" id: int = Field( diff --git a/fileglancer/server.py b/fileglancer/server.py index daf66ab1..b54db625 100644 --- a/fileglancer/server.py +++ b/fileglancer/server.py @@ -1215,6 +1215,18 @@ async def get_proxied_path(sharing_key: str = Path(..., description="The sharing return _convert_proxied_path(path, settings.external_proxy_url) + @app.get("/api/proxied-path/{sharing_key}/views", response_model=ViewResponse, + description="List Neuroglancer Views that depend on this Data Link") + async def get_views_for_proxied_path(sharing_key: str = Path(..., description="The sharing key of the proxied path"), + username: str = Depends(get_current_user)): + with db.get_db_session(settings.db_url) as session: + pp = db.get_proxied_path_by_sharing_key(session, sharing_key) + if not pp or pp.username != username: + raise HTTPException(status_code=404, detail="Proxied path not found") + views = db.get_views_for_data_link(session, pp.id, owner=username) + return ViewResponse(views=[View.model_validate(v) for v in views]) + + @app.put("/api/proxied-path/{sharing_key}", description="Update a proxied path by sharing key") async def update_proxied_path(sharing_key: str = Path(..., description="The sharing key of the proxied path"), fsp_name: Optional[str] = Query(default=None, description="The name of the file share path that this proxied path is associated with"), @@ -1246,11 +1258,25 @@ async def update_proxied_path(sharing_key: str = Path(..., description="The shar @app.delete("/api/proxied-path/{sharing_key}", description="Delete a proxied path by sharing key") async def delete_proxied_path(sharing_key: str = Path(..., description="The sharing key of the proxied path"), + confirm: bool = Query(False, description="Confirm deletion even though it breaks the caller's own Views"), username: str = Depends(get_current_user)): with db.get_db_session(settings.db_url) as session: - deleted = db.delete_proxied_path(session, username, sharing_key) - if deleted == 0: + pp = db.get_proxied_path_by_sharing_key(session, sharing_key) + if not pp or pp.username != username: raise HTTPException(status_code=404, detail="Proxied path not found") + # Disclose only the caller's OWN dependent Views (never leak others'). + own_dependents = db.get_views_for_data_link(session, pp.id, owner=username) + if own_dependents and not confirm: + # ponytail: JSONResponse (not HTTPException) so the structured detail + # survives the app-wide handler at server.py:~614 that stringifies dict details. + return JSONResponse(status_code=409, content={"detail": { + "message": "This data link backs Neuroglancer Views you own; they will be marked broken.", + "dependent_views": [{"short_key": v.short_key, "name": v.name} for v in own_dependents], + }}) + # Mark ALL layers on this link broken (any owner) for referential integrity, + # so other users' Views degrade gracefully without disclosing them here. + db.mark_view_layers_broken(session, pp.id) + db.delete_proxied_path(session, username, sharing_key) return {"message": f"Proxied path {sharing_key} deleted for user {username}"} @@ -1278,6 +1304,18 @@ async def get_neuroglancer_state(short_key: str = Path(..., description="Short k return JSONResponse(content=entry.state, headers={"Cache-Control": "no-store"}) + # ponytail: sharing_mode is not enforced here — every View is readable by its + # read_key (bearer token). A future PR adds "public" (unauthenticated/listed) + # vs owner-only semantics; until then sharing_mode is a stored label only. + @app.get("/ngview/{key}", name="get_view_state", include_in_schema=False) + async def get_view_state(key: str = Path(..., description="A View's read key")): + with db.get_db_session(settings.db_url) as session: + view = db.get_view_by_read_key(session, key) + if not view: + raise HTTPException(status_code=404, detail="View not found") + return JSONResponse(content=view.ng_state, headers={"Cache-Control": "no-store"}) + + @app.get("/api/neuroglancer/nglinks", response_model=NeuroglancerShortLinkResponse, description="List stored Neuroglancer short links for the current user") async def get_neuroglancer_short_links(request: Request, @@ -1309,6 +1347,70 @@ async def get_neuroglancer_short_links(request: Request, return NeuroglancerShortLinkResponse(links=links) + @app.post("/api/neuroglancer/views", response_model=View, + description="Create a Neuroglancer View from a client-built state and layer list") + async def create_view_endpoint(payload: ViewCreateRequest, + username: str = Depends(get_current_user)): + with db.get_db_session(settings.db_url) as session: + layers = [] + for layer in payload.layers: + data_link_id = None + if layer.sharing_key: + pp = db.get_proxied_path_by_sharing_key(session, layer.sharing_key) + if not pp: + raise HTTPException(status_code=400, + detail=f"Unknown data link sharing key: {layer.sharing_key}") + data_link_id = pp.id + layers.append({ + "data_link_id": data_link_id, + "layer_index": layer.layer_index, + "channel": layer.channel, + "opts": layer.opts, + }) + view = db.create_view(session, username, payload.name, payload.ng_state, + layers, payload.sharing_mode) + return View.model_validate(view) + + @app.get("/api/neuroglancer/views", response_model=ViewResponse, + description="List the current user's Neuroglancer Views") + async def list_views_endpoint(username: str = Depends(get_current_user)): + with db.get_db_session(settings.db_url) as session: + views = db.get_views(session, username) + return ViewResponse(views=[View.model_validate(v) for v in views]) + + @app.get("/api/neuroglancer/views/{short_key}", response_model=View, + description="Get one of the current user's Neuroglancer Views") + async def get_view_endpoint(short_key: str = Path(..., description="The View's short key"), + username: str = Depends(get_current_user)): + with db.get_db_session(settings.db_url) as session: + view = db.get_view_by_short_key(session, short_key) + if not view or view.owner != username: + raise HTTPException(status_code=404, detail="View not found") + return View.model_validate(view) + + @app.put("/api/neuroglancer/views/{short_key}", response_model=View, + description="Update (rename / restate) one of the current user's Views") + async def update_view_endpoint(payload: ViewUpdateRequest, + short_key: str = Path(..., description="The View's short key"), + username: str = Depends(get_current_user)): + with db.get_db_session(settings.db_url) as session: + view = db.update_view(session, username, short_key, + name=payload.name, ng_state=payload.ng_state) + if not view: + raise HTTPException(status_code=404, detail="View not found") + return View.model_validate(view) + + @app.delete("/api/neuroglancer/views/{short_key}", + description="Delete one of the current user's Views") + async def delete_view_endpoint(short_key: str = Path(..., description="The View's short key"), + username: str = Depends(get_current_user)): + with db.get_db_session(settings.db_url) as session: + deleted = db.delete_view(session, username, short_key) + if deleted == 0: + raise HTTPException(status_code=404, detail="View not found") + return {"message": f"View {short_key} deleted"} + + @app.get("/files/{sharing_key}/{path:path}") async def target_dispatcher(request: Request, sharing_key: str, diff --git a/tests/test_database.py b/tests/test_database.py index f362c67f..5676cdb8 100644 --- a/tests/test_database.py +++ b/tests/test_database.py @@ -392,6 +392,40 @@ def test_get_views_for_data_link(db_session): assert get_views_for_data_link(db_session, 999) == [] +def test_mark_view_layers_broken(db_session): + layers = [ + {"data_link_id": 7, "layer_index": 0, "channel": None, "opts": None}, + {"data_link_id": 7, "layer_index": 1, "channel": "Ch1", "opts": None}, + {"data_link_id": 8, "layer_index": 2, "channel": None, "opts": None}, + ] + v = create_view(db_session, "u", "mixed", {"layers": []}, layers, "read") + + updated = mark_view_layers_broken(db_session, 7) + assert updated == 2 + + db_session.refresh(v) + by_index = {l.layer_index: l for l in v.layers} + assert by_index[0].data_link_id is None and by_index[0].broken is True + assert by_index[1].data_link_id is None and by_index[1].broken is True + # the data_link_id=8 layer is untouched + assert by_index[2].data_link_id == 8 and by_index[2].broken is False + # the View itself still exists + assert get_view_by_short_key(db_session, v.short_key) is not None + + +def test_get_views_for_data_link_owner_filter(db_session): + layer = [{"data_link_id": 11, "layer_index": 0, "channel": None, "opts": None}] + mine = create_view(db_session, "me", "mine", {"layers": []}, layer, "read") + create_view(db_session, "other", "theirs", {"layers": []}, layer, "read") + + # unfiltered: both owners' views + all_deps = get_views_for_data_link(db_session, 11) + assert {v.owner for v in all_deps} == {"me", "other"} + # owner-scoped: only mine + mine_only = get_views_for_data_link(db_session, 11, owner="me") + assert [v.short_key for v in mine_only] == [mine.short_key] + + def test_view_pydantic_from_orm(db_session): from fileglancer.model import View layers = [{"data_link_id": 7, "layer_index": 0, "channel": "Ch0", "opts": None}] @@ -808,3 +842,26 @@ def test_no_matching_candidate_returns_none(self): ) assert result is None + +def test_view_request_models_validate_sharing_mode(): + from pydantic import ValidationError + from fileglancer.model import ViewCreateRequest, ViewLayerInput + + req = ViewCreateRequest( + name="demo", + ng_state={"layers": []}, + sharing_mode="read", + layers=[ViewLayerInput(sharing_key="abc", layer_index=0)], + ) + assert req.sharing_mode == "read" + assert req.layers[0].sharing_key == "abc" + assert req.layers[0].channel is None + + # default sharing_mode + assert ViewCreateRequest(name="d", ng_state={}).sharing_mode == "read" + + # invalid sharing_mode is rejected at the boundary + import pytest + with pytest.raises(ValidationError): + ViewCreateRequest(name="d", ng_state={}, sharing_mode="public") + diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py index 9eb85595..e4eff6a5 100644 --- a/tests/test_endpoints.py +++ b/tests/test_endpoints.py @@ -1919,3 +1919,125 @@ def test_viewers_config_file_missing(temp_dir): dispose_engine(db_url) fileglancer.settings.get_settings = original_get_settings fileglancer.database.get_settings = original_get_settings + + +def test_views_crud(test_client): + # create + resp = test_client.post("/api/neuroglancer/views", json={ + "name": "seed6 overlay", + "ng_state": {"layers": [{"name": "img"}]}, + "sharing_mode": "read", + "layers": [{"layer_index": 0, "channel": "Ch0"}], + }) + assert resp.status_code == 200, resp.text + created = resp.json() + assert created["name"] == "seed6 overlay" + assert created["sharing_mode"] == "read" + assert created["owner"] == "testuser" + assert created["read_key"] and created["short_key"] + assert "edit_key" not in created # never exposed + assert len(created["layers"]) == 1 and created["layers"][0]["channel"] == "Ch0" + short_key = created["short_key"] + + # list + resp = test_client.get("/api/neuroglancer/views") + assert resp.status_code == 200 + names = [v["name"] for v in resp.json()["views"]] + assert "seed6 overlay" in names + + # get one + resp = test_client.get(f"/api/neuroglancer/views/{short_key}") + assert resp.status_code == 200 + assert resp.json()["short_key"] == short_key + + # update (rename) + resp = test_client.put(f"/api/neuroglancer/views/{short_key}", json={"name": "renamed"}) + assert resp.status_code == 200 + assert resp.json()["name"] == "renamed" + + # delete + resp = test_client.delete(f"/api/neuroglancer/views/{short_key}") + assert resp.status_code == 200 + assert test_client.get(f"/api/neuroglancer/views/{short_key}").status_code == 404 + + +def test_view_get_and_update_missing_returns_404(test_client): + assert test_client.get("/api/neuroglancer/views/nope").status_code == 404 + assert test_client.put("/api/neuroglancer/views/nope", json={"name": "x"}).status_code == 404 + assert test_client.delete("/api/neuroglancer/views/nope").status_code == 404 + + +def test_view_create_with_unknown_sharing_key_400(test_client): + resp = test_client.post("/api/neuroglancer/views", json={ + "name": "bad", + "ng_state": {}, + "layers": [{"layer_index": 0, "sharing_key": "does-not-exist"}], + }) + assert resp.status_code == 400 + + +def test_ngview_serves_state_by_read_key(test_client): + resp = test_client.post("/api/neuroglancer/views", json={ + "name": "readable", + "ng_state": {"layers": [{"name": "img"}], "position": [1, 2, 3]}, + "sharing_mode": "read", + }) + assert resp.status_code == 200, resp.text + created = resp.json() + read_key = created["read_key"] + + # public read endpoint serves the stored ng_state verbatim + resp = test_client.get(f"/ngview/{read_key}") + assert resp.status_code == 200 + assert resp.json() == {"layers": [{"name": "img"}], "position": [1, 2, 3]} + assert resp.headers.get("cache-control") == "no-store" + + # the short_key is NOT a valid read key + assert test_client.get(f"/ngview/{created['short_key']}").status_code == 404 + # unknown key 404s + assert test_client.get("/ngview/nope").status_code == 404 + + +def _make_proxied_path(test_client, test_app_temp_dir, subdir): + # The test FSP "tempdir" is mounted at test_app_temp_dir; create a real dir. + os.makedirs(os.path.join(test_app_temp_dir, subdir), exist_ok=True) + resp = test_client.post(f"/api/proxied-path?fsp_name=tempdir&path={subdir}") + assert resp.status_code == 200, resp.text + return resp.json()["sharing_key"] + + +def test_dependent_views_endpoint(test_client, temp_dir): + sk = _make_proxied_path(test_client, temp_dir, "dl1") + test_client.post("/api/neuroglancer/views", json={ + "name": "uses dl1", "ng_state": {}, "layers": [{"layer_index": 0, "sharing_key": sk}]}) + + resp = test_client.get(f"/api/proxied-path/{sk}/views") + assert resp.status_code == 200 + assert [v["name"] for v in resp.json()["views"]] == ["uses dl1"] + + +def test_delete_data_link_blocks_then_confirms_marks_broken(test_client, temp_dir): + sk = _make_proxied_path(test_client, temp_dir, "dl2") + created = test_client.post("/api/neuroglancer/views", json={ + "name": "v", "ng_state": {}, "layers": [{"layer_index": 0, "sharing_key": sk}]}).json() + + # no confirm + own dependents -> 409 listing the caller's own view + resp = test_client.delete(f"/api/proxied-path/{sk}") + assert resp.status_code == 409 + detail = resp.json()["detail"] + assert detail["dependent_views"][0]["short_key"] == created["short_key"] + + # confirm=true -> link gone, view survives with its layer marked broken + resp = test_client.delete(f"/api/proxied-path/{sk}?confirm=true") + assert resp.status_code == 200 + assert test_client.get(f"/api/proxied-path/{sk}").status_code == 404 + view = test_client.get(f"/api/neuroglancer/views/{created['short_key']}").json() + assert view["layers"][0]["broken"] is True + assert view["layers"][0]["data_link_id"] is None + + +def test_delete_data_link_no_dependents_still_works(test_client, temp_dir): + sk = _make_proxied_path(test_client, temp_dir, "dl4") + resp = test_client.delete(f"/api/proxied-path/{sk}") + assert resp.status_code == 200 + assert test_client.get(f"/api/proxied-path/{sk}").status_code == 404