Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 40 additions & 20 deletions db/models.py
Original file line number Diff line number Diff line change
@@ -1,36 +1,24 @@
import uuid
from datetime import datetime, timezone
from enum import Enum
from typing import Self

from pydantic import AwareDatetime
from sqlalchemy import UniqueConstraint
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
Expand Down Expand Up @@ -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).
"""
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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):
"""
Expand Down
45 changes: 45 additions & 0 deletions db/types.py
Original file line number Diff line number Diff line change
@@ -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"
24 changes: 22 additions & 2 deletions routers/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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
Expand Down Expand Up @@ -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)]):
Expand Down
3 changes: 2 additions & 1 deletion routers/biocommons_register.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions schemas/biocommons.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
2 changes: 2 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
BiocommonsGroupFactory,
BiocommonsUserFactory,
GroupMembershipFactory,
PlatformMembershipFactory,
)


Expand Down Expand Up @@ -248,6 +249,7 @@ def persistent_factories(test_db_session):
BiocommonsGroupFactory,
BiocommonsUserFactory,
GroupMembershipFactory,
PlatformMembershipFactory,
]
for factory in factories:
factory.__session__ = test_db_session
Expand Down
12 changes: 11 additions & 1 deletion tests/db/datagen.py
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -22,3 +28,7 @@ class BiocommonsGroupFactory(SQLAlchemyFactory[BiocommonsGroup]):

class GroupMembershipFactory(SQLAlchemyFactory[GroupMembership]):
__set_relationships__ = True


class PlatformMembershipFactory(SQLAlchemyFactory[PlatformMembership]):
__set_relationships__ = True
29 changes: 27 additions & 2 deletions tests/test_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand Down