From 1fd6430daeb1c897a7313497f41a4bea86c7b826 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Wed, 3 Sep 2025 10:52:31 +1000 Subject: [PATCH 1/7] Schema for returning Auth0 user data + membership info --- schemas/biocommons.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/schemas/biocommons.py b/schemas/biocommons.py index 405ec3c0..35204d7f 100644 --- a/schemas/biocommons.py +++ b/schemas/biocommons.py @@ -18,7 +18,9 @@ ) from pydantic_core import PydanticCustomError +import db import schemas +from db.types import GroupMembershipData, PlatformMembershipData from schemas import Resource, Service from schemas.service import Group, Identity @@ -289,3 +291,21 @@ def pending_resources(self) -> List[Resource]: def approved_resources(self) -> List[Resource]: """Get all resources with approved status across all services.""" return self.app_metadata.get_approved_resources() + + +class Auth0UserDataWithMemberships(Auth0UserData): + """ + User data from Auth0, plus group and platform membership data from our + database + """ + platform_memberships: list[PlatformMembershipData] = Field(default_factory=list) + group_memberships: list[GroupMembershipData] = Field(default_factory=list) + + @classmethod + def from_auth0_data(cls, auth0_data: Auth0UserData, db_data: 'db.models.BiocommonsUser') -> Self: + """ + Create from Auth0 user data and DB user data. + """ + platforms = [platform.get_data() for platform in db_data.platform_memberships] + groups = [group.get_data() for group in db_data.group_memberships] + return cls(**auth0_data.model_dump(), platform_memberships=platforms, group_memberships=groups) From 4f75e4a75b83d2e129e18a8bac99ced34b33db22 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Wed, 3 Sep 2025 10:54:38 +1000 Subject: [PATCH 2/7] Move enums/data models to a separate file to avoid circular import issues --- db/types.py | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 db/types.py diff --git a/db/types.py b/db/types.py new file mode 100644 index 00000000..a1ecd28d --- /dev/null +++ b/db/types.py @@ -0,0 +1,39 @@ +""" +Shared types/enums used by the database. + +Useful to keep them here rather than in models.py to +avoid circular imports. +""" +import uuid +from enum import Enum + +from pydantic import BaseModel + + +class ApprovalStatusEnum(str, Enum): + APPROVED = "approved" + PENDING = "pending" + REVOKED = "revoked" + + +class PlatformEnum(str, Enum): + GALAXY = "galaxy" + BPA_DATA_PORTAL = "bpa_data_portal" + + +class PlatformMembershipData(BaseModel): + """Data model for platform membership, when returned from the API""" + id: uuid.UUID + platform_id: PlatformEnum + user_id: str + approval_status: ApprovalStatusEnum + updated_by: str + + +class GroupMembershipData(BaseModel): + """Data model for group membership, when returned from the API""" + id: uuid.UUID + group_id: str + group_name: str + approval_status: ApprovalStatusEnum + updated_by: str From 92d32eddee11c8587fdf2928b4495a2de94f48a7 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Wed, 3 Sep 2025 10:57:42 +1000 Subject: [PATCH 3/7] Methods to convert db models to API data --- db/models.py | 54 ++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 40 insertions(+), 14 deletions(-) diff --git a/db/models.py b/db/models.py index 965c9cb0..1bf78c04 100644 --- a/db/models.py +++ b/db/models.py @@ -1,6 +1,5 @@ import uuid from datetime import datetime, timezone -from enum import Enum from typing import Self from pydantic import AwareDatetime @@ -8,23 +7,18 @@ from sqlmodel import DateTime, Field, Relationship, Session, select from sqlmodel import Enum as DbEnum +import schemas from auth0.client import Auth0Client from db.core import BaseModel -from schemas.biocommons import Auth0UserData +from db.types import ( + ApprovalStatusEnum, + GroupMembershipData, + PlatformEnum, + PlatformMembershipData, +) from schemas.user import SessionUser -class ApprovalStatusEnum(str, Enum): - APPROVED = "approved" - PENDING = "pending" - REVOKED = "revoked" - - -class PlatformEnum(str, Enum): - GALAXY = "galaxy" - BPA_DATA_PORTAL = "bpa_data_portal" - - class BiocommonsUser(BaseModel, table=True): __tablename__ = "biocommons_user" # Auth0 ID @@ -55,7 +49,7 @@ def create_from_auth0(cls, auth0_id: str, auth0_client: Auth0Client) -> Self: return cls.from_auth0_data(user_data) @classmethod - def from_auth0_data(cls, data: Auth0UserData) -> Self: + def from_auth0_data(cls, data: 'schemas.biocommons.Auth0UserData') -> Self: """ Create a new BiocommonsUser object from Auth0 user data (no API call). """ @@ -147,6 +141,22 @@ def save_history(self, session: Session) -> "PlatformMembershipHistory": session.add(history) return history + def get_data(self) -> PlatformMembershipData: + """ + Get a data model for this membership, suitable for returning to the frontend. + """ + if self.updated_by is not None: + updated_by = self.updated_by.email + else: + updated_by = '(automatic)' + return PlatformMembershipData( + id=self.id, + platform_id=self.platform_id, + user_id=self.user_id, + approval_status=self.approval_status, + updated_by=updated_by, + ) + class PlatformMembershipHistory(BaseModel, table=True): id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) @@ -253,6 +263,22 @@ def grant_auth0_role(self, auth0_client: Auth0Client): auth0_client.add_roles_to_user(user_id=self.user_id, role_id=role.id) return True + def get_data(self) -> GroupMembershipData: + """ + Get a data model for this membership, suitable for returning to the frontend. + """ + if self.updated_by is not None: + updated_by = self.updated_by.email + else: + updated_by = '(automatic)' + return GroupMembershipData( + id=self.id, + group_id=self.group_id, + group_name=self.group.name, + approval_status=self.approval_status, + updated_by=updated_by, + ) + class GroupMembershipHistory(BaseModel, table=True): """ From 7a1cf19eeb7818cb12fb12a1fa2b845ef2b672a5 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Wed, 3 Sep 2025 10:58:03 +1000 Subject: [PATCH 4/7] Get user details endpoint --- routers/admin.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/routers/admin.py b/routers/admin.py index f5357887..efb9864d 100644 --- a/routers/admin.py +++ b/routers/admin.py @@ -5,11 +5,13 @@ from fastapi import APIRouter, Depends, HTTPException, Path from fastapi.params import Query from pydantic import BaseModel, ValidationError +from sqlmodel import Session from auth.validator import get_current_user, user_is_admin from auth0.client import Auth0Client, get_auth0_client +from db.setup import get_db_session from routers.user import update_user_metadata -from schemas.biocommons import Auth0UserData +from schemas.biocommons import Auth0UserData, Auth0UserDataWithMemberships from schemas.user import SessionUser logger = logging.getLogger('uvicorn.error') @@ -83,6 +85,25 @@ def get_user(user_id: Annotated[str, UserIdParam], return client.get_user(user_id) +@router.get("/users/{user_id}/details", + response_model=Auth0UserDataWithMemberships) +def get_user_details(user_id: Annotated[str, UserIdParam], + client: Annotated[Auth0Client, Depends(get_auth0_client)], + db_session: Annotated[Session, Depends(get_db_session)]): + """ + Get user data from Auth0, along with group and platform membership information + from our user DB. + """ + user = client.get_user(user_id) + from db.models import BiocommonsUser + db_user = db_session.get_one(BiocommonsUser, user_id) + details = Auth0UserDataWithMemberships.from_auth0_data( + auth0_data=user, + db_data=db_user, + ) + return details + + @router.post("/users/{user_id}/verification-email/resend") def resend_verification_email(user_id: Annotated[str, UserIdParam], client: Annotated[Auth0Client, Depends(get_auth0_client)]): From 7ea050c9e9cc812df79242b15c278ba43b8ee422 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Wed, 3 Sep 2025 13:32:42 +1000 Subject: [PATCH 5/7] Factory to create PlatformMembership --- tests/conftest.py | 2 ++ tests/db/datagen.py | 12 +++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 92812692..763ea289 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -23,6 +23,7 @@ BiocommonsGroupFactory, BiocommonsUserFactory, GroupMembershipFactory, + PlatformMembershipFactory, ) @@ -248,6 +249,7 @@ def persistent_factories(test_db_session): BiocommonsGroupFactory, BiocommonsUserFactory, GroupMembershipFactory, + PlatformMembershipFactory, ] for factory in factories: factory.__session__ = test_db_session diff --git a/tests/db/datagen.py b/tests/db/datagen.py index 389f161f..37ced59a 100644 --- a/tests/db/datagen.py +++ b/tests/db/datagen.py @@ -1,6 +1,12 @@ from polyfactory.factories.sqlalchemy_factory import SQLAlchemyFactory -from db.models import Auth0Role, BiocommonsGroup, BiocommonsUser, GroupMembership +from db.models import ( + Auth0Role, + BiocommonsGroup, + BiocommonsUser, + GroupMembership, + PlatformMembership, +) from tests.datagen import random_auth0_id @@ -22,3 +28,7 @@ class BiocommonsGroupFactory(SQLAlchemyFactory[BiocommonsGroup]): class GroupMembershipFactory(SQLAlchemyFactory[GroupMembership]): __set_relationships__ = True + + +class PlatformMembershipFactory(SQLAlchemyFactory[PlatformMembership]): + __set_relationships__ = True From fe09c316a9c5698adb1077a318f25e4d6c95a2a3 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Wed, 3 Sep 2025 13:40:46 +1000 Subject: [PATCH 6/7] Test getting user details --- tests/test_admin.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/test_admin.py b/tests/test_admin.py index 488a3387..ca9e1e1d 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -17,6 +17,12 @@ EmailVerificationResponseFactory, SessionUserFactory, ) +from tests.db.datagen import ( + BiocommonsGroupFactory, + BiocommonsUserFactory, + GroupMembershipFactory, + PlatformMembershipFactory, +) FROZEN_TIME = datetime(2025, 1, 1, 12, 0, 0) @@ -298,3 +304,23 @@ def test_resend_verification_email(test_client, as_admin_user, mock_auth0_client resp = test_client.post(f"/admin/users/{user.user_id}/verification-email/resend") assert resp.status_code == 200 assert resp.json() == {"message": "Verification email resent."} + + +def test_get_user_details(test_client, test_db_session, as_admin_user, mock_auth0_client, persistent_factories): + user = Auth0UserDataFactory.build() + group = BiocommonsGroupFactory.create_sync(group_id="biocommons/group/tsi") + db_user = BiocommonsUserFactory.create_sync(id=user.user_id, group_memberships=[], platform_memberships=[]) + group_membership = GroupMembershipFactory.create_sync(group=group, user=db_user, approval_status="approved") + platform_membership = PlatformMembershipFactory.create_sync(user=db_user, platform_id="galaxy") + mock_auth0_client.get_user.return_value = user + test_db_session.commit() + resp = test_client.get(f"/admin/users/{user.user_id}/details") + assert resp.status_code == 200 + data = resp.json() + assert data["email"] == user.email + groups = data["group_memberships"] + group_membership_data = group_membership.get_data().model_dump(mode="json") + assert groups[0] == group_membership_data + platforms = data["platform_memberships"] + platform_membership_data = platform_membership.get_data().model_dump(mode="json") + assert platforms[0] == platform_membership_data From d1d9ab17973c55153b2112105c455c6c0446317c Mon Sep 17 00:00:00 2001 From: marius-mather Date: Wed, 3 Sep 2025 14:13:49 +1000 Subject: [PATCH 7/7] Move GroupEnum to types.py --- db/models.py | 7 ------- db/types.py | 6 ++++++ routers/admin.py | 2 +- routers/biocommons_register.py | 3 ++- tests/test_admin.py | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/db/models.py b/db/models.py index 7dc5f27a..1bf78c04 100644 --- a/db/models.py +++ b/db/models.py @@ -1,6 +1,5 @@ import uuid from datetime import datetime, timezone -from enum import Enum from typing import Self from pydantic import AwareDatetime @@ -20,12 +19,6 @@ from schemas.user import SessionUser -# Not used for Groups in the database yet -class GroupEnum(str, Enum): - TSI = "biocommons/group/tsi" - BPA_GALAXY = "biocommons/group/bpa_galaxy" - - class BiocommonsUser(BaseModel, table=True): __tablename__ = "biocommons_user" # Auth0 ID diff --git a/db/types.py b/db/types.py index a1ecd28d..927a0975 100644 --- a/db/types.py +++ b/db/types.py @@ -37,3 +37,9 @@ class GroupMembershipData(BaseModel): group_name: str approval_status: ApprovalStatusEnum updated_by: str + + +# Not used for Groups in the database yet +class GroupEnum(str, Enum): + TSI = "biocommons/group/tsi" + BPA_GALAXY = "biocommons/group/bpa_galaxy" diff --git a/routers/admin.py b/routers/admin.py index 4aaf8738..7c88db44 100644 --- a/routers/admin.py +++ b/routers/admin.py @@ -13,12 +13,12 @@ from db.models import ( BiocommonsGroup, BiocommonsUser, - GroupEnum, GroupMembership, PlatformEnum, PlatformMembership, ) from db.setup import get_db_session +from db.types import GroupEnum from routers.user import update_user_metadata from schemas.biocommons import Auth0UserData, Auth0UserDataWithMemberships from schemas.user import SessionUser diff --git a/routers/biocommons_register.py b/routers/biocommons_register.py index e633e5c5..2a819bdd 100644 --- a/routers/biocommons_register.py +++ b/routers/biocommons_register.py @@ -8,8 +8,9 @@ from auth.ses import EmailService from auth0.client import Auth0Client, get_auth0_client from config import Settings, get_settings -from db.models import BiocommonsGroup, BiocommonsUser, GroupEnum, PlatformEnum +from db.models import BiocommonsGroup, BiocommonsUser, PlatformEnum from db.setup import get_db_session +from db.types import GroupEnum from routers.errors import RegistrationRoute from schemas.biocommons import Auth0UserData, BiocommonsRegisterData from schemas.biocommons_register import BiocommonsRegistrationRequest, BundleType diff --git a/tests/test_admin.py b/tests/test_admin.py index ba568d9b..865e5fdb 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -138,9 +138,9 @@ def test_get_users_filter_by_group(test_client, as_admin_user, test_db_session): from db.models import ( ApprovalStatusEnum, BiocommonsGroup, - GroupEnum, GroupMembership, ) + from db.types import GroupEnum from tests.db.datagen import BiocommonsUserFactory tsi_group = BiocommonsGroup(