From c2011481120bd6376bf3f5de21c3be092c5ccfba Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Fri, 7 Aug 2026 10:41:15 -0400 Subject: [PATCH 1/7] feat(views): add ViewDB/ViewLayerDB models and create/get helpers --- fileglancer/database.py | 100 +++++++++++++++++++++++++++++++++++++++- tests/test_database.py | 29 ++++++++++++ 2 files changed, 127 insertions(+), 2 deletions(-) diff --git a/fileglancer/database.py b/fileglancer/database.py index 1c861b08..47ea8c54 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,61 @@ 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_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/tests/test_database.py b/tests/test_database.py index 4aa2118a..c8b966ca 100644 --- a/tests/test_database.py +++ b/tests/test_database.py @@ -322,6 +322,35 @@ 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_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" From 5d81c47f13945bb2203e713764be2885b3a8b0a2 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Fri, 7 Aug 2026 10:47:11 -0400 Subject: [PATCH 2/7] feat(views): add list/update/delete/dependent-views DB helpers --- fileglancer/database.py | 56 +++++++++++++++++++++++++++++++++++++++++ tests/test_database.py | 34 +++++++++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/fileglancer/database.py b/fileglancer/database.py index 47ea8c54..a07f0cfe 100644 --- a/fileglancer/database.py +++ b/fileglancer/database.py @@ -996,6 +996,62 @@ def get_view_by_short_key(session: Session, short_key: str) -> Optional[ViewDB]: 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/tests/test_database.py b/tests/test_database.py index c8b966ca..1ce953d9 100644 --- a/tests/test_database.py +++ b/tests/test_database.py @@ -351,6 +351,40 @@ def test_create_and_get_view(db_session): 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): + 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) == [] + + 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" From 573f2ab9a332e0be3cd0ea55de89aa052ee8d2cd Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Fri, 7 Aug 2026 10:51:32 -0400 Subject: [PATCH 3/7] test(views): strengthen cascade and distinct coverage for view helpers --- tests/test_database.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/test_database.py b/tests/test_database.py index 1ce953d9..ea3e3281 100644 --- a/tests/test_database.py +++ b/tests/test_database.py @@ -365,23 +365,30 @@ def test_get_views_and_read_key(db_session): def test_update_and_delete_view(db_session): - v = create_view(db_session, "u", "before", {"layers": [1]}, [], "read") + 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}] + 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] + 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) == [] From 9e94e9c247a11c5b675d92726e5bd8401e0c48d3 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Fri, 7 Aug 2026 10:55:50 -0400 Subject: [PATCH 4/7] feat(views): add View/ViewLayer/ViewResponse Pydantic models --- fileglancer/model.py | 35 ++++++++++++++++++++++++++++++++++- tests/test_database.py | 17 +++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) 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 ea3e3281..f362c67f 100644 --- a/tests/test_database.py +++ b/tests/test_database.py @@ -392,6 +392,23 @@ def test_get_views_for_data_link(db_session): 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" From 7531de3288e9b7092f671982ce1bacf241196b0f Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Fri, 7 Aug 2026 11:01:40 -0400 Subject: [PATCH 5/7] feat(views): add Alembic migration for views and view_layers tables --- .../versions/1e8dc304b4f2_add_views_tables.py | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 fileglancer/alembic/versions/1e8dc304b4f2_add_views_tables.py 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') From 17ad2ebac2da79eb29b2f84d1f3005e5511a1c9c Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Fri, 7 Aug 2026 10:09:17 -0400 Subject: [PATCH 6/7] docs: add Neuroglancer Views design spec (read-only scope) Design spec for the Neuroglancer Views feature, scoped to read-only Views with editable Views deferred to a follow-up stack. Covers the data model, API, frontend architecture, and a six-PR gh stack breakdown. Distills the .scratch decision log, ADR-0001, and wireframes. --- .../2026-08-07-neuroglancer-views-design.md | 266 ++++++++++++++++++ 1 file changed, 266 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-07-neuroglancer-views-design.md 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..37332e7c --- /dev/null +++ b/docs/superpowers/specs/2026-08-07-neuroglancer-views-design.md @@ -0,0 +1,266 @@ +# 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 is + `c1f9a4e7b2d8_bake_revision_into_app_urls`. + +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 with `down_revision = c1f9a4e7b2d8`. +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). From 1cb554cd5431b5f4b28a7a1fb92f660a5d42c0a5 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Fri, 7 Aug 2026 11:45:14 -0400 Subject: [PATCH 7/7] docs: add ngviews-01 (data model) plan; correct stale Alembic head ref The design docs cited c1f9a4e7b2d8 as the current Alembic head; on main that revision already has a child and the true sole head is e7b2a9c4f130. Correct the spec and plan references and note that the head advances as PRs merge, so it must be verified live before generating a migration. --- .../plans/2026-08-07-ngviews-01-model.md | 557 ++++++++++++++++++ .../2026-08-07-neuroglancer-views-design.md | 10 +- 2 files changed, 563 insertions(+), 4 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-07-ngviews-01-model.md 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 index 37332e7c..20351a64 100644 --- a/docs/superpowers/specs/2026-08-07-neuroglancer-views-design.md +++ b/docs/superpowers/specs/2026-08-07-neuroglancer-views-design.md @@ -68,8 +68,9 @@ Backend: - 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 is - `c1f9a4e7b2d8_bake_revision_into_app_urls`. +- 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: @@ -128,8 +129,9 @@ created_at / updated_at 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 with `down_revision = c1f9a4e7b2d8`. -Legacy `neuroglancer_states` is left untouched. +One Alembic migration adds both tables, chained off the current head +(`e7b2a9c4f130` at authoring time). Legacy `neuroglancer_states` is left +untouched. ## 5. API