Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[PAY-2761] Add managed users endpoints #8238

Merged
merged 10 commits into from
Apr 29, 2024
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import copy

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 = {
"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,
},
],
}


def test_get_managed_users_default(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}
)

# 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 won't be found
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice way of setting this up

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)

try:
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:
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"
18 changes: 18 additions & 0 deletions packages/discovery-provider/src/api/v1/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -987,6 +987,24 @@ def format_authorized_app(authorized_app):
}


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):
return {
"user": managed_user["user"],
"grant": format_grant(managed_user["grant"]),
}


def format_dashboard_wallet_user(dashboard_wallet_user):
return {
"wallet": dashboard_wallet_user["wallet"],
Expand Down
25 changes: 25 additions & 0 deletions packages/discovery-provider/src/api/v1/models/grants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from flask_restx import fields

from .common import ns
from .users import user_model

# 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),
"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, required=True),
"grant": fields.Nested(grant, required=True),
},
)
47 changes: 47 additions & 0 deletions packages/discovery-provider/src/api/v1/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
abort_bad_request_param,
abort_forbidden,
abort_not_found,
abort_unauthorized,
current_user_parser,
decode_with_abort,
extend_activity,
Expand All @@ -26,6 +27,7 @@
format_developer_app,
format_library_filter,
format_limit,
format_managed_user,
format_offset,
format_query,
format_sort_direction,
Expand Down Expand Up @@ -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 managed_user
from src.api.v1.models.support import (
supporter_response,
supporter_response_full,
Expand Down Expand Up @@ -99,6 +102,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_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
Expand Down Expand Up @@ -2105,6 +2112,46 @@ def get(self, id):
return success_response(authorized_apps)


managed_users_response = make_response(
"managed_users", ns, fields.List(fields.Nested(managed_user))
)


@ns.route("/<string:id>/managed_users")
class ManagedUsers(Resource):
@record_metrics
@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"},
responses={
200: "Success",
400: "Bad request",
401: "Unauthorized",
403: "Forbidden",
500: "Server error",
},
)
@auth_middleware(include_wallet=True)
@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)

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()
purchases_and_sales_parser.add_argument(
"sort_method",
Expand Down
81 changes: 81 additions & 0 deletions packages/discovery-provider/src/queries/get_managed_users.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import logging
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 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

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:
query = session.query(Grant).filter(
Grant.grantee_address == grantee_address, Grant.is_current == True
)

if is_approved is not None:
query = query.filter(Grant.is_approved == is_approved)
if is_revoked is not None:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

idt it's possible for is_revoked to actually be None

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The input is a dict, so it's possible to not set the value at all. I had assumed that results in None. I can add a test to make sure this actually does something.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a test which sets both filters to None and makes sure we get back all records (which are a mix of permutations of the two flags)

query = query.filter(Grant.is_revoked == is_revoked)

grants = 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 = query_result_to_list(grants)

return make_managed_users_list(users, grants)
19 changes: 16 additions & 3 deletions packages/discovery-provider/src/utils/auth_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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:
Expand All @@ -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),
Expand All @@ -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 (
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I learned today that if you pass a kv argument that isn't consumed, it will often generate an error.
So I had to do this weirdness of conditionally calling the inner function differently. I would have preferred to conditionally add to the arguments list. But I could not wrap my head around arguments in python well enough to do it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you could include in it kwargs, like this

def inner(*args, **kwargs):
  print("fun!", args, kwargs)

def outer(*args, **kwargs):
  kwargs["thing"] = True
  fun(*args, **kwargs)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh interesting. Maybe we should do that for the other variable as well then?
In my reading I couldn't find any suggestion that you can modify kwargs like that. But I'll give it a try.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ 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
Expand Down
Loading