Skip to content

Commit 1601010

Browse files
authored
Scope resolvers (#37)
* scope resolvers
1 parent 41c8705 commit 1601010

42 files changed

Lines changed: 2232 additions & 262 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/tests.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ jobs:
2727
working-directory: backend
2828
run: |
2929
pip install ruff
30-
ruff check . --output-format=concise
30+
ruff check . --output-format=concise --exclude database/migrations
3131
3232
simulator-lint:
3333
runs-on: ubuntu-latest

‎backend/api/context.py‎

Lines changed: 186 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,64 @@
1+
import asyncio
2+
import logging
13
from typing import Any
24

35
import strawberry
46
from auth import get_user_payload
5-
from database.models.user import User
7+
from database.models.location import LocationNode, location_organizations
8+
from database.models.user import User, user_root_locations
69
from database.session import get_db_session
710
from fastapi import Depends
8-
from sqlalchemy import select
11+
from graphql import GraphQLError
12+
from sqlalchemy import delete, select
13+
from sqlalchemy.dialects.postgresql import insert
914
from sqlalchemy.exc import IntegrityError
1015
from sqlalchemy.ext.asyncio import AsyncSession
1116
from starlette.requests import HTTPConnection
1217
from strawberry.fastapi import BaseContext
1318

1419

20+
class LockedAsyncSession:
21+
def __init__(self, session: AsyncSession, lock: asyncio.Lock):
22+
self._session = session
23+
self._lock = lock
24+
25+
async def execute(self, *args, **kwargs):
26+
async with self._lock:
27+
return await self._session.execute(*args, **kwargs)
28+
29+
async def commit(self, *args, **kwargs):
30+
async with self._lock:
31+
return await self._session.commit(*args, **kwargs)
32+
33+
async def rollback(self, *args, **kwargs):
34+
async with self._lock:
35+
return await self._session.rollback(*args, **kwargs)
36+
37+
async def flush(self, *args, **kwargs):
38+
async with self._lock:
39+
return await self._session.flush(*args, **kwargs)
40+
41+
async def refresh(self, *args, **kwargs):
42+
async with self._lock:
43+
return await self._session.refresh(*args, **kwargs)
44+
45+
def add(self, *args, **kwargs):
46+
return self._session.add(*args, **kwargs)
47+
48+
def __getattr__(self, name):
49+
return getattr(self._session, name)
50+
51+
1552
class Context(BaseContext):
16-
def __init__(self, db: AsyncSession, user: "User | None" = None):
53+
def __init__(self, db: AsyncSession, user: "User | None" = None, organizations: str | None = None):
1754
super().__init__()
18-
self.db = db
55+
self._db = db
1956
self.user = user
57+
self.organizations = organizations
58+
self._accessible_location_ids: set[str] | None = None
59+
self._accessible_location_ids_lock = asyncio.Lock()
60+
self._db_lock = asyncio.Lock()
61+
self.db = LockedAsyncSession(db, self._db_lock)
2062

2163

2264
Info = strawberry.Info[Context, Any]
@@ -28,6 +70,7 @@ async def get_context(
2870
) -> Context:
2971
user_payload = get_user_payload(connection)
3072
db_user = None
73+
organizations = None
3174

3275
if user_payload:
3376
user_id = user_payload.get("sub")
@@ -39,13 +82,27 @@ async def get_context(
3982
email = user_payload.get("email")
4083
picture = user_payload.get("picture")
4184

85+
logger = logging.getLogger(__name__)
4286
organizations_raw = user_payload.get("organization")
87+
88+
if organizations_raw is None:
89+
scope = user_payload.get("scope", "")
90+
has_org_scope = "organization" in scope.split() if scope else False
91+
logger.warning(
92+
f"Organization claim not found in token for user {user_payload.get('sub', 'unknown')}. "
93+
f"Has organization scope: {has_org_scope}. "
94+
f"Token scope: {scope}. "
95+
f"Available claims: {sorted(user_payload.keys())}"
96+
)
97+
4398
organizations = None
4499
if organizations_raw:
45100
if isinstance(organizations_raw, list):
46-
organizations = ",".join(str(org) for org in organizations_raw)
101+
org_list = [str(org) for org in organizations_raw if org]
102+
if org_list:
103+
organizations = ",".join(org_list)
47104
else:
48-
organizations = str(organizations_raw)
105+
organizations = str(organizations_raw) if organizations_raw else None
49106

50107
if user_id:
51108
result = await session.execute(
@@ -63,7 +120,6 @@ async def get_context(
63120
lastname=lastname,
64121
title="User",
65122
avatar_url=picture,
66-
organizations=organizations,
67123
)
68124
session.add(new_user)
69125
await session.commit()
@@ -75,24 +131,144 @@ async def get_context(
75131
select(User).where(User.id == user_id),
76132
)
77133
db_user = result.scalars().first()
134+
except Exception as e:
135+
await session.rollback()
136+
raise GraphQLError(
137+
"Failed to create user. Please contact an administrator if you believe this is an error.",
138+
extensions={"code": "INTERNAL_SERVER_ERROR"},
139+
) from e
78140

79141
if db_user and (
80142
db_user.username != username
81143
or db_user.firstname != firstname
82144
or db_user.lastname != lastname
83145
or db_user.email != email
84146
or db_user.avatar_url != picture
85-
or db_user.organizations != organizations
86147
):
87148
db_user.username = username
88149
db_user.firstname = firstname
89150
db_user.lastname = lastname
90151
db_user.email = email
91152
if picture:
92153
db_user.avatar_url = picture
93-
db_user.organizations = organizations
94154
session.add(db_user)
95155
await session.commit()
96156
await session.refresh(db_user)
97157

98-
return Context(db=session, user=db_user)
158+
if db_user:
159+
try:
160+
161+
await _update_user_root_locations(
162+
session,
163+
db_user,
164+
organizations,
165+
)
166+
except Exception as e:
167+
raise GraphQLError(
168+
"Failed to update user root locations. Please contact an administrator if you believe this is an error.",
169+
extensions={"code": "INTERNAL_SERVER_ERROR"},
170+
) from e
171+
172+
return Context(db=session, user=db_user, organizations=organizations)
173+
174+
175+
async def _update_user_root_locations(
176+
session: AsyncSession,
177+
user: User,
178+
organizations: str | None,
179+
) -> None:
180+
organization_ids: list[str] = []
181+
if organizations:
182+
organization_ids = [
183+
org_id.strip()
184+
for org_id in organizations.split(",")
185+
if org_id.strip()
186+
]
187+
188+
root_location_ids: list[str] = []
189+
190+
if organization_ids:
191+
result = await session.execute(
192+
select(LocationNode)
193+
.join(
194+
location_organizations,
195+
LocationNode.id == location_organizations.c.location_id,
196+
)
197+
.where(
198+
location_organizations.c.organization_id.in_(organization_ids),
199+
),
200+
)
201+
found_locations = result.scalars().all()
202+
root_location_ids = [loc.id for loc in found_locations]
203+
204+
found_org_ids = set()
205+
for loc in found_locations:
206+
org_result = await session.execute(
207+
select(location_organizations.c.organization_id).where(
208+
location_organizations.c.location_id == loc.id,
209+
location_organizations.c.organization_id.in_(
210+
organization_ids,
211+
),
212+
),
213+
)
214+
found_org_ids.update(row[0] for row in org_result.all())
215+
216+
for org_id in organization_ids:
217+
if org_id not in found_org_ids:
218+
new_location = LocationNode(
219+
title=f"Organization {org_id[:8]}",
220+
kind="CLINIC",
221+
parent_id=None,
222+
)
223+
session.add(new_location)
224+
await session.flush()
225+
await session.refresh(new_location)
226+
227+
await session.execute(
228+
location_organizations.insert().values(
229+
location_id=new_location.id,
230+
organization_id=org_id,
231+
),
232+
)
233+
root_location_ids.append(new_location.id)
234+
235+
if not root_location_ids:
236+
personal_org_title = f"{user.username}'s Organization"
237+
result = await session.execute(
238+
select(LocationNode).where(
239+
LocationNode.title == personal_org_title,
240+
LocationNode.parent_id.is_(None),
241+
),
242+
)
243+
personal_location = result.scalars().first()
244+
245+
if not personal_location:
246+
personal_location = LocationNode(
247+
title=personal_org_title,
248+
kind="CLINIC",
249+
parent_id=None,
250+
)
251+
session.add(personal_location)
252+
await session.flush()
253+
254+
root_location_ids = [personal_location.id]
255+
256+
await session.execute(
257+
delete(user_root_locations).where(
258+
user_root_locations.c.user_id == user.id,
259+
),
260+
)
261+
262+
if root_location_ids:
263+
stmt = insert(user_root_locations).values(
264+
[
265+
{"user_id": user.id, "location_id": loc_id}
266+
for loc_id in root_location_ids
267+
],
268+
)
269+
stmt = stmt.on_conflict_do_nothing(
270+
index_elements=["user_id", "location_id"],
271+
)
272+
await session.execute(stmt)
273+
274+
await session.commit()

‎backend/api/inputs.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,12 @@ class CreatePatientInput:
6969
sex: Sex
7070
assigned_location_id: strawberry.ID | None = None
7171
assigned_location_ids: list[strawberry.ID] | None = None
72-
clinic_id: strawberry.ID # Required: location node from kind CLINIC
72+
clinic_id: strawberry.ID
7373
position_id: strawberry.ID | None = (
74-
None # Optional: location node from type hospital, practice, clinic, ward, bed or room
74+
None
7575
)
7676
team_ids: list[strawberry.ID] | None = (
77-
None # Array: location nodes from type clinic, team, practice, hospital
77+
None
7878
)
7979
properties: list[PropertyValueInput] | None = None
8080
state: PatientState | None = None

‎backend/api/resolvers/__init__.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import strawberry
22

3-
from .location import LocationQuery
3+
from .location import LocationMutation, LocationQuery, LocationSubscription
44
from .patient import PatientMutation, PatientQuery, PatientSubscription
55
from .property import PropertyDefinitionMutation, PropertyDefinitionQuery
66
from .task import TaskMutation, TaskQuery, TaskSubscription
@@ -23,10 +23,11 @@ class Mutation(
2323
PatientMutation,
2424
TaskMutation,
2525
PropertyDefinitionMutation,
26+
LocationMutation,
2627
):
2728
pass
2829

2930

3031
@strawberry.type
31-
class Subscription(PatientSubscription, TaskSubscription):
32+
class Subscription(PatientSubscription, TaskSubscription, LocationSubscription):
3233
pass

0 commit comments

Comments
 (0)