diff --git a/db/models.py b/db/models.py index 1a51769d..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,29 +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" - - -# 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 @@ -61,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). """ @@ -153,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) @@ -259,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): """ diff --git a/db/types.py b/db/types.py new file mode 100644 index 00000000..927a0975 --- /dev/null +++ b/db/types.py @@ -0,0 +1,45 @@ +""" +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 + + +# 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 886d4de9..7c88db44 100644 --- a/routers/admin.py +++ b/routers/admin.py @@ -13,14 +13,14 @@ 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 +from schemas.biocommons import Auth0UserData, Auth0UserDataWithMemberships from schemas.user import SessionUser logger = logging.getLogger('uvicorn.error') @@ -41,6 +41,7 @@ "bpa_galaxy": {"enum": GroupEnum.BPA_GALAXY, "name": "Bioplatforms Australia Data Portal & Galaxy Australia Bundle"}, } + class BiocommonsUserResponse(BaseModel): """ Response schema for BiocommonsUser from the database @@ -187,6 +188,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)]): 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/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) 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 diff --git a/tests/test_admin.py b/tests/test_admin.py index e29c4efc..865e5fdb 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -17,7 +17,12 @@ EmailVerificationResponseFactory, SessionUserFactory, ) -from tests.db.datagen import BiocommonsUserFactory +from tests.db.datagen import ( + BiocommonsGroupFactory, + BiocommonsUserFactory, + GroupMembershipFactory, + PlatformMembershipFactory, +) FROZEN_TIME = datetime(2025, 1, 1, 12, 0, 0) @@ -133,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( @@ -411,6 +416,26 @@ def test_resend_verification_email(test_client, as_admin_user, mock_auth0_client 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 + + def test_get_unverified_users(test_client, as_admin_user, mock_auth0_client): u1 = Auth0UserDataFactory.build(email_verified=False) u2 = Auth0UserDataFactory.build(email_verified=False)