From d063999346eee302a97fb2e4264873b8172286fe Mon Sep 17 00:00:00 2001 From: Randy Schott <1815175+schottra@users.noreply.github.com> Date: Thu, 25 Apr 2024 12:27:24 -0400 Subject: [PATCH 01/10] adds grants endpoint --- .../discovery-provider/src/api/v1/helpers.py | 9 ++ .../src/api/v1/models/grants.py | 14 +++ .../discovery-provider/src/api/v1/users.py | 33 +++++++ .../src/queries/get_grants.py | 53 +++++++++++ .../src/utils/auth_middleware.py | 19 +++- .../default/.openapi-generator/FILES | 2 + .../api/generated/default/apis/UsersApi.ts | 38 ++++++++ .../sdk/api/generated/default/models/Grant.ts | 94 +++++++++++++++++++ .../api/generated/default/models/Grants.ts | 73 ++++++++++++++ .../sdk/api/generated/default/models/index.ts | 2 + 10 files changed, 334 insertions(+), 3 deletions(-) create mode 100644 packages/discovery-provider/src/api/v1/models/grants.py create mode 100644 packages/discovery-provider/src/queries/get_grants.py create mode 100644 packages/libs/src/sdk/api/generated/default/models/Grant.ts create mode 100644 packages/libs/src/sdk/api/generated/default/models/Grants.ts diff --git a/packages/discovery-provider/src/api/v1/helpers.py b/packages/discovery-provider/src/api/v1/helpers.py index 97bd22b8c14..ebd632dbd44 100644 --- a/packages/discovery-provider/src/api/v1/helpers.py +++ b/packages/discovery-provider/src/api/v1/helpers.py @@ -987,6 +987,15 @@ def format_authorized_app(authorized_app): } +def format_grant(grant): + return { + "grantee_address": grant["grantee_address"], + "user_id": encode_int_id(grant["user_id"]), + "created_at": grant["created_at"], + "updated_at": grant["updated_at"], + } + + def format_dashboard_wallet_user(dashboard_wallet_user): return { "wallet": dashboard_wallet_user["wallet"], diff --git a/packages/discovery-provider/src/api/v1/models/grants.py b/packages/discovery-provider/src/api/v1/models/grants.py new file mode 100644 index 00000000000..f7c78255167 --- /dev/null +++ b/packages/discovery-provider/src/api/v1/models/grants.py @@ -0,0 +1,14 @@ +from flask_restx import fields + +from .common import ns + +# Describes a grant made from user to another user +grant = ns.model( + "grant", + { + "grantee_address": fields.String(required=True), + "user_id": fields.String(required=True), + "created_at": fields.String(required=True), + "updated_at": fields.String(required=True), + }, +) diff --git a/packages/discovery-provider/src/api/v1/users.py b/packages/discovery-provider/src/api/v1/users.py index 8b5915e2c39..43f81462d37 100644 --- a/packages/discovery-provider/src/api/v1/users.py +++ b/packages/discovery-provider/src/api/v1/users.py @@ -11,6 +11,7 @@ abort_bad_request_param, abort_forbidden, abort_not_found, + abort_unauthorized, current_user_parser, decode_with_abort, extend_activity, @@ -24,6 +25,7 @@ format_aggregate_monthly_plays_for_user, format_authorized_app, format_developer_app, + format_grant, format_library_filter, format_limit, format_offset, @@ -55,6 +57,7 @@ ) from src.api.v1.models.common import favorite from src.api.v1.models.developer_apps import authorized_app, developer_app +from src.api.v1.models.grants import grant from src.api.v1.models.support import ( supporter_response, supporter_response_full, @@ -99,6 +102,7 @@ ) from src.queries.get_followees_for_user import get_followees_for_user from src.queries.get_followers_for_user import get_followers_for_user +from src.queries.get_grants import GetGrantsArgs, get_grants from src.queries.get_related_artists import get_related_artists from src.queries.get_repost_feed_for_user import get_repost_feed_for_user from src.queries.get_saves import get_saves @@ -2105,6 +2109,35 @@ def get(self, id): return success_response(authorized_apps) +grants_response = make_response("grants", ns, fields.List(fields.Nested(grant))) + + +@ns.route("/grants") +class GrantsForUser(Resource): + @record_metrics + @ns.doc( + id="""Get User Grants""", + description="""Get grants """, + params={"wallet_address": "Wallet address of the account"}, + responses={ + 200: "Success", + 400: "Bad request", + 401: "Unauthorized", + 500: "Server error", + }, + ) + @auth_middleware(include_wallet=True) + @ns.marshal_with(grants_response) + def get(self, authed_user_id, authed_user_wallet): + if authed_user_wallet is None: + abort_unauthorized(ns) + args = GetGrantsArgs(grantee_address=authed_user_wallet) + grants = get_grants(args) + grants = list(map(format_grant, grants)) + + return success_response(grants) + + purchases_and_sales_parser = pagination_with_current_user_parser.copy() purchases_and_sales_parser.add_argument( "sort_method", diff --git a/packages/discovery-provider/src/queries/get_grants.py b/packages/discovery-provider/src/queries/get_grants.py new file mode 100644 index 00000000000..d521f20ea81 --- /dev/null +++ b/packages/discovery-provider/src/queries/get_grants.py @@ -0,0 +1,53 @@ +import logging +from typing import Dict, List, Optional, TypedDict + +from src.models.grants.grant import Grant +from src.utils import db_session +from src.utils.helpers import query_result_to_list + +logger = logging.getLogger(__name__) + + +class GetGrantsArgs(TypedDict): + user_id: Optional[int] + grantee_address: Optional[str] + is_approved: Optional[bool] + is_revoked: Optional[bool] + + +def get_grants(args: GetGrantsArgs) -> List[Dict]: + """ + Returns grants based on one or more provided filters. Must provide either + user_id or grantee_address. + + Args: + grantee_address: Optional[str] address of grantee + user_id: Optional[int] user id of grantor + is_approved: Optional[bool] whether grant is approved, defaults to True + is_revoked: Optional[bool] whether grant is revoked, defaults to False + + Returns: + List of grants + """ + is_approved = args.get("is_approved", True) + is_revoked = args.get("is_revoked", False) + user_id = args.get("user_id") + grantee_address = args.get("grantee_address") + if user_id is None and grantee_address is None: + raise ValueError("Must provide user_id or grantee_address") + + db = db_session.get_db_read_replica() + with db.scoped_session() as session: + base_query = session.query(Grant) + + if grantee_address: + base_query = base_query.filter(Grant.grantee_address == grantee_address) + if user_id: + base_query = base_query.filter(Grant.user_id == user_id) + base_query.filter( + Grant.is_current == True, + Grant.is_revoked == is_revoked, + Grant.is_approved == is_approved, + ) + grants = base_query.all() + return query_result_to_list(grants) diff --git a/packages/discovery-provider/src/utils/auth_middleware.py b/packages/discovery-provider/src/utils/auth_middleware.py index 0231ba99f38..0c61f88b58e 100644 --- a/packages/discovery-provider/src/utils/auth_middleware.py +++ b/packages/discovery-provider/src/utils/auth_middleware.py @@ -14,7 +14,9 @@ SIGNATURE_HEADER = "Encoded-Data-Signature" -def auth_middleware(parser: reqparse.RequestParser = None): +def auth_middleware( + parser: reqparse.RequestParser = None, include_wallet: bool = False +): """ Auth middleware decorator. @@ -60,6 +62,7 @@ def decorator(func): def wrapper(*args, **kwargs): message = request.headers.get(MESSAGE_HEADER) signature = request.headers.get(SIGNATURE_HEADER) + wallet_lower = None authed_user_id = None if message and signature: @@ -68,13 +71,14 @@ def wrapper(*args, **kwargs): wallet = web3.eth.account.recover_message( encoded_to_recover, signature=signature ) + wallet_lower = wallet.lower() db = db_session.get_db_read_replica() with db.scoped_session() as session: user = ( session.query(User.user_id) .filter( # Convert checksum wallet to lowercase - User.wallet == wallet.lower(), + User.wallet == wallet_lower, User.is_current == True, ) # In the case that multiple wallets match (not enforced on the data layer), @@ -87,7 +91,16 @@ def wrapper(*args, **kwargs): logger.info( f"auth_middleware.py | authed_user_id: {authed_user_id}" ) - return func(*args, **kwargs, authed_user_id=authed_user_id) + return ( + func( + *args, + **kwargs, + authed_user_id=authed_user_id, + authed_user_wallet=wallet_lower, + ) + if include_wallet + else func(*args, **kwargs, authed_user_id=authed_user_id) + ) return wrapper diff --git a/packages/libs/src/sdk/api/generated/default/.openapi-generator/FILES b/packages/libs/src/sdk/api/generated/default/.openapi-generator/FILES index 93ae174f9b8..093566a0080 100644 --- a/packages/libs/src/sdk/api/generated/default/.openapi-generator/FILES +++ b/packages/libs/src/sdk/api/generated/default/.openapi-generator/FILES @@ -29,6 +29,8 @@ models/FollowingResponse.ts models/GetSupporters.ts models/GetSupporting.ts models/GetTipsResponse.ts +models/Grant.ts +models/Grants.ts models/Playlist.ts models/PlaylistAddedTimestamp.ts models/PlaylistArtwork.ts diff --git a/packages/libs/src/sdk/api/generated/default/apis/UsersApi.ts b/packages/libs/src/sdk/api/generated/default/apis/UsersApi.ts index 3bba20c4b3a..53118e65a99 100644 --- a/packages/libs/src/sdk/api/generated/default/apis/UsersApi.ts +++ b/packages/libs/src/sdk/api/generated/default/apis/UsersApi.ts @@ -24,6 +24,7 @@ import type { FollowingResponse, GetSupporters, GetSupporting, + Grants, RelatedArtistResponse, Reposts, SubscribersResponse, @@ -51,6 +52,8 @@ import { GetSupportersToJSON, GetSupportingFromJSON, GetSupportingToJSON, + GrantsFromJSON, + GrantsToJSON, RelatedArtistResponseFromJSON, RelatedArtistResponseToJSON, RepostsFromJSON, @@ -198,6 +201,10 @@ export interface GetUserByHandleRequest { userId?: string; } +export interface GetUserGrantsRequest { + walletAddress?: string; +} + export interface GetUserIDFromWalletRequest { associatedWallet: string; } @@ -1007,6 +1014,37 @@ export class UsersApi extends runtime.BaseAPI { return await response.value(); } + /** + * @hidden + * Get grants + */ + async getUserGrantsRaw(params: GetUserGrantsRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + const queryParameters: any = {}; + + if (params.walletAddress !== undefined) { + queryParameters['wallet_address'] = params.walletAddress; + } + + const headerParameters: runtime.HTTPHeaders = {}; + + const response = await this.request({ + path: `/users/grants`, + method: 'GET', + headers: headerParameters, + query: queryParameters, + }, initOverrides); + + return new runtime.JSONApiResponse(response, (jsonValue) => GrantsFromJSON(jsonValue)); + } + + /** + * Get grants + */ + async getUserGrants(params: GetUserGrantsRequest = {}, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + const response = await this.getUserGrantsRaw(params, initOverrides); + return await response.value(); + } + /** * @hidden * Gets a User ID from an associated wallet address diff --git a/packages/libs/src/sdk/api/generated/default/models/Grant.ts b/packages/libs/src/sdk/api/generated/default/models/Grant.ts new file mode 100644 index 00000000000..821e70c015e --- /dev/null +++ b/packages/libs/src/sdk/api/generated/default/models/Grant.ts @@ -0,0 +1,94 @@ +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck +/** + * API + * Audius V1 API + * + * The version of the OpenAPI document: 1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { exists, mapValues } from '../runtime'; +/** + * + * @export + * @interface Grant + */ +export interface Grant { + /** + * + * @type {string} + * @memberof Grant + */ + granteeAddress: string; + /** + * + * @type {string} + * @memberof Grant + */ + userId: string; + /** + * + * @type {string} + * @memberof Grant + */ + createdAt: string; + /** + * + * @type {string} + * @memberof Grant + */ + updatedAt: string; +} + +/** + * Check if a given object implements the Grant interface. + */ +export function instanceOfGrant(value: object): boolean { + let isInstance = true; + isInstance = isInstance && "granteeAddress" in value; + isInstance = isInstance && "userId" in value; + isInstance = isInstance && "createdAt" in value; + isInstance = isInstance && "updatedAt" in value; + + return isInstance; +} + +export function GrantFromJSON(json: any): Grant { + return GrantFromJSONTyped(json, false); +} + +export function GrantFromJSONTyped(json: any, ignoreDiscriminator: boolean): Grant { + if ((json === undefined) || (json === null)) { + return json; + } + return { + + 'granteeAddress': json['grantee_address'], + 'userId': json['user_id'], + 'createdAt': json['created_at'], + 'updatedAt': json['updated_at'], + }; +} + +export function GrantToJSON(value?: Grant | null): any { + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + return { + + 'grantee_address': value.granteeAddress, + 'user_id': value.userId, + 'created_at': value.createdAt, + 'updated_at': value.updatedAt, + }; +} + diff --git a/packages/libs/src/sdk/api/generated/default/models/Grants.ts b/packages/libs/src/sdk/api/generated/default/models/Grants.ts new file mode 100644 index 00000000000..7b42eed8f32 --- /dev/null +++ b/packages/libs/src/sdk/api/generated/default/models/Grants.ts @@ -0,0 +1,73 @@ +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck +/** + * API + * Audius V1 API + * + * The version of the OpenAPI document: 1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { exists, mapValues } from '../runtime'; +import type { Grant } from './Grant'; +import { + GrantFromJSON, + GrantFromJSONTyped, + GrantToJSON, +} from './Grant'; + +/** + * + * @export + * @interface Grants + */ +export interface Grants { + /** + * + * @type {Array} + * @memberof Grants + */ + data?: Array; +} + +/** + * Check if a given object implements the Grants interface. + */ +export function instanceOfGrants(value: object): boolean { + let isInstance = true; + + return isInstance; +} + +export function GrantsFromJSON(json: any): Grants { + return GrantsFromJSONTyped(json, false); +} + +export function GrantsFromJSONTyped(json: any, ignoreDiscriminator: boolean): Grants { + if ((json === undefined) || (json === null)) { + return json; + } + return { + + 'data': !exists(json, 'data') ? undefined : ((json['data'] as Array).map(GrantFromJSON)), + }; +} + +export function GrantsToJSON(value?: Grants | null): any { + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + return { + + 'data': value.data === undefined ? undefined : ((value.data as Array).map(GrantToJSON)), + }; +} + diff --git a/packages/libs/src/sdk/api/generated/default/models/index.ts b/packages/libs/src/sdk/api/generated/default/models/index.ts index 4f2ce30344a..9f2def3d5ce 100644 --- a/packages/libs/src/sdk/api/generated/default/models/index.ts +++ b/packages/libs/src/sdk/api/generated/default/models/index.ts @@ -22,6 +22,8 @@ export * from './FollowingResponse'; export * from './GetSupporters'; export * from './GetSupporting'; export * from './GetTipsResponse'; +export * from './Grant'; +export * from './Grants'; export * from './Playlist'; export * from './PlaylistAddedTimestamp'; export * from './PlaylistArtwork'; From ebc9e71d5bcf3972a0c40d10005a5f89fbebb7c5 Mon Sep 17 00:00:00 2001 From: Randy Schott <1815175+schottra@users.noreply.github.com> Date: Thu, 25 Apr 2024 17:06:57 -0400 Subject: [PATCH 02/10] update endpoint to be specific to managed users --- .../queries/test_get_managed_users.py | 33 +++++++++ .../discovery-provider/src/api/v1/helpers.py | 9 +++ .../src/api/v1/models/grants.py | 11 +++ .../discovery-provider/src/api/v1/users.py | 45 ++++++++---- .../src/queries/get_grants.py | 53 -------------- .../src/queries/get_managed_users.py | 69 +++++++++++++++++++ 6 files changed, 152 insertions(+), 68 deletions(-) create mode 100644 packages/discovery-provider/integration_tests/queries/test_get_managed_users.py delete mode 100644 packages/discovery-provider/src/queries/get_grants.py create mode 100644 packages/discovery-provider/src/queries/get_managed_users.py diff --git a/packages/discovery-provider/integration_tests/queries/test_get_managed_users.py b/packages/discovery-provider/integration_tests/queries/test_get_managed_users.py new file mode 100644 index 00000000000..174389426d5 --- /dev/null +++ b/packages/discovery-provider/integration_tests/queries/test_get_managed_users.py @@ -0,0 +1,33 @@ +from datetime import datetime + +from integration_tests.utils import populate_mock_db +from src.utils.db_session import get_db + +test_entities = { + "users": [ + {"user_id": 10, "name": "a", "wallet": "0x10"}, + {"user_id": 20, "name": "b", "wallet": "0x20"}, + {"user_id": 30, "name": "c", "wallet": "0x30"}, + ], + "grants": [ + { + "user_id": 10, + "grantee_address": "0x30", + "is_approved": True, + "is_revoked": False, + }, + { + "user_id": 20, + "grantee_address": "0x30", + "is_approved": True, + "is_revoked": False, + }, + ], +} + + +def test_get_managed_users(app): + with app.app_context(): + db = get_db() + populate_mock_db(db, test_entities) + # TODO: Implement test diff --git a/packages/discovery-provider/src/api/v1/helpers.py b/packages/discovery-provider/src/api/v1/helpers.py index ebd632dbd44..0d348fa0b84 100644 --- a/packages/discovery-provider/src/api/v1/helpers.py +++ b/packages/discovery-provider/src/api/v1/helpers.py @@ -991,11 +991,20 @@ def format_grant(grant): return { "grantee_address": grant["grantee_address"], "user_id": encode_int_id(grant["user_id"]), + "is_approved": grant["is_approved"], + "is_revoked": grant["is_revoked"], "created_at": grant["created_at"], "updated_at": grant["updated_at"], } +def format_managed_user(managed_user, current_user_id): + return { + "user": extend_user(managed_user["user"]), + "grant": format_grant(managed_user["grant"]), + } + + def format_dashboard_wallet_user(dashboard_wallet_user): return { "wallet": dashboard_wallet_user["wallet"], diff --git a/packages/discovery-provider/src/api/v1/models/grants.py b/packages/discovery-provider/src/api/v1/models/grants.py index f7c78255167..45ec284d630 100644 --- a/packages/discovery-provider/src/api/v1/models/grants.py +++ b/packages/discovery-provider/src/api/v1/models/grants.py @@ -1,6 +1,7 @@ from flask_restx import fields from .common import ns +from .users import user_model_full # Describes a grant made from user to another user grant = ns.model( @@ -8,7 +9,17 @@ { "grantee_address": fields.String(required=True), "user_id": fields.String(required=True), + "is_revoked": fields.Boolean(required=True), + "is_approved": fields.Boolean(required=True), "created_at": fields.String(required=True), "updated_at": fields.String(required=True), }, ) + +managed_user = ns.model( + "managed_user", + { + "user": fields.Nested(user_model_full, required=True), + "grant": fields.Nested(grant, required=True), + }, +) diff --git a/packages/discovery-provider/src/api/v1/users.py b/packages/discovery-provider/src/api/v1/users.py index 43f81462d37..5c13e7f0ee9 100644 --- a/packages/discovery-provider/src/api/v1/users.py +++ b/packages/discovery-provider/src/api/v1/users.py @@ -28,6 +28,7 @@ format_grant, format_library_filter, format_limit, + format_managed_user, format_offset, format_query, format_sort_direction, @@ -57,7 +58,7 @@ ) from src.api.v1.models.common import favorite from src.api.v1.models.developer_apps import authorized_app, developer_app -from src.api.v1.models.grants import grant +from src.api.v1.models.grants import managed_user from src.api.v1.models.support import ( supporter_response, supporter_response_full, @@ -102,7 +103,10 @@ ) from src.queries.get_followees_for_user import get_followees_for_user from src.queries.get_followers_for_user import get_followers_for_user -from src.queries.get_grants import GetGrantsArgs, get_grants +from src.queries.get_managed_users import ( + GetManagedUsersArgs, + get_managed_users_with_grants, +) from src.queries.get_related_artists import get_related_artists from src.queries.get_repost_feed_for_user import get_repost_feed_for_user from src.queries.get_saves import get_saves @@ -2109,33 +2113,44 @@ def get(self, id): return success_response(authorized_apps) -grants_response = make_response("grants", ns, fields.List(fields.Nested(grant))) +managed_users_response = make_response( + "managed_users", ns, fields.List(fields.Nested(managed_user)) +) -@ns.route("/grants") -class GrantsForUser(Resource): +@ns.route("//managed_users") +class ManagedUsers(Resource): @record_metrics @ns.doc( - id="""Get User Grants""", - description="""Get grants """, - params={"wallet_address": "Wallet address of the account"}, + id="""Get Managed Users""", + description="""Gets a list of users managed by the given user""", + params={"id": "A user id for the manager"}, responses={ 200: "Success", 400: "Bad request", 401: "Unauthorized", + 403: "Forbidden", 500: "Server error", }, ) @auth_middleware(include_wallet=True) - @ns.marshal_with(grants_response) - def get(self, authed_user_id, authed_user_wallet): - if authed_user_wallet is None: + @ns.marshal_with(managed_users_response) + def get(self, id, authed_user_id, authed_user_wallet): + user_id = decode_with_abort(id, ns) + + if authed_user_id is None: abort_unauthorized(ns) - args = GetGrantsArgs(grantee_address=authed_user_wallet) - grants = get_grants(args) - grants = list(map(format_grant, grants)) - return success_response(grants) + if authed_user_id != user_id: + abort_forbidden(ns) + + args = GetManagedUsersArgs( + manager_wallet_address=authed_user_wallet, current_user_id=user_id + ) + users = get_managed_users_with_grants(args) + users = list(map(format_managed_user, users)) + + return success_response(users) purchases_and_sales_parser = pagination_with_current_user_parser.copy() diff --git a/packages/discovery-provider/src/queries/get_grants.py b/packages/discovery-provider/src/queries/get_grants.py deleted file mode 100644 index d521f20ea81..00000000000 --- a/packages/discovery-provider/src/queries/get_grants.py +++ /dev/null @@ -1,53 +0,0 @@ -import logging -from typing import Dict, List, Optional, TypedDict - -from src.models.grants.grant import Grant -from src.utils import db_session -from src.utils.helpers import query_result_to_list - -logger = logging.getLogger(__name__) - - -class GetGrantsArgs(TypedDict): - user_id: Optional[int] - grantee_address: Optional[str] - is_approved: Optional[bool] - is_revoked: Optional[bool] - - -def get_grants(args: GetGrantsArgs) -> List[Dict]: - """ - Returns grants based on one or more provided filters. Must provide either - user_id or grantee_address. - - Args: - grantee_address: Optional[str] address of grantee - user_id: Optional[int] user id of grantor - is_approved: Optional[bool] whether grant is approved, defaults to True - is_revoked: Optional[bool] whether grant is revoked, defaults to False - - Returns: - List of grants - """ - is_approved = args.get("is_approved", True) - is_revoked = args.get("is_revoked", False) - user_id = args.get("user_id") - grantee_address = args.get("grantee_address") - if user_id is None and grantee_address is None: - raise ValueError("Must provide user_id or grantee_address") - - db = db_session.get_db_read_replica() - with db.scoped_session() as session: - base_query = session.query(Grant) - - if grantee_address: - base_query = base_query.filter(Grant.grantee_address == grantee_address) - if user_id: - base_query = base_query.filter(Grant.user_id == user_id) - base_query.filter( - Grant.is_current == True, - Grant.is_revoked == is_revoked, - Grant.is_approved == is_approved, - ) - grants = base_query.all() - return query_result_to_list(grants) diff --git a/packages/discovery-provider/src/queries/get_managed_users.py b/packages/discovery-provider/src/queries/get_managed_users.py new file mode 100644 index 00000000000..23fbdc098b4 --- /dev/null +++ b/packages/discovery-provider/src/queries/get_managed_users.py @@ -0,0 +1,69 @@ +import logging +from functools import reduce +from typing import Dict, List, Optional, TypedDict + +from src.models.grants.grant import Grant +from src.queries.get_unpopulated_users import get_unpopulated_users +from src.queries.query_helpers import populate_user_metadata +from src.utils import db_session +from src.utils.helpers import query_result_to_list + +logger = logging.getLogger(__name__) + + +class GetManagedUsersArgs(TypedDict): + manager_wallet_address: str + current_user_id: int + is_approved: Optional[bool] + is_revoked: Optional[bool] + + +def get_managed_users_with_grants(args: GetManagedUsersArgs) -> List[Dict]: + """ + Returns users managed by the given wallet address + + Args: + manager_wallet_address: str wallet address of the manager + is_approved: Optional[bool] If set, filters by approval status + is_revoked: Optional[bool] If set, filters by revocation status, defaults to False + + Returns: + List of Users with grant information + """ + is_approved = args.get("is_approved", None) + is_revoked = args.get("is_revoked", False) + current_user_id = args.get("current_user_id") + grantee_address = args.get("manager_wallet_address") + if grantee_address is None: + raise ValueError("manager_wallet_address is required") + if current_user_id is None: + raise ValueError("current_user_id is required") + + db = db_session.get_db_read_replica() + with db.scoped_session() as session: + base_query = ( + session.query(Grant) + .filter(Grant.grantee_address == grantee_address) + .filter(Grant.is_current == True) + ) + + if is_approved is not None: + base_query.filter(Grant.is_approved == is_approved) + if is_revoked is not None: + base_query.filter(Grant.is_revoked == is_revoked) + + grants = base_query.all() + if len(grants) == 0: + return [] + + user_ids = [grant.user_id for grant in grants] + users = get_unpopulated_users(session, user_ids) + users = populate_user_metadata(session, user_ids, users, current_user_id) + + grants_map = {grant.user_id: grant for grant in grants} + + managed_users = [ + {"user": user, "grant": grants_map.get(user.user_id)} for user in users + ] + + return query_result_to_list(managed_users) From 73b329922f1fb4f26908855fb94878e9423bba33 Mon Sep 17 00:00:00 2001 From: Randy Schott <1815175+schottra@users.noreply.github.com> Date: Fri, 26 Apr 2024 11:06:58 -0400 Subject: [PATCH 03/10] update endpoint to be user-specific --- .../queries/test_get_managed_users.py | 2 + .../discovery-provider/src/api/v1/helpers.py | 4 +- .../src/api/v1/models/grants.py | 4 +- .../default/.openapi-generator/FILES | 3 +- .../api/generated/default/apis/UsersApi.ts | 76 ++++++++-------- .../sdk/api/generated/default/models/Grant.ts | 18 ++++ .../generated/default/models/ManagedUser.ts | 89 +++++++++++++++++++ .../models/{Grants.ts => ManagedUsers.ts} | 36 ++++---- .../sdk/api/generated/default/models/index.ts | 3 +- 9 files changed, 173 insertions(+), 62 deletions(-) create mode 100644 packages/libs/src/sdk/api/generated/default/models/ManagedUser.ts rename packages/libs/src/sdk/api/generated/default/models/{Grants.ts => ManagedUsers.ts} (51%) diff --git a/packages/discovery-provider/integration_tests/queries/test_get_managed_users.py b/packages/discovery-provider/integration_tests/queries/test_get_managed_users.py index 174389426d5..5212cfeb9ea 100644 --- a/packages/discovery-provider/integration_tests/queries/test_get_managed_users.py +++ b/packages/discovery-provider/integration_tests/queries/test_get_managed_users.py @@ -25,6 +25,8 @@ ], } +# TODO: Actually test it + def test_get_managed_users(app): with app.app_context(): diff --git a/packages/discovery-provider/src/api/v1/helpers.py b/packages/discovery-provider/src/api/v1/helpers.py index 0d348fa0b84..6aceed9a7c9 100644 --- a/packages/discovery-provider/src/api/v1/helpers.py +++ b/packages/discovery-provider/src/api/v1/helpers.py @@ -998,9 +998,9 @@ def format_grant(grant): } -def format_managed_user(managed_user, current_user_id): +def format_managed_user(managed_user): return { - "user": extend_user(managed_user["user"]), + "user": managed_user["user"], "grant": format_grant(managed_user["grant"]), } diff --git a/packages/discovery-provider/src/api/v1/models/grants.py b/packages/discovery-provider/src/api/v1/models/grants.py index 45ec284d630..b4134369dd6 100644 --- a/packages/discovery-provider/src/api/v1/models/grants.py +++ b/packages/discovery-provider/src/api/v1/models/grants.py @@ -1,7 +1,7 @@ from flask_restx import fields from .common import ns -from .users import user_model_full +from .users import user_model # Describes a grant made from user to another user grant = ns.model( @@ -19,7 +19,7 @@ managed_user = ns.model( "managed_user", { - "user": fields.Nested(user_model_full, required=True), + "user": fields.Nested(user_model, required=True), "grant": fields.Nested(grant, required=True), }, ) diff --git a/packages/libs/src/sdk/api/generated/default/.openapi-generator/FILES b/packages/libs/src/sdk/api/generated/default/.openapi-generator/FILES index 093566a0080..a2198ee561a 100644 --- a/packages/libs/src/sdk/api/generated/default/.openapi-generator/FILES +++ b/packages/libs/src/sdk/api/generated/default/.openapi-generator/FILES @@ -30,7 +30,8 @@ models/GetSupporters.ts models/GetSupporting.ts models/GetTipsResponse.ts models/Grant.ts -models/Grants.ts +models/ManagedUser.ts +models/ManagedUsers.ts models/Playlist.ts models/PlaylistAddedTimestamp.ts models/PlaylistArtwork.ts diff --git a/packages/libs/src/sdk/api/generated/default/apis/UsersApi.ts b/packages/libs/src/sdk/api/generated/default/apis/UsersApi.ts index 53118e65a99..5a65014ed0f 100644 --- a/packages/libs/src/sdk/api/generated/default/apis/UsersApi.ts +++ b/packages/libs/src/sdk/api/generated/default/apis/UsersApi.ts @@ -24,7 +24,7 @@ import type { FollowingResponse, GetSupporters, GetSupporting, - Grants, + ManagedUsers, RelatedArtistResponse, Reposts, SubscribersResponse, @@ -52,8 +52,8 @@ import { GetSupportersToJSON, GetSupportingFromJSON, GetSupportingToJSON, - GrantsFromJSON, - GrantsToJSON, + ManagedUsersFromJSON, + ManagedUsersToJSON, RelatedArtistResponseFromJSON, RelatedArtistResponseToJSON, RepostsFromJSON, @@ -139,6 +139,10 @@ export interface GetFollowingRequest { userId?: string; } +export interface GetManagedUsersRequest { + id: string; +} + export interface GetRelatedUsersRequest { id: string; offset?: number; @@ -201,10 +205,6 @@ export interface GetUserByHandleRequest { userId?: string; } -export interface GetUserGrantsRequest { - walletAddress?: string; -} - export interface GetUserIDFromWalletRequest { associatedWallet: string; } @@ -629,6 +629,37 @@ export class UsersApi extends runtime.BaseAPI { return await response.value(); } + /** + * @hidden + * Gets a list of users managed by the given user + */ + async getManagedUsersRaw(params: GetManagedUsersRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + if (params.id === null || params.id === undefined) { + throw new runtime.RequiredError('id','Required parameter params.id was null or undefined when calling getManagedUsers.'); + } + + const queryParameters: any = {}; + + const headerParameters: runtime.HTTPHeaders = {}; + + const response = await this.request({ + path: `/users/{id}/managed_users`.replace(`{${"id"}}`, encodeURIComponent(String(params.id))), + method: 'GET', + headers: headerParameters, + query: queryParameters, + }, initOverrides); + + return new runtime.JSONApiResponse(response, (jsonValue) => ManagedUsersFromJSON(jsonValue)); + } + + /** + * Gets a list of users managed by the given user + */ + async getManagedUsers(params: GetManagedUsersRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + const response = await this.getManagedUsersRaw(params, initOverrides); + return await response.value(); + } + /** * @hidden * Gets a list of users that might be of interest to followers of this user. @@ -1014,37 +1045,6 @@ export class UsersApi extends runtime.BaseAPI { return await response.value(); } - /** - * @hidden - * Get grants - */ - async getUserGrantsRaw(params: GetUserGrantsRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { - const queryParameters: any = {}; - - if (params.walletAddress !== undefined) { - queryParameters['wallet_address'] = params.walletAddress; - } - - const headerParameters: runtime.HTTPHeaders = {}; - - const response = await this.request({ - path: `/users/grants`, - method: 'GET', - headers: headerParameters, - query: queryParameters, - }, initOverrides); - - return new runtime.JSONApiResponse(response, (jsonValue) => GrantsFromJSON(jsonValue)); - } - - /** - * Get grants - */ - async getUserGrants(params: GetUserGrantsRequest = {}, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { - const response = await this.getUserGrantsRaw(params, initOverrides); - return await response.value(); - } - /** * @hidden * Gets a User ID from an associated wallet address diff --git a/packages/libs/src/sdk/api/generated/default/models/Grant.ts b/packages/libs/src/sdk/api/generated/default/models/Grant.ts index 821e70c015e..2c2fee58696 100644 --- a/packages/libs/src/sdk/api/generated/default/models/Grant.ts +++ b/packages/libs/src/sdk/api/generated/default/models/Grant.ts @@ -32,6 +32,18 @@ export interface Grant { * @memberof Grant */ userId: string; + /** + * + * @type {boolean} + * @memberof Grant + */ + isRevoked: boolean; + /** + * + * @type {boolean} + * @memberof Grant + */ + isApproved: boolean; /** * * @type {string} @@ -53,6 +65,8 @@ export function instanceOfGrant(value: object): boolean { let isInstance = true; isInstance = isInstance && "granteeAddress" in value; isInstance = isInstance && "userId" in value; + isInstance = isInstance && "isRevoked" in value; + isInstance = isInstance && "isApproved" in value; isInstance = isInstance && "createdAt" in value; isInstance = isInstance && "updatedAt" in value; @@ -71,6 +85,8 @@ export function GrantFromJSONTyped(json: any, ignoreDiscriminator: boolean): Gra 'granteeAddress': json['grantee_address'], 'userId': json['user_id'], + 'isRevoked': json['is_revoked'], + 'isApproved': json['is_approved'], 'createdAt': json['created_at'], 'updatedAt': json['updated_at'], }; @@ -87,6 +103,8 @@ export function GrantToJSON(value?: Grant | null): any { 'grantee_address': value.granteeAddress, 'user_id': value.userId, + 'is_revoked': value.isRevoked, + 'is_approved': value.isApproved, 'created_at': value.createdAt, 'updated_at': value.updatedAt, }; diff --git a/packages/libs/src/sdk/api/generated/default/models/ManagedUser.ts b/packages/libs/src/sdk/api/generated/default/models/ManagedUser.ts new file mode 100644 index 00000000000..bdba2a6ade2 --- /dev/null +++ b/packages/libs/src/sdk/api/generated/default/models/ManagedUser.ts @@ -0,0 +1,89 @@ +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck +/** + * API + * Audius V1 API + * + * The version of the OpenAPI document: 1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { exists, mapValues } from '../runtime'; +import type { Grant } from './Grant'; +import { + GrantFromJSON, + GrantFromJSONTyped, + GrantToJSON, +} from './Grant'; +import type { User } from './User'; +import { + UserFromJSON, + UserFromJSONTyped, + UserToJSON, +} from './User'; + +/** + * + * @export + * @interface ManagedUser + */ +export interface ManagedUser { + /** + * + * @type {User} + * @memberof ManagedUser + */ + user: User; + /** + * + * @type {Grant} + * @memberof ManagedUser + */ + grant: Grant; +} + +/** + * Check if a given object implements the ManagedUser interface. + */ +export function instanceOfManagedUser(value: object): boolean { + let isInstance = true; + isInstance = isInstance && "user" in value; + isInstance = isInstance && "grant" in value; + + return isInstance; +} + +export function ManagedUserFromJSON(json: any): ManagedUser { + return ManagedUserFromJSONTyped(json, false); +} + +export function ManagedUserFromJSONTyped(json: any, ignoreDiscriminator: boolean): ManagedUser { + if ((json === undefined) || (json === null)) { + return json; + } + return { + + 'user': UserFromJSON(json['user']), + 'grant': GrantFromJSON(json['grant']), + }; +} + +export function ManagedUserToJSON(value?: ManagedUser | null): any { + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + return { + + 'user': UserToJSON(value.user), + 'grant': GrantToJSON(value.grant), + }; +} + diff --git a/packages/libs/src/sdk/api/generated/default/models/Grants.ts b/packages/libs/src/sdk/api/generated/default/models/ManagedUsers.ts similarity index 51% rename from packages/libs/src/sdk/api/generated/default/models/Grants.ts rename to packages/libs/src/sdk/api/generated/default/models/ManagedUsers.ts index 7b42eed8f32..97d457c9afb 100644 --- a/packages/libs/src/sdk/api/generated/default/models/Grants.ts +++ b/packages/libs/src/sdk/api/generated/default/models/ManagedUsers.ts @@ -14,51 +14,51 @@ */ import { exists, mapValues } from '../runtime'; -import type { Grant } from './Grant'; +import type { ManagedUser } from './ManagedUser'; import { - GrantFromJSON, - GrantFromJSONTyped, - GrantToJSON, -} from './Grant'; + ManagedUserFromJSON, + ManagedUserFromJSONTyped, + ManagedUserToJSON, +} from './ManagedUser'; /** * * @export - * @interface Grants + * @interface ManagedUsers */ -export interface Grants { +export interface ManagedUsers { /** * - * @type {Array} - * @memberof Grants + * @type {Array} + * @memberof ManagedUsers */ - data?: Array; + data?: Array; } /** - * Check if a given object implements the Grants interface. + * Check if a given object implements the ManagedUsers interface. */ -export function instanceOfGrants(value: object): boolean { +export function instanceOfManagedUsers(value: object): boolean { let isInstance = true; return isInstance; } -export function GrantsFromJSON(json: any): Grants { - return GrantsFromJSONTyped(json, false); +export function ManagedUsersFromJSON(json: any): ManagedUsers { + return ManagedUsersFromJSONTyped(json, false); } -export function GrantsFromJSONTyped(json: any, ignoreDiscriminator: boolean): Grants { +export function ManagedUsersFromJSONTyped(json: any, ignoreDiscriminator: boolean): ManagedUsers { if ((json === undefined) || (json === null)) { return json; } return { - 'data': !exists(json, 'data') ? undefined : ((json['data'] as Array).map(GrantFromJSON)), + 'data': !exists(json, 'data') ? undefined : ((json['data'] as Array).map(ManagedUserFromJSON)), }; } -export function GrantsToJSON(value?: Grants | null): any { +export function ManagedUsersToJSON(value?: ManagedUsers | null): any { if (value === undefined) { return undefined; } @@ -67,7 +67,7 @@ export function GrantsToJSON(value?: Grants | null): any { } return { - 'data': value.data === undefined ? undefined : ((value.data as Array).map(GrantToJSON)), + 'data': value.data === undefined ? undefined : ((value.data as Array).map(ManagedUserToJSON)), }; } diff --git a/packages/libs/src/sdk/api/generated/default/models/index.ts b/packages/libs/src/sdk/api/generated/default/models/index.ts index 9f2def3d5ce..840809b6735 100644 --- a/packages/libs/src/sdk/api/generated/default/models/index.ts +++ b/packages/libs/src/sdk/api/generated/default/models/index.ts @@ -23,7 +23,8 @@ export * from './GetSupporters'; export * from './GetSupporting'; export * from './GetTipsResponse'; export * from './Grant'; -export * from './Grants'; +export * from './ManagedUser'; +export * from './ManagedUsers'; export * from './Playlist'; export * from './PlaylistAddedTimestamp'; export * from './PlaylistArtwork'; From 3a121b0ea953ba6c077103b1dc1a56ab2ba9fc22 Mon Sep 17 00:00:00 2001 From: Randy Schott <1815175+schottra@users.noreply.github.com> Date: Fri, 26 Apr 2024 11:32:21 -0400 Subject: [PATCH 04/10] add tests for query --- .../queries/test_get_managed_users.py | 100 ++++++++++++++++-- 1 file changed, 93 insertions(+), 7 deletions(-) diff --git a/packages/discovery-provider/integration_tests/queries/test_get_managed_users.py b/packages/discovery-provider/integration_tests/queries/test_get_managed_users.py index 5212cfeb9ea..2fca9955bd6 100644 --- a/packages/discovery-provider/integration_tests/queries/test_get_managed_users.py +++ b/packages/discovery-provider/integration_tests/queries/test_get_managed_users.py @@ -1,6 +1,8 @@ +import copy from datetime import datetime from integration_tests.utils import populate_mock_db +from src.queries.get_managed_users import get_managed_users_with_grants from src.utils.db_session import get_db test_entities = { @@ -8,28 +10,112 @@ {"user_id": 10, "name": "a", "wallet": "0x10"}, {"user_id": 20, "name": "b", "wallet": "0x20"}, {"user_id": 30, "name": "c", "wallet": "0x30"}, + {"user_id": 40, "name": "d", "wallet": "0x40"}, + {"user_id": 50, "name": "e", "wallet": "0x50"}, + {"user_id": 60, "name": "f", "wallet": "0x60"}, ], "grants": [ + # Active grants { - "user_id": 10, - "grantee_address": "0x30", + "user_id": 20, + "grantee_address": "0x10", "is_approved": True, "is_revoked": False, }, { - "user_id": 20, - "grantee_address": "0x30", + "user_id": 30, + "grantee_address": "0x10", "is_approved": True, "is_revoked": False, }, + # Not yet approved + { + "user_id": 40, + "grantee_address": "0x10", + "is_approved": False, + "is_revoked": False, + }, + # Approved then Revoked + { + "user_id": 50, + "grantee_address": "0x10", + "is_approved": True, + "is_revoked": True, + }, + # Revoked before approval + { + "user_id": 60, + "grantee_address": "0x10", + "is_approved": False, + "is_revoked": True, + }, ], } -# TODO: Actually test it +def test_get_managed_users_default(app): + with app.app_context(): + db = get_db() + populate_mock_db(db, test_entities) -def test_get_managed_users(app): + managed_users = get_managed_users_with_grants( + {"manager_wallet_address": "0x10", "current_user_id": 10} + ) + + # return all non-revoked records by default + assert len(managed_users) == 3, "Expected exactly 3 records" + assert ( + record["grant"]["is_revoked"] == False for record in managed_users + ), "Revoked records returned" + + +def test_get_managed_users_grants_without_users(app): + with app.app_context(): + db = get_db() + + entities = copy.deepcopy(test_entities) + # Record for a user which is in an invalid state + entities["users"].append( + {"user_id": 70, "wallet": None, "name": "f", "wallet": "0x60"} + ) + entities["grants"].append( + { + "user_id": 70, + "grantee_address": "0x10", + "is_approved": False, + "is_revoked": False, + } + ) + populate_mock_db(db, entities) + + managed_users = get_managed_users_with_grants( + {"manager_wallet_address": "0x10", "current_user_id": 10} + ) + + # return all non-revoked records by default + assert len(managed_users) == 3, "Expected exactly 3 records" + assert ( + record["grant"]["user_id"] != 70 for record in managed_users + ), "Revoked records returned" + + +def test_get_managed_users_invalid_parameters(app): with app.app_context(): db = get_db() populate_mock_db(db, test_entities) - # TODO: Implement test + + try: + managed_users = get_managed_users_with_grants( + {"manager_wallet_address": None, "current_user_id": 10} + ) + assert False, "Should have thrown an error for missing wallet address" + except ValueError as e: + assert str(e) == "manager_wallet_address is required" + + try: + managed_users = get_managed_users_with_grants( + {"manager_wallet_address": "0x10", "current_user_id": None} + ) + assert False, "Should have thrown an error for missing current user id" + except ValueError as e: + assert str(e) == "current_user_id is required" From cf6ba9bbfc81f537de60960b278dd2e41f6c6f2f Mon Sep 17 00:00:00 2001 From: Randy Schott <1815175+schottra@users.noreply.github.com> Date: Fri, 26 Apr 2024 12:06:36 -0400 Subject: [PATCH 05/10] fixing tests --- .../queries/test_get_managed_users.py | 10 ++--- .../src/queries/get_managed_users.py | 42 ++++++++++++------- 2 files changed, 30 insertions(+), 22 deletions(-) diff --git a/packages/discovery-provider/integration_tests/queries/test_get_managed_users.py b/packages/discovery-provider/integration_tests/queries/test_get_managed_users.py index 2fca9955bd6..c497551335e 100644 --- a/packages/discovery-provider/integration_tests/queries/test_get_managed_users.py +++ b/packages/discovery-provider/integration_tests/queries/test_get_managed_users.py @@ -1,5 +1,4 @@ import copy -from datetime import datetime from integration_tests.utils import populate_mock_db from src.queries.get_managed_users import get_managed_users_with_grants @@ -74,10 +73,7 @@ def test_get_managed_users_grants_without_users(app): db = get_db() entities = copy.deepcopy(test_entities) - # Record for a user which is in an invalid state - entities["users"].append( - {"user_id": 70, "wallet": None, "name": "f", "wallet": "0x60"} - ) + # Record for a user which won't be found entities["grants"].append( { "user_id": 70, @@ -105,7 +101,7 @@ def test_get_managed_users_invalid_parameters(app): populate_mock_db(db, test_entities) try: - managed_users = get_managed_users_with_grants( + get_managed_users_with_grants( {"manager_wallet_address": None, "current_user_id": 10} ) assert False, "Should have thrown an error for missing wallet address" @@ -113,7 +109,7 @@ def test_get_managed_users_invalid_parameters(app): assert str(e) == "manager_wallet_address is required" try: - managed_users = get_managed_users_with_grants( + get_managed_users_with_grants( {"manager_wallet_address": "0x10", "current_user_id": None} ) assert False, "Should have thrown an error for missing current user id" diff --git a/packages/discovery-provider/src/queries/get_managed_users.py b/packages/discovery-provider/src/queries/get_managed_users.py index 23fbdc098b4..8783d65caff 100644 --- a/packages/discovery-provider/src/queries/get_managed_users.py +++ b/packages/discovery-provider/src/queries/get_managed_users.py @@ -1,12 +1,11 @@ import logging -from functools import reduce from typing import Dict, List, Optional, TypedDict from src.models.grants.grant import Grant from src.queries.get_unpopulated_users import get_unpopulated_users from src.queries.query_helpers import populate_user_metadata from src.utils import db_session -from src.utils.helpers import query_result_to_list +from src.utils.helpers import model_to_dictionary, query_result_to_list logger = logging.getLogger(__name__) @@ -18,6 +17,25 @@ class GetManagedUsersArgs(TypedDict): is_revoked: Optional[bool] +def make_managed_users_list(users: List[Dict], grants: List[Dict]) -> List[Dict]: + managed_users = [] + grants_map = {grant.get("user_id"): grant for grant in grants} + + for user in users: + grant = grants_map.get(user.get("user_id")) + if grant is None: + continue + + managed_users.append( + { + "user": user, + "grant": grant, + } + ) + + return managed_users + + def get_managed_users_with_grants(args: GetManagedUsersArgs) -> List[Dict]: """ Returns users managed by the given wallet address @@ -41,18 +59,16 @@ def get_managed_users_with_grants(args: GetManagedUsersArgs) -> List[Dict]: db = db_session.get_db_read_replica() with db.scoped_session() as session: - base_query = ( - session.query(Grant) - .filter(Grant.grantee_address == grantee_address) - .filter(Grant.is_current == True) + query = session.query(Grant).filter( + Grant.grantee_address == grantee_address, Grant.is_current == True ) if is_approved is not None: - base_query.filter(Grant.is_approved == is_approved) + query = query.filter(Grant.is_approved == is_approved) if is_revoked is not None: - base_query.filter(Grant.is_revoked == is_revoked) + query = query.filter(Grant.is_revoked == is_revoked) - grants = base_query.all() + grants = query.all() if len(grants) == 0: return [] @@ -60,10 +76,6 @@ def get_managed_users_with_grants(args: GetManagedUsersArgs) -> List[Dict]: users = get_unpopulated_users(session, user_ids) users = populate_user_metadata(session, user_ids, users, current_user_id) - grants_map = {grant.user_id: grant for grant in grants} - - managed_users = [ - {"user": user, "grant": grants_map.get(user.user_id)} for user in users - ] + grants = query_result_to_list(grants) - return query_result_to_list(managed_users) + return make_managed_users_list(users, grants) From 2ed52e9d1114de123d9b0682df08b036607ee731 Mon Sep 17 00:00:00 2001 From: Randy Schott <1815175+schottra@users.noreply.github.com> Date: Fri, 26 Apr 2024 14:37:13 -0400 Subject: [PATCH 06/10] fix lint --- packages/discovery-provider/src/api/v1/users.py | 1 - packages/discovery-provider/src/queries/get_managed_users.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/discovery-provider/src/api/v1/users.py b/packages/discovery-provider/src/api/v1/users.py index 5c13e7f0ee9..78e13e6a04d 100644 --- a/packages/discovery-provider/src/api/v1/users.py +++ b/packages/discovery-provider/src/api/v1/users.py @@ -25,7 +25,6 @@ format_aggregate_monthly_plays_for_user, format_authorized_app, format_developer_app, - format_grant, format_library_filter, format_limit, format_managed_user, diff --git a/packages/discovery-provider/src/queries/get_managed_users.py b/packages/discovery-provider/src/queries/get_managed_users.py index 8783d65caff..08f28182936 100644 --- a/packages/discovery-provider/src/queries/get_managed_users.py +++ b/packages/discovery-provider/src/queries/get_managed_users.py @@ -5,7 +5,7 @@ from src.queries.get_unpopulated_users import get_unpopulated_users from src.queries.query_helpers import populate_user_metadata from src.utils import db_session -from src.utils.helpers import model_to_dictionary, query_result_to_list +from src.utils.helpers import query_result_to_list logger = logging.getLogger(__name__) From 14e5ed148ba62124c6561f946ace96d6dfbd6df6 Mon Sep 17 00:00:00 2001 From: Randy Schott <1815175+schottra@users.noreply.github.com> Date: Mon, 29 Apr 2024 11:00:33 -0400 Subject: [PATCH 07/10] PR feedback --- .../queries/test_get_managed_users.py | 18 ++++++++++++++++++ .../src/utils/auth_middleware.py | 14 ++++---------- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/packages/discovery-provider/integration_tests/queries/test_get_managed_users.py b/packages/discovery-provider/integration_tests/queries/test_get_managed_users.py index c497551335e..294e5b711f0 100644 --- a/packages/discovery-provider/integration_tests/queries/test_get_managed_users.py +++ b/packages/discovery-provider/integration_tests/queries/test_get_managed_users.py @@ -68,6 +68,24 @@ def test_get_managed_users_default(app): ), "Revoked records returned" +def test_get_managed_users_no_filters(app): + with app.app_context(): + db = get_db() + populate_mock_db(db, test_entities) + + managed_users = get_managed_users_with_grants( + { + "manager_wallet_address": "0x10", + "current_user_id": 10, + "is_approved": None, + "is_revoked": None, + } + ) + + # return all records which map to users + assert len(managed_users) == 5, "Expected exactly 5 records" + + def test_get_managed_users_grants_without_users(app): with app.app_context(): db = get_db() diff --git a/packages/discovery-provider/src/utils/auth_middleware.py b/packages/discovery-provider/src/utils/auth_middleware.py index 0c61f88b58e..ca6c5c1a661 100644 --- a/packages/discovery-provider/src/utils/auth_middleware.py +++ b/packages/discovery-provider/src/utils/auth_middleware.py @@ -91,16 +91,10 @@ def wrapper(*args, **kwargs): logger.info( f"auth_middleware.py | authed_user_id: {authed_user_id}" ) - return ( - func( - *args, - **kwargs, - authed_user_id=authed_user_id, - authed_user_wallet=wallet_lower, - ) - if include_wallet - else func(*args, **kwargs, authed_user_id=authed_user_id) - ) + kwargs["authed_user_id"] = authed_user_id + if include_wallet: + kwargs["authed_user_wallet"] = wallet_lower + return func(*args, **kwargs) return wrapper From a719b68c18d7efd1a32f46d083a27f1be9364f97 Mon Sep 17 00:00:00 2001 From: Randy Schott <1815175+schottra@users.noreply.github.com> Date: Mon, 29 Apr 2024 11:36:39 -0400 Subject: [PATCH 08/10] move to full namespace and return full user --- .../discovery-provider/src/api/v1/helpers.py | 2 +- .../src/api/v1/models/grants.py | 4 +- .../discovery-provider/src/api/v1/users.py | 14 +-- .../default/.openapi-generator/FILES | 3 - .../api/generated/default/apis/UsersApi.ts | 38 ------ .../generated/default/models/ManagedUser.ts | 18 +-- .../sdk/api/generated/default/models/index.ts | 3 - .../generated/full/.openapi-generator/FILES | 3 + .../sdk/api/generated/full/apis/UsersApi.ts | 38 ++++++ .../sdk/api/generated/full/models/Grant.ts | 112 ++++++++++++++++++ .../api/generated/full/models/ManagedUser.ts | 89 ++++++++++++++ .../full/models/ManagedUsersResponse.ts | 73 ++++++++++++ .../sdk/api/generated/full/models/index.ts | 3 + 13 files changed, 337 insertions(+), 63 deletions(-) create mode 100644 packages/libs/src/sdk/api/generated/full/models/Grant.ts create mode 100644 packages/libs/src/sdk/api/generated/full/models/ManagedUser.ts create mode 100644 packages/libs/src/sdk/api/generated/full/models/ManagedUsersResponse.ts diff --git a/packages/discovery-provider/src/api/v1/helpers.py b/packages/discovery-provider/src/api/v1/helpers.py index 6aceed9a7c9..ed7bc05e0d6 100644 --- a/packages/discovery-provider/src/api/v1/helpers.py +++ b/packages/discovery-provider/src/api/v1/helpers.py @@ -1000,7 +1000,7 @@ def format_grant(grant): def format_managed_user(managed_user): return { - "user": managed_user["user"], + "user": extend_user(managed_user["user"]), "grant": format_grant(managed_user["grant"]), } diff --git a/packages/discovery-provider/src/api/v1/models/grants.py b/packages/discovery-provider/src/api/v1/models/grants.py index b4134369dd6..45ec284d630 100644 --- a/packages/discovery-provider/src/api/v1/models/grants.py +++ b/packages/discovery-provider/src/api/v1/models/grants.py @@ -1,7 +1,7 @@ from flask_restx import fields from .common import ns -from .users import user_model +from .users import user_model_full # Describes a grant made from user to another user grant = ns.model( @@ -19,7 +19,7 @@ managed_user = ns.model( "managed_user", { - "user": fields.Nested(user_model, required=True), + "user": fields.Nested(user_model_full, required=True), "grant": fields.Nested(grant, required=True), }, ) diff --git a/packages/discovery-provider/src/api/v1/users.py b/packages/discovery-provider/src/api/v1/users.py index 78e13e6a04d..07b222f8c79 100644 --- a/packages/discovery-provider/src/api/v1/users.py +++ b/packages/discovery-provider/src/api/v1/users.py @@ -2113,14 +2113,14 @@ def get(self, id): managed_users_response = make_response( - "managed_users", ns, fields.List(fields.Nested(managed_user)) + "managed_users_response", full_ns, fields.List(fields.Nested(managed_user)) ) -@ns.route("//managed_users") +@full_ns.route("//managed_users") class ManagedUsers(Resource): @record_metrics - @ns.doc( + @full_ns.doc( id="""Get Managed Users""", description="""Gets a list of users managed by the given user""", params={"id": "A user id for the manager"}, @@ -2133,15 +2133,15 @@ class ManagedUsers(Resource): }, ) @auth_middleware(include_wallet=True) - @ns.marshal_with(managed_users_response) + @full_ns.marshal_with(managed_users_response) def get(self, id, authed_user_id, authed_user_wallet): - user_id = decode_with_abort(id, ns) + user_id = decode_with_abort(id, full_ns) if authed_user_id is None: - abort_unauthorized(ns) + abort_unauthorized(full_ns) if authed_user_id != user_id: - abort_forbidden(ns) + abort_forbidden(full_ns) args = GetManagedUsersArgs( manager_wallet_address=authed_user_wallet, current_user_id=user_id diff --git a/packages/libs/src/sdk/api/generated/default/.openapi-generator/FILES b/packages/libs/src/sdk/api/generated/default/.openapi-generator/FILES index a2198ee561a..93ae174f9b8 100644 --- a/packages/libs/src/sdk/api/generated/default/.openapi-generator/FILES +++ b/packages/libs/src/sdk/api/generated/default/.openapi-generator/FILES @@ -29,9 +29,6 @@ models/FollowingResponse.ts models/GetSupporters.ts models/GetSupporting.ts models/GetTipsResponse.ts -models/Grant.ts -models/ManagedUser.ts -models/ManagedUsers.ts models/Playlist.ts models/PlaylistAddedTimestamp.ts models/PlaylistArtwork.ts diff --git a/packages/libs/src/sdk/api/generated/default/apis/UsersApi.ts b/packages/libs/src/sdk/api/generated/default/apis/UsersApi.ts index 5a65014ed0f..3bba20c4b3a 100644 --- a/packages/libs/src/sdk/api/generated/default/apis/UsersApi.ts +++ b/packages/libs/src/sdk/api/generated/default/apis/UsersApi.ts @@ -24,7 +24,6 @@ import type { FollowingResponse, GetSupporters, GetSupporting, - ManagedUsers, RelatedArtistResponse, Reposts, SubscribersResponse, @@ -52,8 +51,6 @@ import { GetSupportersToJSON, GetSupportingFromJSON, GetSupportingToJSON, - ManagedUsersFromJSON, - ManagedUsersToJSON, RelatedArtistResponseFromJSON, RelatedArtistResponseToJSON, RepostsFromJSON, @@ -139,10 +136,6 @@ export interface GetFollowingRequest { userId?: string; } -export interface GetManagedUsersRequest { - id: string; -} - export interface GetRelatedUsersRequest { id: string; offset?: number; @@ -629,37 +622,6 @@ export class UsersApi extends runtime.BaseAPI { return await response.value(); } - /** - * @hidden - * Gets a list of users managed by the given user - */ - async getManagedUsersRaw(params: GetManagedUsersRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { - if (params.id === null || params.id === undefined) { - throw new runtime.RequiredError('id','Required parameter params.id was null or undefined when calling getManagedUsers.'); - } - - const queryParameters: any = {}; - - const headerParameters: runtime.HTTPHeaders = {}; - - const response = await this.request({ - path: `/users/{id}/managed_users`.replace(`{${"id"}}`, encodeURIComponent(String(params.id))), - method: 'GET', - headers: headerParameters, - query: queryParameters, - }, initOverrides); - - return new runtime.JSONApiResponse(response, (jsonValue) => ManagedUsersFromJSON(jsonValue)); - } - - /** - * Gets a list of users managed by the given user - */ - async getManagedUsers(params: GetManagedUsersRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { - const response = await this.getManagedUsersRaw(params, initOverrides); - return await response.value(); - } - /** * @hidden * Gets a list of users that might be of interest to followers of this user. diff --git a/packages/libs/src/sdk/api/generated/default/models/ManagedUser.ts b/packages/libs/src/sdk/api/generated/default/models/ManagedUser.ts index bdba2a6ade2..e8cb0110e5d 100644 --- a/packages/libs/src/sdk/api/generated/default/models/ManagedUser.ts +++ b/packages/libs/src/sdk/api/generated/default/models/ManagedUser.ts @@ -20,12 +20,12 @@ import { GrantFromJSONTyped, GrantToJSON, } from './Grant'; -import type { User } from './User'; +import type { UserFull } from './UserFull'; import { - UserFromJSON, - UserFromJSONTyped, - UserToJSON, -} from './User'; + UserFullFromJSON, + UserFullFromJSONTyped, + UserFullToJSON, +} from './UserFull'; /** * @@ -35,10 +35,10 @@ import { export interface ManagedUser { /** * - * @type {User} + * @type {UserFull} * @memberof ManagedUser */ - user: User; + user: UserFull; /** * * @type {Grant} @@ -68,7 +68,7 @@ export function ManagedUserFromJSONTyped(json: any, ignoreDiscriminator: boolean } return { - 'user': UserFromJSON(json['user']), + 'user': UserFullFromJSON(json['user']), 'grant': GrantFromJSON(json['grant']), }; } @@ -82,7 +82,7 @@ export function ManagedUserToJSON(value?: ManagedUser | null): any { } return { - 'user': UserToJSON(value.user), + 'user': UserFullToJSON(value.user), 'grant': GrantToJSON(value.grant), }; } diff --git a/packages/libs/src/sdk/api/generated/default/models/index.ts b/packages/libs/src/sdk/api/generated/default/models/index.ts index 840809b6735..4f2ce30344a 100644 --- a/packages/libs/src/sdk/api/generated/default/models/index.ts +++ b/packages/libs/src/sdk/api/generated/default/models/index.ts @@ -22,9 +22,6 @@ export * from './FollowingResponse'; export * from './GetSupporters'; export * from './GetSupporting'; export * from './GetTipsResponse'; -export * from './Grant'; -export * from './ManagedUser'; -export * from './ManagedUsers'; export * from './Playlist'; export * from './PlaylistAddedTimestamp'; export * from './PlaylistArtwork'; diff --git a/packages/libs/src/sdk/api/generated/full/.openapi-generator/FILES b/packages/libs/src/sdk/api/generated/full/.openapi-generator/FILES index bc5760cf9c8..e58a74ff2ad 100644 --- a/packages/libs/src/sdk/api/generated/full/.openapi-generator/FILES +++ b/packages/libs/src/sdk/api/generated/full/.openapi-generator/FILES @@ -42,7 +42,10 @@ models/FullTracksResponse.ts models/FullTrendingPlaylistsResponse.ts models/FullUserResponse.ts models/GetTipsResponse.ts +models/Grant.ts models/HistoryResponseFull.ts +models/ManagedUser.ts +models/ManagedUsersResponse.ts models/PlaylistAddedTimestamp.ts models/PlaylistArtwork.ts models/PlaylistFull.ts diff --git a/packages/libs/src/sdk/api/generated/full/apis/UsersApi.ts b/packages/libs/src/sdk/api/generated/full/apis/UsersApi.ts index f0fa404f406..b2eea5a83f2 100644 --- a/packages/libs/src/sdk/api/generated/full/apis/UsersApi.ts +++ b/packages/libs/src/sdk/api/generated/full/apis/UsersApi.ts @@ -28,6 +28,7 @@ import type { FullTracks, FullUserResponse, HistoryResponseFull, + ManagedUsersResponse, PurchasesCountResponse, PurchasesResponse, RelatedArtistResponseFull, @@ -63,6 +64,8 @@ import { FullUserResponseToJSON, HistoryResponseFullFromJSON, HistoryResponseFullToJSON, + ManagedUsersResponseFromJSON, + ManagedUsersResponseToJSON, PurchasesCountResponseFromJSON, PurchasesCountResponseToJSON, PurchasesResponseFromJSON, @@ -145,6 +148,10 @@ export interface GetFollowingRequest { userId?: string; } +export interface GetManagedUsersRequest { + id: string; +} + export interface GetPurchasesRequest { id: string; offset?: number; @@ -744,6 +751,37 @@ export class UsersApi extends runtime.BaseAPI { return await response.value(); } + /** + * @hidden + * Gets a list of users managed by the given user + */ + async getManagedUsersRaw(params: GetManagedUsersRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + if (params.id === null || params.id === undefined) { + throw new runtime.RequiredError('id','Required parameter params.id was null or undefined when calling getManagedUsers.'); + } + + const queryParameters: any = {}; + + const headerParameters: runtime.HTTPHeaders = {}; + + const response = await this.request({ + path: `/users/{id}/managed_users`.replace(`{${"id"}}`, encodeURIComponent(String(params.id))), + method: 'GET', + headers: headerParameters, + query: queryParameters, + }, initOverrides); + + return new runtime.JSONApiResponse(response, (jsonValue) => ManagedUsersResponseFromJSON(jsonValue)); + } + + /** + * Gets a list of users managed by the given user + */ + async getManagedUsers(params: GetManagedUsersRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + const response = await this.getManagedUsersRaw(params, initOverrides); + return await response.value(); + } + /** * @hidden * Gets the purchases the user has made diff --git a/packages/libs/src/sdk/api/generated/full/models/Grant.ts b/packages/libs/src/sdk/api/generated/full/models/Grant.ts new file mode 100644 index 00000000000..5b6f1202e5c --- /dev/null +++ b/packages/libs/src/sdk/api/generated/full/models/Grant.ts @@ -0,0 +1,112 @@ +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck +/** + * API + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { exists, mapValues } from '../runtime'; +/** + * + * @export + * @interface Grant + */ +export interface Grant { + /** + * + * @type {string} + * @memberof Grant + */ + granteeAddress: string; + /** + * + * @type {string} + * @memberof Grant + */ + userId: string; + /** + * + * @type {boolean} + * @memberof Grant + */ + isRevoked: boolean; + /** + * + * @type {boolean} + * @memberof Grant + */ + isApproved: boolean; + /** + * + * @type {string} + * @memberof Grant + */ + createdAt: string; + /** + * + * @type {string} + * @memberof Grant + */ + updatedAt: string; +} + +/** + * Check if a given object implements the Grant interface. + */ +export function instanceOfGrant(value: object): boolean { + let isInstance = true; + isInstance = isInstance && "granteeAddress" in value; + isInstance = isInstance && "userId" in value; + isInstance = isInstance && "isRevoked" in value; + isInstance = isInstance && "isApproved" in value; + isInstance = isInstance && "createdAt" in value; + isInstance = isInstance && "updatedAt" in value; + + return isInstance; +} + +export function GrantFromJSON(json: any): Grant { + return GrantFromJSONTyped(json, false); +} + +export function GrantFromJSONTyped(json: any, ignoreDiscriminator: boolean): Grant { + if ((json === undefined) || (json === null)) { + return json; + } + return { + + 'granteeAddress': json['grantee_address'], + 'userId': json['user_id'], + 'isRevoked': json['is_revoked'], + 'isApproved': json['is_approved'], + 'createdAt': json['created_at'], + 'updatedAt': json['updated_at'], + }; +} + +export function GrantToJSON(value?: Grant | null): any { + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + return { + + 'grantee_address': value.granteeAddress, + 'user_id': value.userId, + 'is_revoked': value.isRevoked, + 'is_approved': value.isApproved, + 'created_at': value.createdAt, + 'updated_at': value.updatedAt, + }; +} + diff --git a/packages/libs/src/sdk/api/generated/full/models/ManagedUser.ts b/packages/libs/src/sdk/api/generated/full/models/ManagedUser.ts new file mode 100644 index 00000000000..b0eca3d878f --- /dev/null +++ b/packages/libs/src/sdk/api/generated/full/models/ManagedUser.ts @@ -0,0 +1,89 @@ +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck +/** + * API + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { exists, mapValues } from '../runtime'; +import type { Grant } from './Grant'; +import { + GrantFromJSON, + GrantFromJSONTyped, + GrantToJSON, +} from './Grant'; +import type { UserFull } from './UserFull'; +import { + UserFullFromJSON, + UserFullFromJSONTyped, + UserFullToJSON, +} from './UserFull'; + +/** + * + * @export + * @interface ManagedUser + */ +export interface ManagedUser { + /** + * + * @type {UserFull} + * @memberof ManagedUser + */ + user: UserFull; + /** + * + * @type {Grant} + * @memberof ManagedUser + */ + grant: Grant; +} + +/** + * Check if a given object implements the ManagedUser interface. + */ +export function instanceOfManagedUser(value: object): boolean { + let isInstance = true; + isInstance = isInstance && "user" in value; + isInstance = isInstance && "grant" in value; + + return isInstance; +} + +export function ManagedUserFromJSON(json: any): ManagedUser { + return ManagedUserFromJSONTyped(json, false); +} + +export function ManagedUserFromJSONTyped(json: any, ignoreDiscriminator: boolean): ManagedUser { + if ((json === undefined) || (json === null)) { + return json; + } + return { + + 'user': UserFullFromJSON(json['user']), + 'grant': GrantFromJSON(json['grant']), + }; +} + +export function ManagedUserToJSON(value?: ManagedUser | null): any { + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + return { + + 'user': UserFullToJSON(value.user), + 'grant': GrantToJSON(value.grant), + }; +} + diff --git a/packages/libs/src/sdk/api/generated/full/models/ManagedUsersResponse.ts b/packages/libs/src/sdk/api/generated/full/models/ManagedUsersResponse.ts new file mode 100644 index 00000000000..f6881d7871d --- /dev/null +++ b/packages/libs/src/sdk/api/generated/full/models/ManagedUsersResponse.ts @@ -0,0 +1,73 @@ +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck +/** + * API + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { exists, mapValues } from '../runtime'; +import type { ManagedUser } from './ManagedUser'; +import { + ManagedUserFromJSON, + ManagedUserFromJSONTyped, + ManagedUserToJSON, +} from './ManagedUser'; + +/** + * + * @export + * @interface ManagedUsersResponse + */ +export interface ManagedUsersResponse { + /** + * + * @type {Array} + * @memberof ManagedUsersResponse + */ + data?: Array; +} + +/** + * Check if a given object implements the ManagedUsersResponse interface. + */ +export function instanceOfManagedUsersResponse(value: object): boolean { + let isInstance = true; + + return isInstance; +} + +export function ManagedUsersResponseFromJSON(json: any): ManagedUsersResponse { + return ManagedUsersResponseFromJSONTyped(json, false); +} + +export function ManagedUsersResponseFromJSONTyped(json: any, ignoreDiscriminator: boolean): ManagedUsersResponse { + if ((json === undefined) || (json === null)) { + return json; + } + return { + + 'data': !exists(json, 'data') ? undefined : ((json['data'] as Array).map(ManagedUserFromJSON)), + }; +} + +export function ManagedUsersResponseToJSON(value?: ManagedUsersResponse | null): any { + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + return { + + 'data': value.data === undefined ? undefined : ((value.data as Array).map(ManagedUserToJSON)), + }; +} + diff --git a/packages/libs/src/sdk/api/generated/full/models/index.ts b/packages/libs/src/sdk/api/generated/full/models/index.ts index d748115604c..f3f9c98d09f 100644 --- a/packages/libs/src/sdk/api/generated/full/models/index.ts +++ b/packages/libs/src/sdk/api/generated/full/models/index.ts @@ -34,7 +34,10 @@ export * from './FullTracksResponse'; export * from './FullTrendingPlaylistsResponse'; export * from './FullUserResponse'; export * from './GetTipsResponse'; +export * from './Grant'; export * from './HistoryResponseFull'; +export * from './ManagedUser'; +export * from './ManagedUsersResponse'; export * from './PlaylistAddedTimestamp'; export * from './PlaylistArtwork'; export * from './PlaylistFull'; From 9b7e660e798c96152359588b3ff45b145bacb237 Mon Sep 17 00:00:00 2001 From: Randy Schott <1815175+schottra@users.noreply.github.com> Date: Mon, 29 Apr 2024 16:12:02 -0400 Subject: [PATCH 09/10] add managers endpoint --- .../queries/test_get_managed_users.py | 218 ++++++++++++++---- .../discovery-provider/src/api/v1/helpers.py | 7 + .../src/api/v1/models/grants.py | 8 + .../discovery-provider/src/api/v1/users.py | 48 +++- .../src/queries/get_managed_users.py | 76 +++++- .../src/queries/get_unpopulated_users.py | 33 ++- .../generated/full/.openapi-generator/FILES | 2 + .../sdk/api/generated/full/apis/UsersApi.ts | 38 +++ .../generated/full/models/ManagersResponse.ts | 73 ++++++ .../api/generated/full/models/UserManager.ts | 89 +++++++ .../sdk/api/generated/full/models/index.ts | 2 + 11 files changed, 539 insertions(+), 55 deletions(-) create mode 100644 packages/libs/src/sdk/api/generated/full/models/ManagersResponse.ts create mode 100644 packages/libs/src/sdk/api/generated/full/models/UserManager.ts diff --git a/packages/discovery-provider/integration_tests/queries/test_get_managed_users.py b/packages/discovery-provider/integration_tests/queries/test_get_managed_users.py index 294e5b711f0..ebaa4941bae 100644 --- a/packages/discovery-provider/integration_tests/queries/test_get_managed_users.py +++ b/packages/discovery-provider/integration_tests/queries/test_get_managed_users.py @@ -1,61 +1,100 @@ import copy from integration_tests.utils import populate_mock_db -from src.queries.get_managed_users import get_managed_users_with_grants +from src.queries.get_managed_users import get_managed_users_with_grants, get_user_managers_with_grants from src.utils.db_session import get_db -test_entities = { - "users": [ - {"user_id": 10, "name": "a", "wallet": "0x10"}, - {"user_id": 20, "name": "b", "wallet": "0x20"}, - {"user_id": 30, "name": "c", "wallet": "0x30"}, - {"user_id": 40, "name": "d", "wallet": "0x40"}, - {"user_id": 50, "name": "e", "wallet": "0x50"}, - {"user_id": 60, "name": "f", "wallet": "0x60"}, - ], - "grants": [ - # Active grants - { - "user_id": 20, - "grantee_address": "0x10", - "is_approved": True, - "is_revoked": False, - }, - { - "user_id": 30, - "grantee_address": "0x10", - "is_approved": True, - "is_revoked": False, - }, - # Not yet approved - { - "user_id": 40, - "grantee_address": "0x10", - "is_approved": False, - "is_revoked": False, - }, - # Approved then Revoked - { - "user_id": 50, - "grantee_address": "0x10", - "is_approved": True, - "is_revoked": True, - }, - # Revoked before approval - { - "user_id": 60, - "grantee_address": "0x10", - "is_approved": False, - "is_revoked": True, - }, - ], -} + +test_users = [ + {"user_id": 10, "name": "a", "wallet": "0x10"}, + {"user_id": 20, "name": "b", "wallet": "0x20"}, + {"user_id": 30, "name": "c", "wallet": "0x30"}, + {"user_id": 40, "name": "d", "wallet": "0x40"}, + {"user_id": 50, "name": "e", "wallet": "0x50"}, + {"user_id": 60, "name": "f", "wallet": "0x60"}, +] + +test_managed_user_grants = [ + # Active grants + { + "user_id": 20, + "grantee_address": "0x10", + "is_approved": True, + "is_revoked": False, + }, + { + "user_id": 30, + "grantee_address": "0x10", + "is_approved": True, + "is_revoked": False, + }, + # Not yet approved + { + "user_id": 40, + "grantee_address": "0x10", + "is_approved": False, + "is_revoked": False, + }, + # Approved then Revoked + { + "user_id": 50, + "grantee_address": "0x10", + "is_approved": True, + "is_revoked": True, + }, + # Revoked before approval + { + "user_id": 60, + "grantee_address": "0x10", + "is_approved": False, + "is_revoked": True, + }, +] + +test_user_manager_grants = [ + # Active grants + { + "user_id": 10, + "grantee_address": "0x20", + "is_approved": True, + "is_revoked": False, + }, + { + "user_id": 10, + "grantee_address": "0x30", + "is_approved": True, + "is_revoked": False, + }, + # Not yet approved + { + "user_id": 10, + "grantee_address": "0x40", + "is_approved": False, + "is_revoked": False, + }, + # Approved then Revoked + { + "user_id": 10, + "grantee_address": "0x50", + "is_approved": True, + "is_revoked": True, + }, + # Revoked before approval + { + "user_id": 10, + "grantee_address": "0x60", + "is_approved": False, + "is_revoked": True, + } +] + +# ### get_managed_users ### # def test_get_managed_users_default(app): with app.app_context(): db = get_db() - populate_mock_db(db, test_entities) + populate_mock_db(db, {"users": test_users, "grants": test_managed_user_grants}) managed_users = get_managed_users_with_grants( {"manager_wallet_address": "0x10", "current_user_id": 10} @@ -71,7 +110,7 @@ def test_get_managed_users_default(app): def test_get_managed_users_no_filters(app): with app.app_context(): db = get_db() - populate_mock_db(db, test_entities) + populate_mock_db(db, {"users": test_users, "grants": test_managed_user_grants}) managed_users = get_managed_users_with_grants( { @@ -90,7 +129,7 @@ def test_get_managed_users_grants_without_users(app): with app.app_context(): db = get_db() - entities = copy.deepcopy(test_entities) + entities = ({"users": copy.deepcopy(test_users), "grants": copy.deepcopy(test_managed_user_grants)}) # Record for a user which won't be found entities["grants"].append( { @@ -116,7 +155,7 @@ def test_get_managed_users_grants_without_users(app): def test_get_managed_users_invalid_parameters(app): with app.app_context(): db = get_db() - populate_mock_db(db, test_entities) + populate_mock_db(db, {"users": test_users, "grants": test_managed_user_grants}) try: get_managed_users_with_grants( @@ -133,3 +172,80 @@ def test_get_managed_users_invalid_parameters(app): assert False, "Should have thrown an error for missing current user id" except ValueError as e: assert str(e) == "current_user_id is required" + + +# ### get_user_managers ### # + + +def test_get_user_managers_default(app): + with app.app_context(): + db = get_db() + populate_mock_db(db, {"users": test_users, "grants": test_user_manager_grants}) + + user_managers = get_user_managers_with_grants( + {"user_id": 10} + ) + + # return all non-revoked records by default + assert len(user_managers) == 3, "Expected exactly 3 records" + assert ( + record["grant"]["is_revoked"] == False for record in user_managers + ), "Revoked records returned" + + +def test_get_user_managers_no_filters(app): + with app.app_context(): + db = get_db() + populate_mock_db(db, {"users": test_users, "grants": test_user_manager_grants}) + + user_managers = get_user_managers_with_grants( + { + "user_id": 10, + "is_approved": None, + "is_revoked": None, + } + ) + + # return all records which map to users + assert len(user_managers) == 5, "Expected exactly 5 records" + + +def test_get_user_managers_grants_without_users(app): + with app.app_context(): + db = get_db() + + entities = {"users": copy.deepcopy(test_users), "grants": copy.deepcopy(test_user_manager_grants)} + # Record for a user which won't be found + entities["grants"].append( + { + "user_id": 10, + "grantee_address": "0x70", + "is_approved": False, + "is_revoked": False, + } + ) + populate_mock_db(db, entities) + + user_managers = get_user_managers_with_grants( + {"user_id": 10} + ) + + # return all non-revoked records by default + assert len(user_managers) == 3, "Expected exactly 3 records" + assert ( + record["grant"]["grantee_address"] != "0x70" for record in user_managers + ), "Revoked records returned" + + +def test_get_user_managers_invalid_parameters(app): + with app.app_context(): + db = get_db() + populate_mock_db(db, {"users": test_users, "grants": test_user_manager_grants}) + + try: + get_user_managers_with_grants( + {} + ) + assert False, "Should have thrown an error for missing user id" + except ValueError as e: + assert str(e) == "user_id is required" diff --git a/packages/discovery-provider/src/api/v1/helpers.py b/packages/discovery-provider/src/api/v1/helpers.py index ed7bc05e0d6..e6121ed21cd 100644 --- a/packages/discovery-provider/src/api/v1/helpers.py +++ b/packages/discovery-provider/src/api/v1/helpers.py @@ -1005,6 +1005,13 @@ def format_managed_user(managed_user): } +def format_user_manager(user_manager): + return { + "manager": extend_user(user_manager["manager"]), + "grant": format_grant(user_manager["grant"]), + } + + def format_dashboard_wallet_user(dashboard_wallet_user): return { "wallet": dashboard_wallet_user["wallet"], diff --git a/packages/discovery-provider/src/api/v1/models/grants.py b/packages/discovery-provider/src/api/v1/models/grants.py index 45ec284d630..5d112789155 100644 --- a/packages/discovery-provider/src/api/v1/models/grants.py +++ b/packages/discovery-provider/src/api/v1/models/grants.py @@ -23,3 +23,11 @@ "grant": fields.Nested(grant, required=True), }, ) + +user_manager = ns.model( + "user_manager", + { + "manager": fields.Nested(user_model_full, required=True), + "grant": fields.Nested(grant, required=True), + }, +) diff --git a/packages/discovery-provider/src/api/v1/users.py b/packages/discovery-provider/src/api/v1/users.py index 07b222f8c79..84205c57019 100644 --- a/packages/discovery-provider/src/api/v1/users.py +++ b/packages/discovery-provider/src/api/v1/users.py @@ -32,6 +32,7 @@ format_query, format_sort_direction, format_sort_method, + format_user_manager, get_current_user_id, get_default_max, make_full_response, @@ -57,7 +58,7 @@ ) from src.api.v1.models.common import favorite from src.api.v1.models.developer_apps import authorized_app, developer_app -from src.api.v1.models.grants import managed_user +from src.api.v1.models.grants import managed_user, user_manager from src.api.v1.models.support import ( supporter_response, supporter_response_full, @@ -104,7 +105,9 @@ from src.queries.get_followers_for_user import get_followers_for_user from src.queries.get_managed_users import ( GetManagedUsersArgs, + GetUserManagersArgs, get_managed_users_with_grants, + get_user_managers_with_grants, ) from src.queries.get_related_artists import get_related_artists from src.queries.get_repost_feed_for_user import get_repost_feed_for_user @@ -2152,6 +2155,49 @@ def get(self, id, authed_user_id, authed_user_wallet): return success_response(users) +managers_response = make_response( + "managers_response", full_ns, fields.List(fields.Nested(user_manager)) +) + + +@full_ns.route("//managers") +class Managers(Resource): + @record_metrics + @full_ns.doc( + id="""Get Managers""", + description="""Gets a list of users managing the given user""", + params={"id": "An id for the managed user"}, + responses={ + 200: "Success", + 400: "Bad request", + 401: "Unauthorized", + 403: "Forbidden", + 500: "Server error", + }, + ) + @auth_middleware(include_wallet=True) + @full_ns.marshal_with(managers_response) + def get(self, id, authed_user_id, authed_user_wallet): + user_id = decode_with_abort(id, full_ns) + + if authed_user_id is None: + abort_unauthorized(full_ns) + + # TODO: If accessing this endpoint as a manager, this check will not + # work correctly. + # https://linear.app/audius/issue/PAY-2780/support-getting-target-user-in-auth-middleware + if authed_user_id != user_id: + abort_forbidden(full_ns) + + args = GetUserManagersArgs( + manager_wallet_address=authed_user_wallet, user_id=user_id + ) + managers = get_user_managers_with_grants(args) + managers = list(map(format_user_manager, managers)) + + return success_response(managers) + + purchases_and_sales_parser = pagination_with_current_user_parser.copy() purchases_and_sales_parser.add_argument( "sort_method", diff --git a/packages/discovery-provider/src/queries/get_managed_users.py b/packages/discovery-provider/src/queries/get_managed_users.py index 08f28182936..b7d170e63ac 100644 --- a/packages/discovery-provider/src/queries/get_managed_users.py +++ b/packages/discovery-provider/src/queries/get_managed_users.py @@ -2,7 +2,10 @@ from typing import Dict, List, Optional, TypedDict from src.models.grants.grant import Grant -from src.queries.get_unpopulated_users import get_unpopulated_users +from src.queries.get_unpopulated_users import ( + get_unpopulated_users, + get_unpopulated_users_by_wallet, +) from src.queries.query_helpers import populate_user_metadata from src.utils import db_session from src.utils.helpers import query_result_to_list @@ -17,6 +20,77 @@ class GetManagedUsersArgs(TypedDict): is_revoked: Optional[bool] +class GetUserManagersArgs(TypedDict): + user_id: int + is_approved: Optional[bool] + is_revoked: Optional[bool] + + +def make_user_managers_list(managers: List[Dict], grants: List[Dict]) -> List[Dict]: + user_managers = [] + grants_map = {grant.get("grantee_address"): grant for grant in grants} + + for user in managers: + grant = grants_map.get(user.get("wallet")) + if grant is None: + continue + + user_managers.append( + { + "manager": user, + "grant": grant, + } + ) + + return user_managers + + +def get_user_managers_with_grants(args: GetUserManagersArgs) -> List[Dict]: + """ + Returns users which manage the given user + + Args: + user_id: id of the managed user + is_approved: Optional[bool] If set, filters by approval status + is_revoked: Optional[bool] If set, filters by revocation status, defaults to False + + Returns: + List of Users with grant information + """ + is_approved = args.get("is_approved", None) + is_revoked = args.get("is_revoked", False) + user_id = args.get("user_id") + + if user_id is None: + raise ValueError("user_id is required") + + db = db_session.get_db_read_replica() + with db.scoped_session() as session: + query = session.query(Grant).filter( + Grant.user_id == user_id, Grant.is_current == True + ) + + if is_approved is not None: + query = query.filter(Grant.is_approved == is_approved) + if is_revoked is not None: + query = query.filter(Grant.is_revoked == is_revoked) + + grants = query.all() + if len(grants) == 0: + return [] + + wallet_addresses = [grant.grantee_address for grant in grants] + users = get_unpopulated_users_by_wallet(session, wallet_addresses) + user_ids = [user.get("user_id") for user in users] + managers = populate_user_metadata( + session, user_ids, users, current_user_id=user_id + ) + + grants = query_result_to_list(grants) + + return make_user_managers_list(managers, grants) + + def make_managed_users_list(users: List[Dict], grants: List[Dict]) -> List[Dict]: managed_users = [] grants_map = {grant.get("user_id"): grant for grant in grants} diff --git a/packages/discovery-provider/src/queries/get_unpopulated_users.py b/packages/discovery-provider/src/queries/get_unpopulated_users.py index d09615bc348..d80d85b67ae 100644 --- a/packages/discovery-provider/src/queries/get_unpopulated_users.py +++ b/packages/discovery-provider/src/queries/get_unpopulated_users.py @@ -14,8 +14,7 @@ def get_unpopulated_users(session, user_ids): """ - Fetches users by checking the redis cache first then - going to DB and writes to cache if not present + Fetches a list of users based on an input array of ids Args: session: DB session @@ -40,3 +39,33 @@ def get_unpopulated_users(session, user_ids): users_response.append(queried_users[user_id]) return users_response + + +def get_unpopulated_users_by_wallet(session, wallet_addresses): + """ + Fetch users based on an input array of wallet addresses + + Args: + session: DB session + wallet_addresses: array A list of wallet addresses + + Returns: + Array of users + """ + + wallets_lower = [wallet.lower() for wallet in wallet_addresses] + users = ( + session.query(User) + .filter(User.is_current == True, User.handle != None) + .filter(User.wallet.in_(wallets_lower)) + .all() + ) + users = helpers.query_result_to_list(users) + queried_users = {user["wallet"]: user for user in users} + + users_response = [] + for wallet in wallets_lower: + if wallet in queried_users: + users_response.append(queried_users[wallet]) + + return users_response diff --git a/packages/libs/src/sdk/api/generated/full/.openapi-generator/FILES b/packages/libs/src/sdk/api/generated/full/.openapi-generator/FILES index e58a74ff2ad..7de06a125a5 100644 --- a/packages/libs/src/sdk/api/generated/full/.openapi-generator/FILES +++ b/packages/libs/src/sdk/api/generated/full/.openapi-generator/FILES @@ -46,6 +46,7 @@ models/Grant.ts models/HistoryResponseFull.ts models/ManagedUser.ts models/ManagedUsersResponse.ts +models/ManagersResponse.ts models/PlaylistAddedTimestamp.ts models/PlaylistArtwork.ts models/PlaylistFull.ts @@ -83,6 +84,7 @@ models/TransactionHistoryResponse.ts models/TrendingIdsResponse.ts models/TrendingTimesIds.ts models/UserFull.ts +models/UserManager.ts models/UserReplicaSet.ts models/UserSubscribers.ts models/UsersByContentNode.ts diff --git a/packages/libs/src/sdk/api/generated/full/apis/UsersApi.ts b/packages/libs/src/sdk/api/generated/full/apis/UsersApi.ts index b2eea5a83f2..48aa57fa6db 100644 --- a/packages/libs/src/sdk/api/generated/full/apis/UsersApi.ts +++ b/packages/libs/src/sdk/api/generated/full/apis/UsersApi.ts @@ -29,6 +29,7 @@ import type { FullUserResponse, HistoryResponseFull, ManagedUsersResponse, + ManagersResponse, PurchasesCountResponse, PurchasesResponse, RelatedArtistResponseFull, @@ -66,6 +67,8 @@ import { HistoryResponseFullToJSON, ManagedUsersResponseFromJSON, ManagedUsersResponseToJSON, + ManagersResponseFromJSON, + ManagersResponseToJSON, PurchasesCountResponseFromJSON, PurchasesCountResponseToJSON, PurchasesResponseFromJSON, @@ -152,6 +155,10 @@ export interface GetManagedUsersRequest { id: string; } +export interface GetManagersRequest { + id: string; +} + export interface GetPurchasesRequest { id: string; offset?: number; @@ -782,6 +789,37 @@ export class UsersApi extends runtime.BaseAPI { return await response.value(); } + /** + * @hidden + * Gets a list of users managing the given user + */ + async getManagersRaw(params: GetManagersRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + if (params.id === null || params.id === undefined) { + throw new runtime.RequiredError('id','Required parameter params.id was null or undefined when calling getManagers.'); + } + + const queryParameters: any = {}; + + const headerParameters: runtime.HTTPHeaders = {}; + + const response = await this.request({ + path: `/users/{id}/managers`.replace(`{${"id"}}`, encodeURIComponent(String(params.id))), + method: 'GET', + headers: headerParameters, + query: queryParameters, + }, initOverrides); + + return new runtime.JSONApiResponse(response, (jsonValue) => ManagersResponseFromJSON(jsonValue)); + } + + /** + * Gets a list of users managing the given user + */ + async getManagers(params: GetManagersRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + const response = await this.getManagersRaw(params, initOverrides); + return await response.value(); + } + /** * @hidden * Gets the purchases the user has made diff --git a/packages/libs/src/sdk/api/generated/full/models/ManagersResponse.ts b/packages/libs/src/sdk/api/generated/full/models/ManagersResponse.ts new file mode 100644 index 00000000000..f9f7fd470ed --- /dev/null +++ b/packages/libs/src/sdk/api/generated/full/models/ManagersResponse.ts @@ -0,0 +1,73 @@ +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck +/** + * API + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { exists, mapValues } from '../runtime'; +import type { UserManager } from './UserManager'; +import { + UserManagerFromJSON, + UserManagerFromJSONTyped, + UserManagerToJSON, +} from './UserManager'; + +/** + * + * @export + * @interface ManagersResponse + */ +export interface ManagersResponse { + /** + * + * @type {Array} + * @memberof ManagersResponse + */ + data?: Array; +} + +/** + * Check if a given object implements the ManagersResponse interface. + */ +export function instanceOfManagersResponse(value: object): boolean { + let isInstance = true; + + return isInstance; +} + +export function ManagersResponseFromJSON(json: any): ManagersResponse { + return ManagersResponseFromJSONTyped(json, false); +} + +export function ManagersResponseFromJSONTyped(json: any, ignoreDiscriminator: boolean): ManagersResponse { + if ((json === undefined) || (json === null)) { + return json; + } + return { + + 'data': !exists(json, 'data') ? undefined : ((json['data'] as Array).map(UserManagerFromJSON)), + }; +} + +export function ManagersResponseToJSON(value?: ManagersResponse | null): any { + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + return { + + 'data': value.data === undefined ? undefined : ((value.data as Array).map(UserManagerToJSON)), + }; +} + diff --git a/packages/libs/src/sdk/api/generated/full/models/UserManager.ts b/packages/libs/src/sdk/api/generated/full/models/UserManager.ts new file mode 100644 index 00000000000..73b775dcd09 --- /dev/null +++ b/packages/libs/src/sdk/api/generated/full/models/UserManager.ts @@ -0,0 +1,89 @@ +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck +/** + * API + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { exists, mapValues } from '../runtime'; +import type { Grant } from './Grant'; +import { + GrantFromJSON, + GrantFromJSONTyped, + GrantToJSON, +} from './Grant'; +import type { UserFull } from './UserFull'; +import { + UserFullFromJSON, + UserFullFromJSONTyped, + UserFullToJSON, +} from './UserFull'; + +/** + * + * @export + * @interface UserManager + */ +export interface UserManager { + /** + * + * @type {UserFull} + * @memberof UserManager + */ + manager: UserFull; + /** + * + * @type {Grant} + * @memberof UserManager + */ + grant: Grant; +} + +/** + * Check if a given object implements the UserManager interface. + */ +export function instanceOfUserManager(value: object): boolean { + let isInstance = true; + isInstance = isInstance && "manager" in value; + isInstance = isInstance && "grant" in value; + + return isInstance; +} + +export function UserManagerFromJSON(json: any): UserManager { + return UserManagerFromJSONTyped(json, false); +} + +export function UserManagerFromJSONTyped(json: any, ignoreDiscriminator: boolean): UserManager { + if ((json === undefined) || (json === null)) { + return json; + } + return { + + 'manager': UserFullFromJSON(json['manager']), + 'grant': GrantFromJSON(json['grant']), + }; +} + +export function UserManagerToJSON(value?: UserManager | null): any { + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + return { + + 'manager': UserFullToJSON(value.manager), + 'grant': GrantToJSON(value.grant), + }; +} + diff --git a/packages/libs/src/sdk/api/generated/full/models/index.ts b/packages/libs/src/sdk/api/generated/full/models/index.ts index f3f9c98d09f..f89a84ae65b 100644 --- a/packages/libs/src/sdk/api/generated/full/models/index.ts +++ b/packages/libs/src/sdk/api/generated/full/models/index.ts @@ -38,6 +38,7 @@ export * from './Grant'; export * from './HistoryResponseFull'; export * from './ManagedUser'; export * from './ManagedUsersResponse'; +export * from './ManagersResponse'; export * from './PlaylistAddedTimestamp'; export * from './PlaylistArtwork'; export * from './PlaylistFull'; @@ -75,6 +76,7 @@ export * from './TransactionHistoryResponse'; export * from './TrendingIdsResponse'; export * from './TrendingTimesIds'; export * from './UserFull'; +export * from './UserManager'; export * from './UserReplicaSet'; export * from './UserSubscribers'; export * from './UsersByContentNode'; From ca5ab6efd93125f401ee2ba8e61bf78448304f1e Mon Sep 17 00:00:00 2001 From: Randy Schott <1815175+schottra@users.noreply.github.com> Date: Mon, 29 Apr 2024 16:55:06 -0400 Subject: [PATCH 10/10] add changeset --- .changeset/sixty-bugs-eat.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/sixty-bugs-eat.md diff --git a/.changeset/sixty-bugs-eat.md b/.changeset/sixty-bugs-eat.md new file mode 100644 index 00000000000..b7373a5e925 --- /dev/null +++ b/.changeset/sixty-bugs-eat.md @@ -0,0 +1,5 @@ +--- +'@audius/sdk': minor +--- + +added endpoints for fetching user management relationships