Skip to content

Commit ff7f8a0

Browse files
committed
update migration to scoped entities
1 parent d3a4aec commit ff7f8a0

20 files changed

Lines changed: 277 additions & 51 deletions

File tree

‎backend/api/inputs.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ class UpdateLocationNodeInput:
154154
@strawberry.enum
155155
class ScopeVisibility(Enum):
156156
PRIVATE = "private"
157+
SHARED = "shared"
157158
PUBLIC = "public"
158159

159160

‎backend/api/resolvers/saved_view.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ async def create_saved_view(
8686
) -> SavedViewType:
8787
user = _require_user(info)
8888
visibility, location_id = await resolve_scope_input(
89-
info, user, data.visibility, data.location_id
89+
info, user, data.visibility, data.location_id, allow_shared=True
9090
)
9191
for blob, label in (
9292
(data.filter_definition, "filter_definition"),
@@ -158,7 +158,9 @@ async def update_saved_view(
158158
row.related_parameters = _validated_json(
159159
data.related_parameters, "related_parameters"
160160
)
161-
await apply_scope_update(info, user, row, data.visibility, data.location_id)
161+
await apply_scope_update(
162+
info, user, row, data.visibility, data.location_id, allow_shared=True
163+
)
162164

163165
await db.commit()
164166
await db.refresh(row)

‎backend/api/services/scope.py‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@
1313
from database import models
1414

1515
PRIVATE = ScopeVisibility.PRIVATE.value
16+
SHARED = ScopeVisibility.SHARED.value
1617
PUBLIC = ScopeVisibility.PUBLIC.value
18+
LINK_READABLE = (PUBLIC, SHARED)
1719

1820

1921
def normalize_root_location_ids(
@@ -47,7 +49,7 @@ async def can_read_scoped(info: Info, user: models.User | None, row: Any) -> boo
4749
owner_user_id = getattr(row, "owner_user_id", None)
4850
if owner_user_id is not None and owner_user_id == user.id:
4951
return True
50-
if row.visibility != PUBLIC:
52+
if row.visibility not in LINK_READABLE:
5153
return False
5254
if row.location_id is None:
5355
return True
@@ -61,9 +63,16 @@ async def resolve_scope_input(
6163
user: models.User,
6264
visibility: ScopeVisibility,
6365
location_id: strawberry.ID | str | None,
66+
*,
67+
allow_shared: bool = False,
6468
) -> tuple[str, str | None]:
6569
if visibility == ScopeVisibility.PRIVATE:
6670
return PRIVATE, None
71+
if visibility == ScopeVisibility.SHARED and not allow_shared:
72+
raise GraphQLError(
73+
"Link sharing is only available for saved views.",
74+
extensions={"code": "BAD_REQUEST"},
75+
)
6776
auth_service = AuthorizationService(info.context.db)
6877
if location_id is None:
6978
default_location_id = await auth_service.default_scope_location_id(
@@ -74,12 +83,12 @@ async def resolve_scope_input(
7483
"A location is required to share this entry.",
7584
extensions={"code": "BAD_REQUEST"},
7685
)
77-
return PUBLIC, default_location_id
86+
return visibility.value, default_location_id
7887
if not await auth_service.can_access_location(
7988
user, str(location_id), info.context
8089
):
8190
raise_forbidden()
82-
return PUBLIC, str(location_id)
91+
return visibility.value, str(location_id)
8392

8493

8594
async def apply_scope_update(
@@ -88,6 +97,8 @@ async def apply_scope_update(
8897
row: Any,
8998
visibility: ScopeVisibility | None,
9099
location_id: strawberry.ID | None,
100+
*,
101+
allow_shared: bool = False,
91102
) -> None:
92103
if visibility is None and location_id is None:
93104
return
@@ -98,7 +109,7 @@ async def apply_scope_update(
98109
str(location_id) if location_id is not None else row.location_id
99110
)
100111
row.visibility, row.location_id = await resolve_scope_input(
101-
info, user, target_visibility, target_location_id
112+
info, user, target_visibility, target_location_id, allow_shared=allow_shared
102113
)
103114

104115

‎backend/database/migrations/versions/add_scope_visibility.py‎

Lines changed: 65 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
"""Scope presets, saved views and property definitions to a location node.
22
3+
Existing property definitions become public on the root location so everyone
4+
keeps seeing them. Existing link-shared saved views stay reachable by link
5+
(``shared``) without being listed publicly, everything else becomes private.
6+
Existing task presets become private.
7+
38
Revision ID: add_scope_visibility
49
Revises: add_scope_location_prop_view
510
Create Date: 2026-09-02 12:00:00.000000
@@ -15,11 +20,30 @@
1520
branch_labels: Union[str, Sequence[str], None] = None
1621
depends_on: Union[str, Sequence[str], None] = None
1722

23+
PRIVATE = "private"
24+
SHARED = "shared"
25+
PUBLIC = "public"
26+
LEGACY_LINK_SHARED = "link_shared"
27+
1828

1929
def _root_location_id(conn) -> str | None:
2030
rows = conn.execute(
2131
sa.text(
22-
"SELECT id FROM location_nodes WHERE parent_id IS NULL ORDER BY title, id"
32+
"""
33+
WITH RECURSIVE subtree(root_id, id) AS (
34+
SELECT id, id FROM location_nodes WHERE parent_id IS NULL
35+
UNION ALL
36+
SELECT subtree.root_id, child.id
37+
FROM location_nodes AS child
38+
JOIN subtree ON child.parent_id = subtree.id
39+
)
40+
SELECT root.id, COUNT(subtree.id) AS size
41+
FROM location_nodes AS root
42+
JOIN subtree ON subtree.root_id = root.id
43+
WHERE root.parent_id IS NULL
44+
GROUP BY root.id, root.title
45+
ORDER BY size DESC, root.title, root.id
46+
"""
2347
)
2448
).fetchall()
2549
return rows[0][0] if rows else None
@@ -35,7 +59,7 @@ def upgrade() -> None:
3559
"visibility",
3660
sa.String(length=16),
3761
nullable=False,
38-
server_default="private",
62+
server_default=PRIVATE,
3963
)
4064
)
4165
batch_op.add_column(sa.Column("owner_user_id", sa.String(), nullable=True))
@@ -45,7 +69,10 @@ def upgrade() -> None:
4569
["owner_user_id"],
4670
["id"],
4771
)
48-
conn.execute(sa.text("UPDATE property_definitions SET visibility = 'public'"))
72+
conn.execute(
73+
sa.text("UPDATE property_definitions SET visibility = :visibility"),
74+
{"visibility": PUBLIC},
75+
)
4976
if root_id:
5077
conn.execute(
5178
sa.text(
@@ -56,16 +83,35 @@ def upgrade() -> None:
5683
)
5784

5885
conn.execute(
59-
sa.text("UPDATE saved_views SET visibility = 'private', location_id = NULL")
86+
sa.text(
87+
"UPDATE saved_views SET visibility = :shared "
88+
"WHERE visibility = :link_shared"
89+
),
90+
{"shared": SHARED, "link_shared": LEGACY_LINK_SHARED},
6091
)
92+
conn.execute(
93+
sa.text(
94+
"UPDATE saved_views SET visibility = :private, location_id = NULL "
95+
"WHERE visibility <> :shared"
96+
),
97+
{"private": PRIVATE, "shared": SHARED},
98+
)
99+
if root_id:
100+
conn.execute(
101+
sa.text(
102+
"UPDATE saved_views SET location_id = :root_id "
103+
"WHERE visibility = :shared AND location_id IS NULL"
104+
),
105+
{"root_id": root_id, "shared": SHARED},
106+
)
61107

62108
with op.batch_alter_table("task_presets") as batch_op:
63109
batch_op.add_column(
64110
sa.Column(
65111
"visibility",
66112
sa.String(length=16),
67113
nullable=False,
68-
server_default="private",
114+
server_default=PRIVATE,
69115
)
70116
)
71117
batch_op.add_column(sa.Column("location_id", sa.String(), nullable=True))
@@ -75,14 +121,10 @@ def upgrade() -> None:
75121
["location_id"],
76122
["id"],
77123
)
78-
if root_id:
79-
conn.execute(
80-
sa.text(
81-
"UPDATE task_presets SET visibility = 'public', location_id = :root_id "
82-
"WHERE owner_user_id IS NULL"
83-
),
84-
{"root_id": root_id},
85-
)
124+
conn.execute(
125+
sa.text("UPDATE task_presets SET visibility = :private, location_id = NULL"),
126+
{"private": PRIVATE},
127+
)
86128
with op.batch_alter_table("task_presets") as batch_op:
87129
batch_op.drop_column("scope")
88130

@@ -100,13 +142,22 @@ def downgrade() -> None:
100142
)
101143
)
102144
conn.execute(
103-
sa.text("UPDATE task_presets SET scope = 'GLOBAL' WHERE visibility = 'public'")
145+
sa.text("UPDATE task_presets SET scope = 'GLOBAL' WHERE visibility = :public"),
146+
{"public": PUBLIC},
104147
)
105148
with op.batch_alter_table("task_presets") as batch_op:
106149
batch_op.drop_constraint("fk_task_presets_location_id", type_="foreignkey")
107150
batch_op.drop_column("location_id")
108151
batch_op.drop_column("visibility")
109152

153+
conn.execute(
154+
sa.text(
155+
"UPDATE saved_views SET visibility = :link_shared "
156+
"WHERE visibility IN (:shared, :public)"
157+
),
158+
{"link_shared": LEGACY_LINK_SHARED, "shared": SHARED, "public": PUBLIC},
159+
)
160+
110161
with op.batch_alter_table("property_definitions") as batch_op:
111162
batch_op.drop_constraint(
112163
"fk_property_definitions_owner_user_id", type_="foreignkey"

‎backend/schema.graphql‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,7 @@ enum SavedViewEntityType {
403403

404404
enum ScopeVisibility {
405405
PRIVATE
406+
SHARED
406407
PUBLIC
407408
}
408409

‎backend/tests/integration/test_authorization_scoping.py‎

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,108 @@ async def test_private_view_is_owner_only_and_needs_no_location(
423423
)
424424

425425

426+
@pytest.mark.asyncio
427+
async def test_shared_view_is_reachable_by_link_but_not_listed(
428+
two_tenants, db_session
429+
):
430+
info1 = MockInfo(db_session, two_tenants["user1"])
431+
info2 = MockInfo(db_session, two_tenants["user2"])
432+
info3 = MockInfo(db_session, two_tenants["user3"])
433+
434+
view = await SavedViewMutation().create_saved_view(
435+
info1,
436+
CreateSavedViewInput(
437+
name="Shared by link",
438+
base_entity_type=SavedViewEntityType.PATIENT,
439+
filter_definition="{}",
440+
sort_definition="{}",
441+
parameters="{}",
442+
visibility=ScopeVisibility.SHARED,
443+
location_id="loc-a",
444+
),
445+
)
446+
assert view.visibility == ScopeVisibility.SHARED
447+
assert view.location_id == "loc-a"
448+
assert view.id in [v.id for v in await SavedViewQuery().my_saved_views(info1)]
449+
assert view.id not in [v.id for v in await SavedViewQuery().my_saved_views(info3)]
450+
assert (await SavedViewQuery().saved_view(info3, view.id)) is not None
451+
with pytest.raises(GraphQLError):
452+
await SavedViewQuery().saved_view(info2, view.id)
453+
454+
copy = await SavedViewMutation().duplicate_saved_view(info3, view.id, "Copy")
455+
assert copy.visibility == ScopeVisibility.PRIVATE
456+
assert copy.owner_user_id == "user-3"
457+
458+
459+
@pytest.mark.asyncio
460+
async def test_shared_view_defaults_into_scope_and_can_be_updated(
461+
two_tenants, db_session
462+
):
463+
info1 = MockInfo(db_session, two_tenants["user1"])
464+
info3 = MockInfo(db_session, two_tenants["user3"])
465+
466+
view = await SavedViewMutation().create_saved_view(
467+
info1,
468+
CreateSavedViewInput(
469+
name="Shared",
470+
base_entity_type=SavedViewEntityType.TASK,
471+
filter_definition="{}",
472+
sort_definition="{}",
473+
parameters="{}",
474+
visibility=ScopeVisibility.SHARED,
475+
),
476+
)
477+
assert view.location_id == "loc-a"
478+
479+
listed = await SavedViewMutation().update_saved_view(
480+
info1, view.id, UpdateSavedViewInput(visibility=ScopeVisibility.PUBLIC)
481+
)
482+
assert listed.visibility == ScopeVisibility.PUBLIC
483+
assert listed.location_id == "loc-a"
484+
assert view.id in [v.id for v in await SavedViewQuery().my_saved_views(info3)]
485+
486+
unlisted = await SavedViewMutation().update_saved_view(
487+
info1, view.id, UpdateSavedViewInput(visibility=ScopeVisibility.SHARED)
488+
)
489+
assert unlisted.visibility == ScopeVisibility.SHARED
490+
assert unlisted.location_id == "loc-a"
491+
assert view.id not in [v.id for v in await SavedViewQuery().my_saved_views(info3)]
492+
assert (await SavedViewQuery().saved_view(info3, view.id)) is not None
493+
494+
495+
@pytest.mark.asyncio
496+
async def test_shared_visibility_is_rejected_for_presets_and_definitions(
497+
two_tenants, db_session
498+
):
499+
info1 = MockInfo(db_session, two_tenants["user1"])
500+
501+
with pytest.raises(GraphQLError):
502+
await TaskPresetMutation().create_task_preset(
503+
info1,
504+
_preset_input("Shared", visibility=ScopeVisibility.SHARED, location_id="loc-a"),
505+
)
506+
with pytest.raises(GraphQLError):
507+
await PropertyDefinitionMutation().create_property_definition(
508+
info1,
509+
CreatePropertyDefinitionInput(
510+
name="Shared",
511+
field_type=FieldType.FIELD_TYPE_TEXT,
512+
allowed_entities=[PropertyEntity.PATIENT],
513+
visibility=ScopeVisibility.SHARED,
514+
location_id="loc-a",
515+
),
516+
)
517+
518+
preset = await TaskPresetMutation().create_task_preset(
519+
info1, _preset_input("Private")
520+
)
521+
with pytest.raises(GraphQLError):
522+
await TaskPresetMutation().update_task_preset(
523+
info1, preset.id, UpdateTaskPresetInput(visibility=ScopeVisibility.SHARED)
524+
)
525+
assert preset.visibility == ScopeVisibility.PRIVATE
526+
527+
426528
def _preset_input(name: str, **kwargs) -> CreateTaskPresetInput:
427529
return CreateTaskPresetInput(
428530
name=name,

‎docs/VIEWS_ARCHITECTURE.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ A **SavedView** stores a named configuration for list screens:
1010
| `sortDefinition` | JSON string: TanStack `SortingState` array. |
1111
| `parameters` | JSON string: **scope** and cross-entity context — `rootLocationIds`, `locationId`, `searchQuery` (patient), `assigneeId` (task / my tasks). |
1212
| `baseEntityType` | `PATIENT` or `TASK` — primary tab when opening `/view/:uid`. |
13-
| `visibility` | `PRIVATE` (owner only, no location needed) or `PUBLIC` (stored at a scaffold node via `locationId`). |
14-
| `locationId` | Scaffold node a `PUBLIC` view is stored at. Everyone who can reach that node sees the view: users whose selected root location lies on the node's path (ancestor or descendant). |
13+
| `visibility` | `PRIVATE` (owner only, no location needed), `SHARED` (reachable by link at a scaffold node via `locationId`, listed only for the owner) or `PUBLIC` (stored at a scaffold node via `locationId`, listed for everyone in scope). |
14+
| `locationId` | Scaffold node a `SHARED` or `PUBLIC` view is stored at. Everyone who can reach that node sees the view: users whose selected root location lies on the node's path (ancestor or descendant). |
1515

1616
Location is **not** a separate route anymore for saved views: it is encoded in `parameters` (`rootLocationIds`, `locationId`).
1717

@@ -21,10 +21,11 @@ Saved views, task presets and property definitions share the same scoping model
2121

2222
- **Private** (default): only the owner sees the entry. No scaffold node is required.
2323
- **Public**: the entry is stored at a scaffold node (`locationId`). It is visible to everyone who can access that node and whose selected root location lies on the node's path, i.e. the node itself, its subtree and its ancestors. Storing an entry at the root makes it visible to everyone.
24+
- **Shared** (saved views only): the view is stored at a scaffold node like a public one, but it is only listed for its owner. Everyone in the node's scope can open it via its link (`savedView(id)`), which mirrors the former `LINK_SHARED` mode.
2425

2526
List queries (`mySavedViews`, `taskPresets`, `propertyDefinitions`) accept `rootLocationIds`; the web client passes the currently selected root locations so lists follow the app node selection. Editing stays with the owner (views, presets) or with users who can access the node (property definitions).
2627

27-
Migration `add_scope_visibility` makes existing property definitions public on the root node and turns existing saved views and presets private.
28+
Migration `add_scope_visibility` makes existing property definitions public on the root node, keeps formerly link-shared saved views reachable by link (`SHARED`, stored at their node or the root) while other views become private, and turns existing presets private.
2829

2930
## Cross-entity model
3031

‎web/api/gql/types.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -769,7 +769,8 @@ export enum SavedViewEntityType {
769769

770770
export enum ScopeVisibility {
771771
Private = 'PRIVATE',
772-
Public = 'PUBLIC'
772+
Public = 'PUBLIC',
773+
Shared = 'SHARED'
773774
}
774775

775776
export type ScopedPatientCountsType = {

0 commit comments

Comments
 (0)