diff --git a/docs/superpowers/plans/2026-08-07-ngviews-01-model.md b/docs/superpowers/plans/2026-08-07-ngviews-01-model.md new file mode 100644 index 00000000..4a98412e --- /dev/null +++ b/docs/superpowers/plans/2026-08-07-ngviews-01-model.md @@ -0,0 +1,557 @@ +# Neuroglancer Views — PR 1 (`ngviews-01-model`) 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 `views` and `view_layers` database tables, their Pydantic models, DB accessor functions, and one additive Alembic migration — the data-model foundation the whole Neuroglancer Views stack builds on. + +**Architecture:** Mirror the existing `NeuroglancerStateDB` / `ProxiedPathDB` patterns in `fileglancer/database.py`. A `ViewDB` row stores the Neuroglancer state + sharing keys; a `ViewLayerDB` row is the many-to-many join between a View and a Data Link (`proxied_paths`). This PR is pure-additive: no routes, no behavior change, existing tables untouched. + +**Tech Stack:** Python 3.12, SQLAlchemy (synchronous), Alembic, Pydantic v2, pytest. All commands run through **pixi** (never system tools). + +## Global Constraints + +- **Always use pixi.** Backend tests run with `pixi run -e test test-backend`. Never call `pytest` or `alembic` directly. +- **Additive only.** Do not modify `neuroglancer_states`, `proxied_paths`, or any existing table/route. This PR changes no runtime behavior. +- **Key generation:** reuse the `secrets.token_urlsafe(12)` pattern; keys must be unique with a retry loop (mirror `_generate_unique_neuroglancer_key`, `database.py:777`). +- **Timestamps:** `datetime.now(UTC)` with the `default`/`onupdate` lambda pattern used by every existing `*_at` column. +- **`edit_key` is reserved, not used.** It is created in the schema so the later editable-Views stack needs no second migration. No code reads or writes it in this PR. Mark it with a `# ponytail: edit_key reserved, unused until the edit stack` comment. +- **`sharing_mode`** values in this scope: `'private'` and `'read'` only. Default `'read'`. +- **Migration `down_revision` is the current sole Alembic head** — `e7b2a9c4f130` at authoring time. Verify the live head before generating (heads advance as PRs merge; `alembic ... heads` or a scan of `fileglancer/alembic/versions/` for the revision that is nobody's `down_revision`). A wrong `down_revision` creates two heads and breaks `alembic upgrade head`. +- **Branch:** all commits land on `ngviews-01-model`, branched off `main`. Create it before Task 1: `git checkout main && git checkout -b ngviews-01-model`. + +--- + +### Task 1: `ViewDB` + `ViewLayerDB` models and `create_view` / `get_view_by_short_key` + +**Files:** +- Modify: `fileglancer/database.py` (add models after `NeuroglancerStateDB`, `database.py:119`; add helpers after `delete_neuroglancer_state`, `database.py:854`) +- Test: `tests/test_database.py` (add after the proxied-path tests, ~`tests/test_database.py:188`) + +**Interfaces:** +- Consumes: `Base`, `secrets`, `datetime`, `UTC`, `Session`, `Optional`, `Dict`, `List` (all already imported at `database.py:1-13`). Add `ForeignKey`, `Boolean` to the `sqlalchemy` import on `database.py:7`. +- Produces: + - `class ViewDB(Base)` — table `views`; columns `id, short_key, read_key, edit_key, name, ng_state (JSON), sharing_mode, owner, created_at, updated_at`. + - `class ViewLayerDB(Base)` — table `view_layers`; columns `id, view_id (FK views.id), data_link_id (FK proxied_paths.id, nullable), layer_index, channel (nullable), opts (JSON nullable), broken (Boolean default False)`. + - `_generate_unique_view_key(session) -> str` + - `create_view(session, username: str, name: str, ng_state: Dict, layers: List[Dict], sharing_mode: str = 'read') -> ViewDB` where each layer dict is `{'data_link_id': Optional[int], 'layer_index': int, 'channel': Optional[str], 'opts': Optional[Dict]}`. + - `get_view_by_short_key(session, short_key: str) -> Optional[ViewDB]` + +- [ ] **Step 1: Write the failing test** + +Add to `tests/test_database.py`: + +```python +def test_create_and_get_view(db_session): + layers = [ + {"data_link_id": None, "layer_index": 0, "channel": "Ch0", "opts": {"color": "red"}}, + {"data_link_id": None, "layer_index": 1, "channel": None, "opts": None}, + ] + view = create_view( + db_session, + username="testuser", + name="seed6 overlay", + ng_state={"layers": []}, + layers=layers, + sharing_mode="read", + ) + assert view.short_key is not None + assert view.read_key is not None + assert view.edit_key is not None + assert view.short_key != view.read_key != view.edit_key + assert view.owner == "testuser" + assert view.sharing_mode == "read" + + fetched = get_view_by_short_key(db_session, view.short_key) + assert fetched is not None + assert fetched.name == "seed6 overlay" + assert len(fetched.layers) == 2 + assert {l.layer_index for l in fetched.layers} == {0, 1} + assert fetched.layers[0].channel == "Ch0" + assert fetched.layers[0].broken is False +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pixi run -e test test-backend -- tests/test_database.py::test_create_and_get_view` +Expected: FAIL with `NameError: name 'create_view' is not defined`. + +- [ ] **Step 3: Add the models** + +In `fileglancer/database.py`, change the import on line 7 to include `ForeignKey` and `Boolean`: + +```python +from sqlalchemy import create_engine, Column, String, Integer, DateTime, JSON, UniqueConstraint, ForeignKey, Boolean +``` + +Add a constant next to the others (`database.py:22`): + +```python +VIEW_KEY_LENGTH = 12 +``` + +Add after `NeuroglancerStateDB` (after `database.py:119`), before `TicketDB`: + +```python +from sqlalchemy.orm import relationship + + +class ViewDB(Base): + """Database model for a Neuroglancer View (state + sharing keys).""" + __tablename__ = 'views' + + id = Column(Integer, primary_key=True, autoincrement=True) + short_key = Column(String, nullable=False, unique=True, index=True) + read_key = Column(String, nullable=False, unique=True, index=True) + # ponytail: edit_key reserved, unused until the edit stack + edit_key = Column(String, nullable=False, unique=True, index=True) + name = Column(String, nullable=False) + ng_state = Column(JSON, nullable=False) + sharing_mode = Column(String, nullable=False, server_default='read') + owner = Column(String, nullable=False, index=True) + created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC)) + updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)) + + layers = relationship( + 'ViewLayerDB', + back_populates='view', + cascade='all, delete-orphan', + order_by='ViewLayerDB.layer_index', + ) + + +class ViewLayerDB(Base): + """Join row: one dataset/channel layer of a View, backed by a Data Link.""" + __tablename__ = 'view_layers' + + id = Column(Integer, primary_key=True, autoincrement=True) + view_id = Column(Integer, ForeignKey('views.id'), nullable=False, index=True) + data_link_id = Column(Integer, ForeignKey('proxied_paths.id'), nullable=True, index=True) + layer_index = Column(Integer, nullable=False) + channel = Column(String, nullable=True) + opts = Column(JSON, nullable=True) + broken = Column(Boolean, nullable=False, server_default=sa_false()) + + view = relationship('ViewDB', back_populates='layers') +``` + +`server_default` for a boolean needs a SQL literal. Add this import at the top of `database.py` with the other sqlalchemy imports (`database.py:7-8` area): + +```python +from sqlalchemy import false as sa_false +``` + +- [ ] **Step 4: Add the helper functions** + +Add after `delete_neuroglancer_state` (`database.py:854`): + +```python +def _generate_unique_view_key(session: Session) -> str: + """Generate a short key unique across all View keys (short/read/edit).""" + for _ in range(10): + candidate = secrets.token_urlsafe(VIEW_KEY_LENGTH) + clash = session.query(ViewDB).filter( + (ViewDB.short_key == candidate) + | (ViewDB.read_key == candidate) + | (ViewDB.edit_key == candidate) + ).first() + if not clash: + return candidate + raise RuntimeError("Failed to generate a unique View key") + + +def create_view( + session: Session, + username: str, + name: str, + ng_state: Dict, + layers: List[Dict], + sharing_mode: str = 'read', +) -> ViewDB: + """Create a View plus its ViewLayer rows. Returns the persisted ViewDB. + + Each layer dict: {data_link_id, layer_index, channel, opts}. + """ + now = datetime.now(UTC) + view = ViewDB( + short_key=_generate_unique_view_key(session), + read_key=_generate_unique_view_key(session), + edit_key=_generate_unique_view_key(session), + name=name, + ng_state=ng_state, + sharing_mode=sharing_mode, + owner=username, + created_at=now, + updated_at=now, + ) + for layer in layers: + view.layers.append(ViewLayerDB( + data_link_id=layer.get('data_link_id'), + layer_index=layer['layer_index'], + channel=layer.get('channel'), + opts=layer.get('opts'), + )) + session.add(view) + session.commit() + return view + + +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() +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `pixi run -e test test-backend -- tests/test_database.py::test_create_and_get_view` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add fileglancer/database.py tests/test_database.py +git commit -m "feat(views): add ViewDB/ViewLayerDB models and create/get helpers" +``` + +--- + +### Task 2: List / update / delete / dependent-views helpers + +**Files:** +- Modify: `fileglancer/database.py` (add after `get_view_by_short_key` from Task 1) +- Test: `tests/test_database.py` + +**Interfaces:** +- Consumes: `ViewDB`, `ViewLayerDB`, `create_view` (Task 1). +- Produces: + - `get_view_by_read_key(session, read_key: str) -> Optional[ViewDB]` + - `get_views(session, username: str) -> List[ViewDB]` (newest first) + - `update_view(session, username, short_key, name=None, ng_state=None) -> Optional[ViewDB]` + - `delete_view(session, username, short_key) -> int` (rows deleted; cascades to layers) + - `get_views_for_data_link(session, data_link_id: int) -> List[ViewDB]` (distinct Views with a layer backed by this Data Link) + +- [ ] **Step 1: Write the failing tests** + +Add to `tests/test_database.py`: + +```python +def test_get_views_and_read_key(db_session): + v1 = create_view(db_session, "u", "one", {"layers": []}, [], "read") + v2 = create_view(db_session, "u", "two", {"layers": []}, [], "private") + create_view(db_session, "other", "three", {"layers": []}, [], "read") + + mine = get_views(db_session, "u") + assert [v.name for v in mine] == ["two", "one"] # newest first + + assert get_view_by_read_key(db_session, v1.read_key).short_key == v1.short_key + assert get_view_by_read_key(db_session, "nope") is None + assert v2.sharing_mode == "private" + + +def test_update_and_delete_view(db_session): + v = create_view(db_session, "u", "before", {"layers": [1]}, [], "read") + updated = update_view(db_session, "u", v.short_key, name="after", ng_state={"layers": [2]}) + assert updated.name == "after" + assert updated.ng_state == {"layers": [2]} + assert update_view(db_session, "wronguser", v.short_key, name="x") is None + + assert delete_view(db_session, "u", v.short_key) == 1 + assert get_view_by_short_key(db_session, v.short_key) is None + + +def test_get_views_for_data_link(db_session): + layers = [{"data_link_id": 42, "layer_index": 0, "channel": None, "opts": None}] + v = create_view(db_session, "u", "linked", {"layers": []}, layers, "read") + create_view(db_session, "u", "unlinked", {"layers": []}, [], "read") + + dependents = get_views_for_data_link(db_session, 42) + assert [d.short_key for d in dependents] == [v.short_key] + assert get_views_for_data_link(db_session, 999) == [] +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pixi run -e test test-backend -- tests/test_database.py -k "views_and_read_key or update_and_delete_view or views_for_data_link"` +Expected: FAIL with `NameError` on the new helpers. + +- [ ] **Step 3: Implement the helpers** + +Add after `get_view_by_short_key` in `database.py`: + +```python +def get_view_by_read_key(session: Session, read_key: str) -> Optional[ViewDB]: + """Resolve a View by its read key (read-only share access).""" + return session.query(ViewDB).filter_by(read_key=read_key).first() + + +def get_views(session: Session, username: str) -> List[ViewDB]: + """Get all Views owned by a user, newest first.""" + return ( + session.query(ViewDB) + .filter_by(owner=username) + .order_by(ViewDB.created_at.desc()) + .all() + ) + + +def update_view( + session: Session, + username: str, + short_key: str, + name: Optional[str] = None, + ng_state: Optional[Dict] = None, +) -> Optional[ViewDB]: + """Update an owned View's name and/or state. Returns None if not owned/found.""" + view = session.query(ViewDB).filter_by(short_key=short_key, owner=username).first() + if not view: + return None + if name is not None: + view.name = name + if ng_state is not None: + view.ng_state = ng_state + view.updated_at = datetime.now(UTC) + session.commit() + return view + + +def delete_view(session: Session, username: str, short_key: str) -> int: + """Delete an owned View (cascades to its layers). Returns rows deleted.""" + view = session.query(ViewDB).filter_by(short_key=short_key, owner=username).first() + if not view: + return 0 + session.delete(view) + session.commit() + 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 ( + session.query(ViewDB) + .join(ViewLayerDB, ViewLayerDB.view_id == ViewDB.id) + .filter(ViewLayerDB.data_link_id == data_link_id) + .distinct() + .all() + ) +``` + +Note: `delete_view` uses `session.delete(view)` (not a bulk `.delete()`) so the `cascade='all, delete-orphan'` on the `layers` relationship removes the join rows. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pixi run -e test test-backend -- tests/test_database.py -k "views_and_read_key or update_and_delete_view or views_for_data_link"` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add fileglancer/database.py tests/test_database.py +git commit -m "feat(views): add list/update/delete/dependent-views DB helpers" +``` + +--- + +### Task 3: Pydantic models + +**Files:** +- Modify: `fileglancer/model.py` (add after `ProxiedPathResponse`, `model.py:163`) +- Test: `tests/test_database.py` (a serialization test that converts a `ViewDB` to the Pydantic `View`) + +**Interfaces:** +- Consumes: `BaseModel`, `Field`, `datetime`, `Optional`, `List`, `Dict` (already imported in `model.py`). +- Produces: + - `class ViewLayer(BaseModel)` — `layer_index: int`, `data_link_id: Optional[int]`, `channel: Optional[str]`, `opts: Optional[Dict]`, `broken: bool`. + - `class View(BaseModel)` — `short_key, read_key, name, ng_state: Dict, sharing_mode, owner, created_at, updated_at, layers: List[ViewLayer]`. **Note: no `edit_key`** — it is not exposed in read-only scope. + - `class ViewResponse(BaseModel)` — `views: List[View]`. + - A `model_config = ConfigDict(from_attributes=True)` on `ViewLayer` and `View` so they build from ORM rows via `View.model_validate(view_db)`. + +- [ ] **Step 1: Write the failing test** + +Add to `tests/test_database.py` (import at top of the function to avoid touching the module import block): + +```python +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}] + view_db = create_view(db_session, "u", "demo", {"layers": []}, layers, "read") + + model = View.model_validate(view_db) + assert model.short_key == view_db.short_key + assert model.read_key == view_db.read_key + assert model.name == "demo" + assert model.sharing_mode == "read" + assert len(model.layers) == 1 + assert model.layers[0].channel == "Ch0" + assert model.layers[0].broken is False + # edit_key must NOT be exposed in read-only scope + assert not hasattr(model, "edit_key") +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pixi run -e test test-backend -- tests/test_database.py::test_view_pydantic_from_orm` +Expected: FAIL with `ImportError: cannot import name 'View'`. + +- [ ] **Step 3: Add the Pydantic models** + +Ensure `ConfigDict` is imported in `model.py` (check the existing `from pydantic import ...` line; add `ConfigDict` if absent). Add after `ProxiedPathResponse` (`model.py:163`): + +```python +class ViewLayer(BaseModel): + """One dataset/channel layer of a Neuroglancer View.""" + model_config = ConfigDict(from_attributes=True) + + layer_index: int = Field(description="Position of this layer within the View") + data_link_id: Optional[int] = Field( + default=None, + description="ID of the Data Link (proxied path) backing this layer; null if broken", + ) + 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") + broken: bool = Field(default=False, description="True if the backing Data Link was deleted") + + +class View(BaseModel): + """A Neuroglancer View: saved NG state + its layers + sharing settings.""" + model_config = ConfigDict(from_attributes=True) + + short_key: str = Field(description="Owner-facing key identifying this View") + 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'") + 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") + layers: List[ViewLayer] = Field(default_factory=list, description="The layers of this View") + + +class ViewResponse(BaseModel): + views: List[View] = Field(description="A list of Neuroglancer Views") +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pixi run -e test test-backend -- tests/test_database.py::test_view_pydantic_from_orm` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add fileglancer/model.py tests/test_database.py +git commit -m "feat(views): add View/ViewLayer/ViewResponse Pydantic models" +``` + +--- + +### Task 4: Alembic migration for `views` + `view_layers` + +**Files:** +- Create: `fileglancer/alembic/versions/_add_views_tables.py` +- (No new test file; verified by running the migration up and down against a scratch DB.) + +**Interfaces:** +- Consumes: the model definitions from Task 1 (the migration must match them column-for-column). +- Produces: a migration with `down_revision` set to the current sole head (`e7b2a9c4f130` at authoring time) creating both tables, and a `downgrade()` that drops them in FK-safe order (`view_layers` before `views`). + +- [ ] **Step 1: Autogenerate the migration** + +Run the project's migrate-create task (`migrate-create = "alembic -c fileglancer/alembic.ini revision --autogenerate"` — `--autogenerate` is already included, so only pass the message): + +Run: `pixi run migrate-create -- -m "add views tables"` + +This writes a new file under `fileglancer/alembic/versions/`. Open it and verify `down_revision` is the current sole head (`e7b2a9c4f130` at authoring time — autogenerate resolves it automatically; confirm it matches the live head and fix it if not). + +- [ ] **Step 2: Replace the generated body with an explicit, reviewed version** + +Autogenerate can misorder FK drops and omit `server_default`s. Overwrite `upgrade()`/`downgrade()` with this (keep the generated `revision`/`down_revision`/`Create Date` header): + +```python +def upgrade() -> None: + op.create_table( + 'views', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('short_key', sa.String(), nullable=False), + sa.Column('read_key', sa.String(), nullable=False), + sa.Column('edit_key', sa.String(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('ng_state', sa.JSON(), nullable=False), + sa.Column('sharing_mode', sa.String(), nullable=False, server_default='read'), + sa.Column('owner', sa.String(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.UniqueConstraint('short_key', name='uq_views_short_key'), + sa.UniqueConstraint('read_key', name='uq_views_read_key'), + sa.UniqueConstraint('edit_key', name='uq_views_edit_key'), + ) + op.create_index('ix_views_short_key', 'views', ['short_key'], unique=True) + op.create_index('ix_views_read_key', 'views', ['read_key'], unique=True) + op.create_index('ix_views_edit_key', 'views', ['edit_key'], unique=True) + op.create_index('ix_views_owner', 'views', ['owner']) + + op.create_table( + 'view_layers', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('view_id', sa.Integer(), sa.ForeignKey('views.id'), nullable=False), + sa.Column('data_link_id', sa.Integer(), sa.ForeignKey('proxied_paths.id'), nullable=True), + sa.Column('layer_index', sa.Integer(), nullable=False), + sa.Column('channel', sa.String(), nullable=True), + sa.Column('opts', sa.JSON(), nullable=True), + sa.Column('broken', sa.Boolean(), nullable=False, server_default=sa.false()), + ) + op.create_index('ix_view_layers_view_id', 'view_layers', ['view_id']) + op.create_index('ix_view_layers_data_link_id', 'view_layers', ['data_link_id']) + + +def downgrade() -> None: + op.drop_index('ix_view_layers_data_link_id', table_name='view_layers') + op.drop_index('ix_view_layers_view_id', table_name='view_layers') + op.drop_table('view_layers') + op.drop_index('ix_views_owner', table_name='views') + op.drop_index('ix_views_edit_key', table_name='views') + op.drop_index('ix_views_read_key', table_name='views') + op.drop_index('ix_views_short_key', table_name='views') + op.drop_table('views') +``` + +- [ ] **Step 3: Verify the migration applies and reverses** + +Run the migration to head, then confirm the schema exists, then verify downgrade removes the tables. Use the project migrate task against a throwaway sqlite DB in the scratchpad: + +Settings use `env_prefix='fgc_'` (`fileglancer/settings.py:135`) over `db_url`, so the DB-URL env var is **`FGC_DB_URL`**: + +```bash +FGDB=/tmp/claude-66302/-opt-fileglancer/61242884-cc6f-4362-8393-066a34fec95a/scratchpad/ngviews-migrate.db +rm -f "$FGDB" +FGC_DB_URL="sqlite:///$FGDB" pixi run migrate +pixi run python -c "import sqlalchemy as sa; e=sa.create_engine('sqlite:///$FGDB'); print(sorted(sa.inspect(e).get_table_names()))" +``` +Expected: the printed table list includes `views` and `view_layers`. + +- [ ] **Step 4: Confirm the full backend suite still passes** + +Run: `pixi run -e test test-backend` +Expected: PASS, including the four new View tests from Tasks 1–3. This also confirms `Base.metadata.create_all` (used by the `db_session` fixture) builds the new tables cleanly. + +- [ ] **Step 5: Commit** + +```bash +git add fileglancer/alembic/versions/ +git commit -m "feat(views): add Alembic migration for views and view_layers tables" +``` + +--- + +## Self-Review + +**Spec coverage (PR 1 slice of §4/§8):** `views` table ✓ (Task 1), `view_layers` join with nullable `data_link_id` + `broken` ✓ (Task 1), `edit_key` reserved/unexposed ✓ (Tasks 1, 3), `sharing_mode` `private|read` ✓ (Tasks 1–3), cart-as-preference — correctly **not** here (no schema change needed, lands in a later PR), Pydantic models ✓ (Task 3), `get_views_for_data_link` for the later delete-guard + "Appears in N Views" ✓ (Task 2), one additive migration off the current head ✓ (Task 4 — implemented as `1e8dc304b4f2` off `e7b2a9c4f130`, the true sole head; the plan's earlier `c1f9a4e7b2d8` was stale). + +**Placeholder scan:** No TBD/TODO; every code step is concrete. + +**Type consistency:** `create_view(session, username, name, ng_state, layers, sharing_mode)` and the layer-dict shape `{data_link_id, layer_index, channel, opts}` are identical in Tasks 1, 2, 3. `View` Pydantic model excludes `edit_key` in both Task 3's definition and its test. `delete_view` returns `int` (Task 2 signature + test). `ViewLayerDB.broken` default `False` asserted in Tasks 1 and 3. + +## Out of scope for this PR (next plans) + +- PR 2 `ngviews-02-api`: Views CRUD routes, `GET /ngview/{key}` (read key), `GET /api/proxied-path/{sharing_key}/views`, Data Link delete modes (`mark_broken` | `cascade`). +- PR 3–6: frontend (multi-select, Views page, browser entry points, read-only embedded viewer). + +Each gets its own plan once the interfaces below it are locked. diff --git a/docs/superpowers/specs/2026-08-07-neuroglancer-views-design.md b/docs/superpowers/specs/2026-08-07-neuroglancer-views-design.md new file mode 100644 index 00000000..20351a64 --- /dev/null +++ b/docs/superpowers/specs/2026-08-07-neuroglancer-views-design.md @@ -0,0 +1,268 @@ +# Neuroglancer Views — design spec + +Status: **approved for implementation (read-only scope)** +Date: 2026-08-07 +Source material: `.scratch/neuroglancer-views/` (decision log + wireframes), +`docs/adr/0001-neuroglancer-views.md`, `CONTEXT.md` (glossary). + +## 1. Summary + +A **Neuroglancer View** becomes a first-class saved object: a Neuroglancer +state + the datasets it shows (a many-to-many relationship) + sharing settings. +Views open **embedded inside Fileglancer** in an iframe. A plain Neuroglancer +link becomes a one-shot **export** of a View, not a separately persisted record. +Views are built two ways — a **Layer Cart** (collect datasets/channels while +browsing, then check out) and, in a later stack, an **In-View Data Panel**. + +This spec covers the **read-only** scope. Editable Views (an edit link whose +changes persist for everyone) are explicitly deferred to a follow-up stack; the +data model reserves space for them but no edit behavior is built here. + +Delivery is a `gh stack` of six bottom-up PRs (§6). + +## 2. Scope + +### In scope (read-only) + +- `views` + `view_layers` tables and one Alembic migration (additive). +- Views CRUD API (owner-scoped): create, list, get, rename, delete. +- Read-link access: `GET /ngview/{key}` serves a View's state to the NG iframe. +- Layer Cart (server-side, per-user) + checkout → creates a View. +- File-browser entry points: multi-select, row `⋯` items, floating selection + bar, right-edge icon rail (Properties ↔ Cart), cart drawer, count badges. +- Data-link consent reuse for any View-creating action. +- Bidirectional discovery: dataset → Views (Properties drawer) and + View → datasets (Views page / layer list). +- Data Link deletion guard with a dependent-Views resolution dialog. +- Read-only embedded viewer: iframe + state + Export menu + Fullscreen + + scratch View → "Save as View". +- Three one-shot exports: Copy Neuroglancer link · Download JSON state · + Open in external Neuroglancer. + +### Out of scope (deferred to the editable-Views stack) + +- The **edit link**, persist-on-change, and the amber "changes save for + everyone" banner. +- The **In-View Data Panel** (live browse-and-add that mutates a saved View). +- `sharing_mode: read_edit` and the two-link Share UI. + +### Out of scope (other) + +- Migrating existing `neuroglancer_states` / `/nglinks` short links into Views + (a later cleanup stack). Legacy short links keep working; the nav entry is + repointed and `/nglinks` redirects to `/ngviews`. +- Transparent vs non-transparent Data Link subpath behavior (open question 6): + Views use whatever URL a Data Link resolves to. +- Org-wide View directory. Listing is owner-only + anyone with a Read Link. + +## 3. What already exists (reuse, do not rebuild) + +Backend: + +- `NeuroglancerStateDB` (`fileglancer/database.py:108`) + full + `/api/neuroglancer/nglinks` CRUD and `/ng/{short_key}` state-serving routes + (`fileglancer/server.py:1007+`, `:1229`, `:1240`). Views mirror the + state-serving mechanism. +- `ProxiedPathDB` / Data Links, keyed by `sharing_key` + (`database.py:89`, routes `server.py:1120+`). +- Generic user preferences (`UserPreferenceDB`, `database.py:75`; routes + `server.py:972-1005`) — the Layer Cart is stored here, not in a new table. +- `secrets.token_urlsafe(12)` key pattern for short/sharing keys. +- Linear Alembic chain; head at authoring time is + `e7b2a9c4f130_add_name_to_jobs` (verify the live head before generating a + migration — it advances as PRs merge). + +Frontend: + +- `/nglinks` page (`NGLinks.tsx` + `NGLinkContext` + `queries/ngLinkQueries.ts`) + — the page Views replaces; its structure is the template for the Views page. +- `AppsLayout` top-tab sub-nav pattern (`src/layouts/AppsLayout.tsx`) with + `FgBadge` counts — the template for the Views sub-nav. +- `TableCard` (`components/ui/Table/TableCard.tsx`) + generic + `DataLinksActionsMenu` (`components/ui/Menus/DataLinksActions.tsx`). +- NG state generation (`src/omezarr-helper.ts`, surfaced via + `useZarrMetadata.ts`) and NG URL parse/construct utils + (`src/utils/neuroglancerUrl.ts`, incl. `constructNeuroglancerUrl`). +- The configured `neuroglancer` viewer entry + its `urlTemplate`, loaded by + `ViewersContext` — this is the iframe base URL for embedding (no new config, + no hosted build). +- Data-link consent dialog + gate (`components/ui/Dialogs/DataLink.tsx`, + `PropertiesDrawer.tsx:281`, `hooks/useDataToolLinks.ts:244`) driven by the + `areDataLinksAutomatic` preference (`PreferencesContext.tsx`). + +Two net-new things the codebase lacks: + +- **No in-app NG embed** — NG only ever opens in a new tab today. +- **No multi-select** in the file browser — it is single-select, no row + checkboxes. + +## 4. Data model + +Two new tables. Everything else already exists. + +``` +views view_layers (join) +----- ------------------ +id id +short_key (token_urlsafe, unique) view_id → views.id (FK) +read_key (token_urlsafe, unique) data_link_id → proxied_paths.id (FK, nullable) +edit_key (token_urlsafe, unique) layer_index +name channel (nullable) +ng_state (JSON) opts (JSON, nullable) +sharing_mode ('private' | 'read') broken (bool, default false) +owner (username) +created_at / updated_at +``` + +- `ng_state` is the source of truth. The plain Neuroglancer link is *derived* + from it and never stored separately. +- `view_layers` records the many-to-many between Views and Data Links, powering + both discovery directions and the delete guard. +- `edit_key` is created but unused until the editable-Views stack — cutting it + now avoids a second migration later. + `# ponytail: edit_key reserved, unused until the edit stack`. +- `data_link_id` nullable + `broken` support the "mark broken" deletion path: + null the link and flag the layer without deleting the View. +- **Layer Cart is not a table.** It is a single `UserPreferenceDB` row, + `key = "neuroglancerCart"`, value a JSON array of + `{ fsp_name, path, channel?, label }`. Server-side, per-user, survives + reload/devices, via the existing preference CRUD. + `# ponytail: cart-as-preference; a table only if it needs indexing or cross-user sharing`. + +One Alembic migration adds both tables, chained off the current head +(`e7b2a9c4f130` at authoring time). Legacy `neuroglancer_states` is left +untouched. + +## 5. API + +All under the existing `/api/neuroglancer` prefix unless noted. The client +builds `ng_state` (state generation already lives in `omezarr-helper.ts`), so +the backend only stores it — no server-side NG state generation. + +| Method + path | Purpose | +|---|---| +| `POST /api/neuroglancer/views` | Create a View from a client-built `ng_state` + layer list. Generates keys. | +| `GET /api/neuroglancer/views` | List the current user's Views. | +| `GET /api/neuroglancer/views/{short_key}` | Get one owned View (management). | +| `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). + +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, +and stays in read-only scope. + +## 6. Frontend architecture + +- **Views page** mirrors `AppsLayout`'s top-tab pattern (the wireframe says + "Apps-style sidebar," but Apps uses top tabs in code — match the code). Tabs: + **Saved Views** and **Layer Cart** (count badge via `FgBadge`). + - Saved Views: `TableCard` + a `useNGViewsColumns` hook + reused + `DataLinksActionsMenu`. Columns: name / View link, layers, sharing, updated, + actions (Open · Export ▾ · ⋯). + - Export ▾ (client-side, from `ng_state`): Copy Neuroglancer link · + Download JSON state · Open in external Neuroglancer (reuse + `constructNeuroglancerUrl`). + - Layer Cart tab: Fiji/N5-Viewer-style tree, two-level (dataset + channel), + channels lazy-load on expand, non-Zarr/N5 folders disabled → + "Create View" checkout. +- **State**: new `ViewsContext` + `queries/viewQueries.ts` (mirror + `NGLinkContext` / `ngLinkQueries.ts`); new `CartContext` backed by the + `neuroglancerCart` preference. +- **Multi-select** (net-new) added to `FileTable` + `FileBrowserContext`: row + checkboxes, a header select-all, selection set. Foundation for the selection + bar and cart; lands before them in the stack. +- **Browser entry points**: + - Row `⋯` menu (`FileTable` / `ContextMenu`): add "View in Neuroglancer" + (scratch View) and "Add to Neuroglancer cart". + - Floating selection bar (multi-row, ClickUp-style), shown when ≥1 row + selected: Add to cart · New View from selection · Share · Download · More. + - Right-edge icon rail (Browse only): **Properties (ⓘ)** ↔ **Cart (🛒 + count)**, + swapping the drawer content. Selecting a file while Cart is open updates + Properties in the background (dot on ⓘ), not stealing the panel. + - Cart drawer: working list (review/remove) + "Create View" + "Open full + Layer Cart". + - Passive cart count on the header **NG Views** nav item (all routes) mirrored + on the 🛒 rail icon (Browse) — informational, like Apps' "N jobs" badge. +- **Consent**: any View-creating action while `areDataLinksAutomatic` is OFF + reuses the existing `DataLink.tsx` consent dialog (copy: "Create N links & + open View", with the "don't ask again" toggle). ON ⇒ links created silently. +- **Properties drawer**: add an "Appears in N Views" section (dataset → Views), + fed by `GET /api/proxied-path/{sharing_key}/views`. +- **Data Link delete dialog**: query dependents; if any, show the list + a + choice of **Mark Views broken** or **Delete those Views**, plus **Cancel**; + on confirm call `DELETE` with the chosen mode. +- **Embedded viewer** (`/ngview/:key`, read-only): iframe the configured + `neuroglancer` viewer `urlTemplate`, hash-driven `#!{state}` where the state + is served by `GET /ngview/{key}`. Thin chrome: a View toolbar (name, Export ▾, + Fullscreen); Fullscreen hides the header + toolbar so the iframe fills the + window (still the in-app session). No edit banner, no data panel in this scope. + - **Scratch View**: "View in Neuroglancer" on a single dataset opens a + read-only embedded View from client state; "Save as View" persists it via + `POST /api/neuroglancer/views`. Unsaved scratch state is client-only — no + server row, nothing to garbage-collect. +- **Navigation**: replace the Navbar "NG Links" entry with "NG Views" (+ passive + cart count); `/nglinks` redirects to `/ngviews`. + +## 7. Behavior details / decisions + +- **Visibility**: owner-only listing; anyone with a Read Link can open. No ACL. +- **Read-link export**: a read-link visitor may Copy link / Download JSON of + 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. +- **Scratch View lifetime**: client-only until saved. + +## 8. The `gh stack` — six bottom-up PRs + +Delivered via `gh stack` (official `github/gh-stack` extension). Each branch +targets the one below; `gh stack init` off `main`, `gh stack add ` up +the chain, `gh stack submit` links them into stacked PRs on GitHub. Every PR is +independently reviewable and the chain merges bottom-up. + +| # | Branch | Scope | Depends on | +|---|--------|-------|-----------| +| 1 | `ngviews-01-model` | `views` + `view_layers` tables (`edit_key` reserved), Pydantic models, Alembic migration. Pure-additive, zero behavior change. | `main` | +| 2 | `ngviews-02-api` | Views CRUD, `GET /ngview/{key}` (read key), dependent-views endpoint, Data Link delete modes. Backend tests. | 01 | +| 3 | `ngviews-03-multiselect` | Row checkboxes + multi-select in `FileTable` / `FileBrowserContext`. Isolated so the core-browser change reviews cleanly. | 02 | +| 4 | `ngviews-04-views-page` | `/ngviews` page (Saved Views table + Export menu), `ViewsContext`, `CartContext`, nav rename + `/nglinks` redirect. | 03 | +| 5 | `ngviews-05-browser-entry` | Row `⋯` items, floating selection bar, right-edge rail (Properties ↔ Cart), cart drawer, consent reuse, Properties "Appears in N Views", Data Link delete dialog, full Layer Cart tab + checkout. | 04 | +| 6 | `ngviews-06-embedded-readonly` | `/ngview/:key` read-only embedded iframe, thin chrome, Export, Fullscreen, scratch View + "Save as View". | 05 | + +**Follow-up stack (not built here): editable Views** — edit-link sessions, +persist-on-change, the amber edit banner, the In-View Data Panel, and +`sharing_mode: read_edit` + two-link Share UI. + +## 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). +- **Frontend unit** (`pixi run test-frontend`): `viewQueries` / `CartContext` + reducers; Export menu URL construction; multi-select selection logic; + consent-gate branching on `areDataLinksAutomatic`. +- **E2E** (`pixi run test-ui`): add-to-cart → checkout → View appears in Saved + Views → open embedded read-only viewer; selection-bar "New View from + selection" with consent OFF → consent dialog → View; Data Link delete with a + dependent View → dialog choice. + +## 10. Open items carried forward (non-blocking) + +- Which NG deployment `urlTemplate` backs the iframe in production (config, not + code) — reuses the existing `neuroglancer` viewer entry. +- Whether legacy `neuroglancer_states` short links are migrated into Views + (separate later stack). diff --git a/fileglancer/alembic/versions/1e8dc304b4f2_add_views_tables.py b/fileglancer/alembic/versions/1e8dc304b4f2_add_views_tables.py new file mode 100644 index 00000000..11ef556c --- /dev/null +++ b/fileglancer/alembic/versions/1e8dc304b4f2_add_views_tables.py @@ -0,0 +1,63 @@ +"""add views tables + +Revision ID: 1e8dc304b4f2 +Revises: e7b2a9c4f130 +Create Date: 2026-08-07 10:59:17.090843 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '1e8dc304b4f2' +down_revision = 'e7b2a9c4f130' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + 'views', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('short_key', sa.String(), nullable=False), + sa.Column('read_key', sa.String(), nullable=False), + sa.Column('edit_key', sa.String(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('ng_state', sa.JSON(), nullable=False), + sa.Column('sharing_mode', sa.String(), nullable=False, server_default='read'), + sa.Column('owner', sa.String(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.UniqueConstraint('short_key', name='uq_views_short_key'), + sa.UniqueConstraint('read_key', name='uq_views_read_key'), + sa.UniqueConstraint('edit_key', name='uq_views_edit_key'), + ) + op.create_index('ix_views_short_key', 'views', ['short_key'], unique=True) + op.create_index('ix_views_read_key', 'views', ['read_key'], unique=True) + op.create_index('ix_views_edit_key', 'views', ['edit_key'], unique=True) + op.create_index('ix_views_owner', 'views', ['owner']) + + op.create_table( + 'view_layers', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('view_id', sa.Integer(), sa.ForeignKey('views.id'), nullable=False), + sa.Column('data_link_id', sa.Integer(), sa.ForeignKey('proxied_paths.id'), nullable=True), + sa.Column('layer_index', sa.Integer(), nullable=False), + sa.Column('channel', sa.String(), nullable=True), + sa.Column('opts', sa.JSON(), nullable=True), + sa.Column('broken', sa.Boolean(), nullable=False, server_default=sa.false()), + ) + op.create_index('ix_view_layers_view_id', 'view_layers', ['view_id']) + op.create_index('ix_view_layers_data_link_id', 'view_layers', ['data_link_id']) + + +def downgrade() -> None: + op.drop_index('ix_view_layers_data_link_id', table_name='view_layers') + op.drop_index('ix_view_layers_view_id', table_name='view_layers') + op.drop_table('view_layers') + op.drop_index('ix_views_owner', table_name='views') + op.drop_index('ix_views_edit_key', table_name='views') + op.drop_index('ix_views_read_key', table_name='views') + op.drop_index('ix_views_short_key', table_name='views') + op.drop_table('views') diff --git a/fileglancer/database.py b/fileglancer/database.py index 1c861b08..a07f0cfe 100644 --- a/fileglancer/database.py +++ b/fileglancer/database.py @@ -4,8 +4,9 @@ import os from functools import lru_cache -from sqlalchemy import create_engine, Boolean, Column, String, Integer, DateTime, JSON, UniqueConstraint, func -from sqlalchemy.orm import sessionmaker, declarative_base, Session +from sqlalchemy import create_engine, Boolean, Column, String, Integer, DateTime, JSON, UniqueConstraint, ForeignKey, func +from sqlalchemy import false as sa_false +from sqlalchemy.orm import sessionmaker, declarative_base, relationship, Session from sqlalchemy.engine.url import make_url from sqlalchemy.pool import StaticPool from typing import Optional, Dict, List, Tuple @@ -20,6 +21,7 @@ # Constants SHARING_KEY_LENGTH = 12 NEUROGLANCER_SHORT_KEY_LENGTH = 12 +VIEW_KEY_LENGTH = 12 # Global flag to track if migrations have been run _migrations_run = False @@ -128,6 +130,45 @@ class NeuroglancerStateDB(Base): updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)) +class ViewDB(Base): + """Database model for a Neuroglancer View (state + sharing keys).""" + __tablename__ = 'views' + + id = Column(Integer, primary_key=True, autoincrement=True) + short_key = Column(String, nullable=False, unique=True, index=True) + read_key = Column(String, nullable=False, unique=True, index=True) + # ponytail: edit_key reserved, unused until the edit stack + edit_key = Column(String, nullable=False, unique=True, index=True) + name = Column(String, nullable=False) + ng_state = Column(JSON, nullable=False) + sharing_mode = Column(String, nullable=False, server_default='read') + owner = Column(String, nullable=False, index=True) + created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC)) + updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)) + + layers = relationship( + 'ViewLayerDB', + back_populates='view', + cascade='all, delete-orphan', + order_by='ViewLayerDB.layer_index', + ) + + +class ViewLayerDB(Base): + """Join row: one dataset/channel layer of a View, backed by a Data Link.""" + __tablename__ = 'view_layers' + + id = Column(Integer, primary_key=True, autoincrement=True) + view_id = Column(Integer, ForeignKey('views.id'), nullable=False, index=True) + data_link_id = Column(Integer, ForeignKey('proxied_paths.id'), nullable=True, index=True) + layer_index = Column(Integer, nullable=False) + channel = Column(String, nullable=True) + opts = Column(JSON, nullable=True) + broken = Column(Boolean, nullable=False, server_default=sa_false()) + + view = relationship('ViewDB', back_populates='layers') + + class TicketDB(Base): """Database model for storing proxied paths""" __tablename__ = 'tickets' @@ -900,6 +941,117 @@ def delete_neuroglancer_state(session: Session, username: str, short_key: str) - return deleted +def _generate_unique_view_key(session: Session) -> str: + """Generate a short key unique across all View keys (short/read/edit).""" + for _ in range(10): + candidate = secrets.token_urlsafe(VIEW_KEY_LENGTH) + clash = session.query(ViewDB).filter( + (ViewDB.short_key == candidate) + | (ViewDB.read_key == candidate) + | (ViewDB.edit_key == candidate) + ).first() + if not clash: + return candidate + raise RuntimeError("Failed to generate a unique View key") + + +def create_view( + session: Session, + username: str, + name: str, + ng_state: Dict, + layers: List[Dict], + sharing_mode: str = 'read', +) -> ViewDB: + """Create a View plus its ViewLayer rows. Returns the persisted ViewDB. + + Each layer dict: {data_link_id, layer_index, channel, opts}. + """ + now = datetime.now(UTC) + view = ViewDB( + short_key=_generate_unique_view_key(session), + read_key=_generate_unique_view_key(session), + edit_key=_generate_unique_view_key(session), + name=name, + ng_state=ng_state, + sharing_mode=sharing_mode, + owner=username, + created_at=now, + updated_at=now, + ) + for layer in layers: + view.layers.append(ViewLayerDB( + data_link_id=layer.get('data_link_id'), + layer_index=layer['layer_index'], + channel=layer.get('channel'), + opts=layer.get('opts'), + )) + session.add(view) + session.commit() + return view + + +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() + + +def get_view_by_read_key(session: Session, read_key: str) -> Optional[ViewDB]: + """Resolve a View by its read key (read-only share access).""" + return session.query(ViewDB).filter_by(read_key=read_key).first() + + +def get_views(session: Session, username: str) -> List[ViewDB]: + """Get all Views owned by a user, newest first.""" + return ( + session.query(ViewDB) + .filter_by(owner=username) + .order_by(ViewDB.created_at.desc()) + .all() + ) + + +def update_view( + session: Session, + username: str, + short_key: str, + name: Optional[str] = None, + ng_state: Optional[Dict] = None, +) -> Optional[ViewDB]: + """Update an owned View's name and/or state. Returns None if not owned/found.""" + view = session.query(ViewDB).filter_by(short_key=short_key, owner=username).first() + if not view: + return None + if name is not None: + view.name = name + if ng_state is not None: + view.ng_state = ng_state + view.updated_at = datetime.now(UTC) + session.commit() + return view + + +def delete_view(session: Session, username: str, short_key: str) -> int: + """Delete an owned View (cascades to its layers). Returns rows deleted.""" + view = session.query(ViewDB).filter_by(short_key=short_key, owner=username).first() + if not view: + return 0 + session.delete(view) + session.commit() + 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 ( + session.query(ViewDB) + .join(ViewLayerDB, ViewLayerDB.view_id == ViewDB.id) + .filter(ViewLayerDB.data_link_id == data_link_id) + .distinct() + .all() + ) + + def get_tickets(session: Session, username: str, fsp_name: str = None, path: str = None) -> List[TicketDB]: """Get tickets for a user, optionally filtered by fsp_name and path""" logger.info(f"Getting tickets for {username} with fsp_name={fsp_name} and path={path}") diff --git a/fileglancer/model.py b/fileglancer/model.py index c7fa8b69..28b5bf7a 100644 --- a/fileglancer/model.py +++ b/fileglancer/model.py @@ -3,7 +3,7 @@ from datetime import datetime from typing import Annotated, Any, List, Literal, Optional, Dict, Union -from pydantic import BaseModel, Discriminator, Field, HttpUrl, Tag, field_validator, model_validator +from pydantic import BaseModel, ConfigDict, Discriminator, Field, HttpUrl, Tag, field_validator, model_validator from fileglancer.giturls import _parse_github_url @@ -166,6 +166,39 @@ class ProxiedPathResponse(BaseModel): ) +class ViewLayer(BaseModel): + """One dataset/channel layer of a Neuroglancer View.""" + model_config = ConfigDict(from_attributes=True) + + layer_index: int = Field(description="Position of this layer within the View") + data_link_id: Optional[int] = Field( + default=None, + description="ID of the Data Link (proxied path) backing this layer; null if broken", + ) + 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") + broken: bool = Field(default=False, description="True if the backing Data Link was deleted") + + +class View(BaseModel): + """A Neuroglancer View: saved NG state + its layers + sharing settings.""" + model_config = ConfigDict(from_attributes=True) + + short_key: str = Field(description="Owner-facing key identifying this View") + 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'") + 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") + layers: List[ViewLayer] = Field(default_factory=list, description="The layers of this View") + + +class ViewResponse(BaseModel): + views: List[View] = Field(description="A list of Neuroglancer Views") + + class ExternalBucket(BaseModel): """An external bucket for S3-compatible storage""" id: int = Field( diff --git a/tests/test_database.py b/tests/test_database.py index 4aa2118a..f362c67f 100644 --- a/tests/test_database.py +++ b/tests/test_database.py @@ -322,6 +322,93 @@ def test_delete_proxied_path(db_session, fsp): assert deleted_path is None +def test_create_and_get_view(db_session): + layers = [ + {"data_link_id": None, "layer_index": 0, "channel": "Ch0", "opts": {"color": "red"}}, + {"data_link_id": None, "layer_index": 1, "channel": None, "opts": None}, + ] + view = create_view( + db_session, + username="testuser", + name="seed6 overlay", + ng_state={"layers": []}, + layers=layers, + sharing_mode="read", + ) + assert view.short_key is not None + assert view.read_key is not None + assert view.edit_key is not None + assert view.short_key != view.read_key != view.edit_key + assert view.owner == "testuser" + assert view.sharing_mode == "read" + + fetched = get_view_by_short_key(db_session, view.short_key) + assert fetched is not None + assert fetched.name == "seed6 overlay" + assert len(fetched.layers) == 2 + assert {l.layer_index for l in fetched.layers} == {0, 1} + assert fetched.layers[0].channel == "Ch0" + assert fetched.layers[0].broken is False + + +def test_get_views_and_read_key(db_session): + v1 = create_view(db_session, "u", "one", {"layers": []}, [], "read") + v2 = create_view(db_session, "u", "two", {"layers": []}, [], "private") + create_view(db_session, "other", "three", {"layers": []}, [], "read") + + mine = get_views(db_session, "u") + assert [v.name for v in mine] == ["two", "one"] # newest first + + assert get_view_by_read_key(db_session, v1.read_key).short_key == v1.short_key + assert get_view_by_read_key(db_session, "nope") is None + assert v2.sharing_mode == "private" + + +def test_update_and_delete_view(db_session): + layers = [{"data_link_id": None, "layer_index": 0, "channel": None, "opts": None}] + v = create_view(db_session, "u", "before", {"layers": [1]}, layers, "read") + view_id = v.id + updated = update_view(db_session, "u", v.short_key, name="after", ng_state={"layers": [2]}) + assert updated.name == "after" + assert updated.ng_state == {"layers": [2]} + assert update_view(db_session, "wronguser", v.short_key, name="x") is None + + assert db_session.query(ViewLayerDB).filter_by(view_id=view_id).count() == 1 # layer exists pre-delete + assert delete_view(db_session, "u", v.short_key) == 1 + assert get_view_by_short_key(db_session, v.short_key) is None + assert db_session.query(ViewLayerDB).filter_by(view_id=view_id).count() == 0 # cascade removed the join row + + +def test_get_views_for_data_link(db_session): + layers = [ + {"data_link_id": 42, "layer_index": 0, "channel": None, "opts": None}, + {"data_link_id": 42, "layer_index": 1, "channel": "Ch1", "opts": None}, + ] + v = create_view(db_session, "u", "linked", {"layers": []}, layers, "read") + create_view(db_session, "u", "unlinked", {"layers": []}, [], "read") + + dependents = get_views_for_data_link(db_session, 42) + assert [d.short_key for d in dependents] == [v.short_key] # distinct: one entry despite two matching layers + assert get_views_for_data_link(db_session, 999) == [] + + +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}] + view_db = create_view(db_session, "u", "demo", {"layers": []}, layers, "read") + + model = View.model_validate(view_db) + assert model.short_key == view_db.short_key + assert model.read_key == view_db.read_key + assert model.name == "demo" + assert model.sharing_mode == "read" + assert len(model.layers) == 1 + assert model.layers[0].channel == "Ch0" + assert model.layers[0].broken is False + # edit_key must NOT be exposed in read-only scope + assert not hasattr(model, "edit_key") + + def test_create_proxied_path_for_file(db_session, fsp): """Regression: create_proxied_path should succeed for a file path (not 500 on os.listdir).""" username = "testuser"