feat(api): Finished initial route collection.

This commit is contained in:
2025-06-10 13:00:37 +10:00
parent 94bc8b6c21
commit dc551b34a9
9 changed files with 1430 additions and 10 deletions

View File

@@ -276,13 +276,15 @@ SELECT
cheer_events.message AS cheer_message,
subscriber_events.subscribed_length AS subscriber_length,
subscriber_events.tier AS subscriber_tier,
subscriber_events.message AS subscriber_message
subscriber_events.message AS subscriber_message,
documents.seal AS document_seal
FROM
events
LEFT JOIN plain_events USING (event_id)
LEFT JOIN raid_events USING (event_id)
LEFT JOIN cheer_events USING (event_id)
LEFT JOIN subscriber_events USING (event_id)
LEFT JOIN documents USING (document_id)
ORDER BY events.occurred_at ASC;
-- }}}
@@ -295,6 +297,7 @@ CREATE TABLE user_specimens (
born_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
forgotten_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX ON user_specimens (owner_id) WHERE forgotten_at IS NULL;
-- }}}

View File

@@ -10,15 +10,25 @@ from utils.auth import key_auth_factory
from datamodels import DataModel
from constants import DATA_VERSION
from modules.profiles.data import ProfileData
from routes.stamps import routes as stamp_routes
from routes.documents import routes as doc_routes
from routes.lib import dbvar, datamodelsv
from routes.users import routes as user_routes
from routes.specimens import routes as spec_routes
from routes.transactions import routes as txn_routes
from routes.events import routes as event_routes
from routes.lib import dbvar, datamodelsv, profiledatav
sys.path.insert(0, os.path.join(os.getcwd()))
sys.path.insert(0, os.path.join(os.getcwd(), "src"))
logger = logging.getLogger(__name__)
# TODO: Move the route table to the __init__ of routes
# Maybe we can join route tables together?
# Or we just expose an add_routes or register method
"""
- `/stamps` with `POST`, `PUT`, `GET`
- `/stamps/{stamp_id}` with `GET`, `PATCH`, `DELETE`
@@ -61,8 +71,15 @@ async def attach_db(app: web.Application):
datamodel = DataModel()
db.load_registry(datamodel)
await datamodel.init()
profiledata = ProfileData()
db.load_registry(profiledata)
await profiledata.init()
app[dbvar] = db
app[datamodelsv] = datamodel
app[profiledatav] = profiledata
yield
@@ -76,6 +93,10 @@ async def app_factory():
app.router.add_get('/', test)
app.router.add_routes(stamp_routes)
app.router.add_routes(doc_routes)
app.router.add_routes(user_routes)
app.router.add_routes(spec_routes)
app.router.add_routes(event_routes)
app.router.add_routes(txn_routes)
return app

View File

@@ -46,10 +46,10 @@ class DataModel(Registry):
LEFT JOIN profiles_twitch USING (profileid)
LEFT JOIN user_preferences USING (profileid);
"""
_tablename_ = ''
_tablename_ = 'dreamers'
_readonly_ = True
profileid = Integer(primary=True)
user_id = Integer(primary=True)
name = String()
twitch_id = Integer()
preferences = String()
@@ -227,12 +227,14 @@ class DataModel(Registry):
subscriber_events.subscribed_length AS subscriber_length,
subscriber_events.tier AS subscriber_tier,
subscriber_events.message AS subscriber_message,
documents.seal AS document_seal
FROM
events
LEFT JOIN plain_events USING (event_id)
LEFT JOIN raid_events USING (event_id)
LEFT JOIN cheer_events USING (event_id)
LEFT JOIN subscriber_events USING (event_id)
LEFT JOIN documents USING (document_id)
ORDER BY events.occurred_at ASC;
"""
_tablename_ = 'event_details'
@@ -253,6 +255,7 @@ class DataModel(Registry):
subscriber_length = Integer()
subscriber_tier = Integer()
subscriber_message = String()
document_seal = Integer()
class Specimen(RowModel):
@@ -265,6 +268,7 @@ class DataModel(Registry):
born_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
forgotten_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX ON user_specimens (owner_id) WHERE forgotten_at IS NULL;
"""
_tablename_ = 'user_specimens'
_cache_ = {}

View File

@@ -3,6 +3,7 @@
- `/documents/{document_id}` with `GET`, `PATCH`, `DELETE`
- `/documents/{document_id}/stamps` which is passed to `/stamps` with `document_id` set.
"""
import logging
from datetime import datetime
from typing import Any, NamedTuple, Optional, Self, TypedDict, Unpack, reveal_type, List
from aiohttp import web
@@ -14,6 +15,7 @@ from .lib import ModelField, datamodelsv
from .stamps import Stamp, StampCreateParams, StampEditParams, StampPayload
routes = web.RouteTableDef()
logger = logging.getLogger(__name__)
class DocPayload(TypedDict):
@@ -61,6 +63,13 @@ class Document:
self.data = app[datamodelsv]
self.row = row
@classmethod
async def validate_create_params(cls, params):
if extra := next((key for key in params if key not in create_fields), None):
raise web.HTTPBadRequest(text=f"Invalid key '{extra}' passed to document creation.")
if missing := next((key for key in req_fields if key not in params), None):
raise web.HTTPBadRequest(text=f"Document params missing required key '{missing}'")
@classmethod
async def fetch_from_id(cls, app: web.Application, document_id: int) -> Optional[Self]:
data = app[datamodelsv]
@@ -222,10 +231,7 @@ class DocumentsView(web.View):
if key in request:
params.setdefault(key, request[key])
if extra := next((key for key in params if key not in create_fields), None):
raise web.HTTPBadRequest(text=f"Invalid key '{extra}' passed to document creation.")
if missing := next((key for key in req_fields if key not in params), None):
raise web.HTTPBadRequest(text=f"Document params missing required key '{missing}'")
await Document.validate_create_params(params)
document = await Document.create(self.request.app, **params)
payload = await document.prepare()
@@ -276,15 +282,18 @@ class DocumentView(web.View):
@routes.route('*', "/documents/{document_id}{tail:/stamps}")
@routes.route('*', "/documents/{document_id}{tail:/stamps/.*}")
async def document_stamps_route(request: web.Request):
document_id = request.match_info['document_id']
document = await Document.fetch_from_id(request.app, int(document_id))
document_id = int(request.match_info['document_id'])
document = await Document.fetch_from_id(request.app, document_id)
if document is None:
raise web.HTTPNotFound(text="No document exists with the given ID.")
new_path = request.match_info['tail']
logger.info(f"Redirecting {request=} to {new_path=} and setting {document_id=}")
new_request = request.clone(rel_url=new_path)
new_request['document_id'] = document_id
match_info = await request.app.router.resolve(new_request)
match_info.current_app = request.app
new_request._match_info = match_info
if match_info.handler:
return await match_info.handler(new_request)
else:

434
src/routes/events.py Normal file
View File

@@ -0,0 +1,434 @@
import logging
from datetime import datetime
from typing import Any, Literal, NamedTuple, Optional, Self, TypedDict, Unpack, overload, reveal_type, List
from aiohttp import web
from data import Condition, condition
from data.conditions import NULL
from data.queries import JOINTYPE
from datamodels import DataModel, EventType
from modules.profiles.data import ProfileData
from .lib import ModelField, datamodelsv, dbvar, profiledatav
from .specimens import Specimen, SpecimenPayload
routes = web.RouteTableDef()
logger = logging.getLogger(__name__)
class Event:
def __init__(self, app: web.Application, row: DataModel.EventDetails):
self.app = app
self.data = app[datamodelsv]
self.row = row
@classmethod
async def fetch_from_id(cls, app: web.Application, event_id: int):
data = app[datamodelsv]
row = await data.EventDetails.fetch(int(event_id))
return cls(app, row) if row is not None else None
@classmethod
async def query(
cls,
app: web.Application,
event_id: Optional[str] = None,
document_id: Optional[str] = None,
document_seal: Optional[str] = None,
user_id: Optional[str] = None,
user_name: Optional[str] = None,
occurred_before: Optional[str] = None,
occurred_after: Optional[str] = None,
created_before: Optional[str] = None,
created_after: Optional[str] = None,
event_type: Optional[str] = None,
) -> List[Self]:
data = app[datamodelsv]
EventD = data.EventDetails
conds = []
if event_id is not None:
conds.append(EventD.event_id == int(event_id))
if document_id is not None:
conds.append(EventD.document_id == int(document_id))
if document_seal is not None:
conds.append(EventD.document_seal == int(document_seal))
if user_id is not None:
conds.append(EventD.user_id == int(user_id))
if user_name is not None:
conds.append(EventD.user_name == user_name)
if created_before is not None:
cbefore = datetime.fromisoformat(created_before)
conds.append(EventD.created_at <= cbefore)
if created_after is not None:
cafter = datetime.fromisoformat(created_after)
conds.append(EventD.created_at >= cafter)
if occurred_before is not None:
before = datetime.fromisoformat(occurred_before)
conds.append(EventD.occurred_at <= before)
if occurred_after is not None:
after = datetime.fromisoformat(occurred_after)
conds.append(EventD.occurred_at >= after)
if event_type is not None:
ekey = (event_type.lower().strip(),)
if ekey not in EventType:
raise web.HTTPBadRequest(text=f"Unknown event type '{event_type}'")
conds.append(EventD.event_type == EventType(ekey))
rows = await EventD.fetch_where(*conds).order_by(EventD.occurred_at)
return [cls(app, row) for row in rows]
@classmethod
async def validate_create_params(cls, params):
if 'event_type' not in params:
raise web.HTTPBadRequest(text="Event creation missing required field 'event_type'.")
ekey = (params['event_type'].lower().strip(),)
if ekey not in EventType:
raise web.HTTPBadRequest(text=f"Unknown event type '{params['event_type']}'")
event_type = EventType(ekey)
req_fields = {
'user_name', 'occurred_at', 'event_type',
}
other_fields = {
'document_id', 'document',
'user_id', 'user',
}
if 'user_id' not in params and 'user' not in params:
raise web.HTTPBadRequest(text="One of 'user_id' or 'user' must be supplied to create Event.")
match event_type:
case EventType.PLAIN:
req_fields.add('message')
case EventType.SUBSCRIBER:
req_fields.add('tier')
req_fields.add('subscribed_length')
other_fields.add('message')
case EventType.CHEER:
req_fields.add('amount')
other_fields.add('cheer_type')
other_fields.add('message')
case EventType.RAID:
req_fields.add('viewer_count')
create_fields = req_fields.union(other_fields)
if extra := next((key for key in params if key not in create_fields), None):
raise web.HTTPBadRequest(text=f"Invalid key '{extra}' passed to {event_type} event creation.")
if missing := next((key for key in req_fields if key not in params), None):
raise web.HTTPBadRequest(text=f"{event_type} Event params missing required key '{missing}'")
@classmethod
async def create(cls, app: web.Application, **kwargs):
data = app[datamodelsv]
# EventD = data.EventDetails
ekey = (kwargs['event_type'].lower().strip(),)
if ekey not in EventType:
raise web.HTTPBadRequest(text=f"Unknown event type '{kwargs['event_type']}'")
event_type = EventType(ekey)
params = {}
typparams = {}
match event_type:
case EventType.PLAIN:
typtab = data.plain_events
typparams['message'] = kwargs['message']
case EventType.CHEER:
typtab = data.cheer_events
typparams['amount'] = kwargs['amount']
typparams['cheer_type'] = kwargs.get('cheer_type')
typparams['message'] = kwargs.get('message')
case EventType.RAID:
typtab = data.raid_events
typparams['viewer_count'] = kwargs.get('viewer_count')
case EventType.SUBSCRIBER:
typtab = data.subscriber_events
typparams['tier'] = kwargs['tier']
typparams['subscribed_length'] = kwargs['subscribed_length']
typparams['message'] = kwargs.get('message')
case _:
raise ValueError("Invalid EventType")
# TODO: This really really should be a transaction
# Create Document if required
if 'document' in kwargs:
from .documents import Document
doc_args = kwargs['document']
await Document.validate_create_params(doc_args)
doc = await Document.create(app, **doc_args)
document_id = doc.row.document_id
params['document_id'] = document_id
elif 'document_id' in kwargs:
document_id = kwargs['document_id']
params['document_id'] = document_id
# Create User if required
if 'user' in kwargs:
from .users import User
user_args = kwargs['user']
await User.validate_create_params(user_args)
user = await User.create(app, **user_args)
user_id = user.row.user_id
if 'user_id' in kwargs and not kwargs['user_id'] == user_id:
raise web.HTTPBadRequest(text="Provided 'user_id' does not match provided 'user'.")
else:
user_id = kwargs['user_id']
params['user_id'] = user_id
# Create Event row
params['event_type'] = event_type
params['user_name'] = kwargs['user_name']
params['occurred_at'] = datetime.fromisoformat(kwargs['occurred_at'])
eventrow = await data.Events.create(**params)
typparams['event_id'] = eventrow.event_id
# Create Event type row
typrow = await typtab.insert(**typparams)
details = await data.EventDetails.fetch(eventrow.event_id)
assert details is not None
return cls(app, details)
async def edit(self, **kwargs):
data = self.data
# EventD = data.EventDetails
if 'event_type' in kwargs:
raise web.HTTPBadRequest(text="You cannot change the type of an event after creation.")
typparams = {}
match self.row.event_type:
case EventType.PLAIN:
typtab = data.plain_events
if 'message' in kwargs:
typparams['message'] = kwargs['message']
case EventType.CHEER:
typtab = data.cheer_events
for key in ('amount', 'cheer_type', 'message'):
if key in kwargs:
typparams[key] = kwargs[key]
case EventType.RAID:
typtab = data.raid_events
for key in ('viewer_count',):
if key in kwargs:
typparams[key] = kwargs[key]
case EventType.SUBSCRIBER:
typtab = data.subscriber_events
for key in ('tier', 'subscribed_length', 'message'):
if key in kwargs:
typparams[key] = kwargs[key]
if typparams:
await typtab.update_where(event_id=self.row.event_id).set(**typparams)
await self.row.refresh()
async def delete(self):
payload = await self.prepare()
if self.row.document_id:
await self.data.Document.table.delete_where(document_id=self.row.document_id)
await self.data.Events.table.delete_where(event_id=self.row.event_id)
await self.row.refresh()
return payload
async def get_user(self):
from .users import User
return await User.fetch_from_id(self.app, self.row.user_id)
async def get_document(self):
from .documents import Document
if self.row.document_id:
return await Document.fetch_from_id(self.app, self.row.document_id)
async def prepare(self):
row = await self.row.refresh()
assert row is not None
data = self.data
user = await self.get_user()
assert user is not None
document = await self.get_document()
payload = {
'event_id': self.row.event_id,
'document_id': self.row.document_id,
'document': await document.prepare() if document else None,
'user_id': self.row.user_id,
'user': await user.prepare(),
'user_name': self.row.user_name,
'occurred_at': self.row.occurred_at.isoformat(),
'created_at': self.row.created_at.isoformat(),
'event_type': self.row.event_type.value[0],
}
match row.event_type:
case EventType.PLAIN:
payload['message'] = row.plain_message
case EventType.SUBSCRIBER:
payload['tier'] = row.subscriber_tier
payload['subscribed_length'] = row.subscriber_length
payload['message'] = row.subscriber_message
case EventType.CHEER:
payload['amount'] = row.cheer_amount
payload['cheer_type'] = row.cheer_type
payload['message'] = row.cheer_message
case EventType.RAID:
payload['viewer_count'] = row.raid_visitor_count
return payload
@routes.view('/events')
@routes.view('/events/', name='events')
class EventsView(web.View):
async def post(self):
request = self.request
params = await request.json()
if 'user_id' in request:
params.setdefault('user_id', request['user_id'])
await Event.validate_create_params(params)
logger.info(f"Creating a new event with args: {params=}")
event = await Event.create(self.request.app, **params)
logger.debug(f"Created event: {event!r}")
payload = await event.prepare()
return web.json_response(payload)
async def get(self):
request = self.request
filter_params = {}
keys = [
'event_id', 'document_id', 'document_seal',
'user_id', 'user_name', 'occurred_before', 'occurred_after',
'created_before', 'created_after', 'event_type',
]
for key in keys:
value = request.query.get(key, request.get(key, None))
filter_params[key] = value
logger.info(f"Querying events with params: {filter_params=}")
events = await Event.query(request.app, **filter_params)
payload = [await event.prepare() for event in events]
return web.json_response(payload)
@routes.view('/events/{event_id}')
@routes.view('/events/{event_id}/', name='event')
class EventView(web.View):
async def resolve_event(self):
request = self.request
event_id = request.match_info['event_id']
event = await Event.fetch_from_id(request.app, int(event_id))
if event is None:
raise web.HTTPNotFound(text="No event exists with the given ID.")
return event
async def get(self):
event = await self.resolve_event()
logger.info(f"Received GET for event {event=}")
payload = await event.prepare()
return web.json_response(payload)
async def patch(self):
event = await self.resolve_event()
params = await self.request.json()
edit_data = {}
edit_fields = {'message', 'amount', 'cheer_type', 'viewer_count', 'tier', 'subscriber_length', 'message'}
for key, value in params.items():
if key not in edit_fields:
raise web.HTTPBadRequest(text=f"You cannot update field '{key}' of User!")
edit_data[key] = value
for key in edit_fields:
if key in self.request:
edit_data.setdefault(key, self.request[key])
logger.info(f"Received PATCH for event {event} with params: {params}")
await event.edit(**edit_data)
payload = await event.prepare()
return web.json_response(payload)
async def delete(self):
event = await self.resolve_event()
logger.info(f"Received DELETE for event {event}")
payload = await event.delete()
return web.json_response(payload)
@routes.route('*', "/events/{event_id}/user")
@routes.route('*', "/events/{event_id}/user{tail:/.*}")
async def event_user_route(request: web.Request):
event_id = int(request.match_info['event_id'])
event = await Event.fetch_from_id(request.app, event_id)
if event is None:
raise web.HTTPNotFound(text="No event exists with the given ID.")
tail = request.match_info.get('tail', '')
new_path = "/users/{user_id}".format(user_id=event.row.user_id) + tail
logger.info(f"Redirecting {request=} to {new_path}")
new_request = request.clone(rel_url=new_path)
new_request['user_id'] = event.row.user_id
match_info = await request.app.router.resolve(new_request)
new_request._match_info = match_info
match_info.current_app = request.app
if match_info.handler:
return await match_info.handler(new_request)
else:
logger.info(f"Could not find handler matching {new_request}")
raise web.HTTPNotFound()
@routes.route('*', "/events/{event_id}/document")
@routes.route('*', "/events/{event_id}/document{tail:/.*}")
async def event_document_route(request: web.Request):
event_id = int(request.match_info['event_id'])
event = await Event.fetch_from_id(request.app, event_id)
if event is None:
raise web.HTTPNotFound(text="No event exists with the given ID.")
tail = request.match_info.get('tail', '')
document = await event.get_document()
if document is None:
if request.method == 'POST' and not tail:
new_path = '/documents'
logger.info(f"Redirecting {request=} to POST /documents")
new_request = request.clone(rel_url=new_path)
new_request['event_id'] = event_id
match_info = await request.app.router.resolve(new_request)
new_request._match_info = match_info
match_info.current_app = request.app
return await match_info.handler(new_request)
else:
raise web.HTTPNotFound(text="This event has no document.")
else:
document_id = document.row.document_id
# Redirect to POST /documents/{document_id}/...
new_path = f"/documents/{document_id}".format(document_id=document_id) + tail
logger.info(f"Redirecting {request=} to {new_path}")
new_request = request.clone(rel_url=new_path)
new_request['event_id'] = event_id
new_request['document_id'] = document_id
match_info = await request.app.router.resolve(new_request)
new_request._match_info = match_info
match_info.current_app = request.app
if match_info.handler:
return await match_info.handler(new_request)
else:
logger.info(f"Could not find handler matching {new_request}")
raise web.HTTPNotFound()

View File

@@ -3,9 +3,11 @@ from aiohttp import web
from data.database import Database
from datamodels import DataModel
from modules.profiles.data import ProfileData
dbvar = web.AppKey("database", Database)
datamodelsv = web.AppKey("datamodels", DataModel)
profiledatav = web.AppKey("profiledata", ProfileData)
class ModelField(NamedTuple):

304
src/routes/specimens.py Normal file
View File

@@ -0,0 +1,304 @@
"""
- `/specimens` with `GET` and `POST`
- `/specimens/{specimen_id}` with `PATCH` and `DELETE`
- `/specimens/{specimen_id}/owner` which is passed to `/users/{user_id}`
"""
import logging
from datetime import datetime
from typing import Any, Literal, NamedTuple, Optional, Self, TypedDict, Unpack, overload, reveal_type, List, TYPE_CHECKING
from aiohttp import web
from data import Condition, condition
from data.conditions import NULL
from data.queries import JOINTYPE
from datamodels import DataModel
from .lib import ModelField, datamodelsv, dbvar
if TYPE_CHECKING:
from .users import UserCreateParams, UserPayload, User
routes = web.RouteTableDef()
logger = logging.getLogger(__name__)
class SpecimenPayload(TypedDict):
specimen_id: int
owner_id: int
owner: 'UserPayload'
born_at: str
forgotten_at: Optional[str]
class SpecimenCreateParamsReq(TypedDict, total=True):
owner_id: int
class SpecimenCreateParams(SpecimenCreateParamsReq, total=False):
owner: 'UserCreateParams'
born_at: str
forgotten_at: str
class SpecimenEditParams(TypedDict, total=False):
owner_id: int
forgotten_at: Optional[str]
fields = [
ModelField('specimen_id', int, False, False, False),
ModelField('owner_id', int, False, True, True),
ModelField('owner', 'UserPayload', False, True, False),
ModelField('born_at', str, False, True, False),
ModelField('forgotten_at', str, False, True, True),
]
req_fields = {field.name for field in fields if field.required}
edit_fields = {field.name for field in fields if field.can_edit}
create_fields = {field.name for field in fields if field.can_create}
class Specimen:
def __init__(self, app: web.Application, row: DataModel.Specimen):
self.app = app
self.data = app[datamodelsv]
self.row = row
@classmethod
async def validate_create_params(cls, params):
if 'owner_id' not in params and 'owner' not in params:
raise web.HTTPBadRequest(text="One of 'owner' or 'owner_id' must be supplied to create Specimen.")
if extra := next((key for key in params if key not in create_fields), None):
raise web.HTTPBadRequest(text=f"Invalid key '{extra}' passed to specimen creation.")
if missing := next((key for key in req_fields if key not in params), None):
raise web.HTTPBadRequest(text=f"Specimen params missing required key '{missing}'")
@classmethod
async def fetch_from_id(cls, app: web.Application, spec_id: int) -> Optional[Self]:
data = app[datamodelsv]
row = await data.Specimen.fetch(int(spec_id))
return cls(app, row) if row is not None else None
@classmethod
async def query(
cls,
app: web.Application,
specimen_id: Optional[str] = None,
owner_id: Optional[str] = None,
born_after: Optional[str] = None,
born_before: Optional[str] = None,
forgotten: Optional[str] = None,
forgotten_after: Optional[str] = None,
forgotten_before: Optional[str] = None,
) -> List[Self]:
data = app[datamodelsv]
Spec = data.Specimen
conds = []
if specimen_id is not None:
conds.append(Spec.specimen_id == int(specimen_id))
if owner_id is not None:
conds.append(Spec.owner_id == int(owner_id))
if born_after is not None:
bafter = datetime.fromisoformat(born_after)
conds.append(Spec.born_at >= bafter)
if born_before is not None:
bbefore = datetime.fromisoformat(born_before)
conds.append(Spec.born_at <= bbefore)
if forgotten_after is not None:
fafter = datetime.fromisoformat(forgotten_after)
conds.append(Spec.forgotten_at >= fafter)
if forgotten_before is not None:
fbefore = datetime.fromisoformat(forgotten_before)
conds.append(Spec.forgotten_at <= fbefore)
if forgotten is not None:
if forgotten.lower() in ('1', 'true'):
conds.append(Spec.forgotten_at != NULL)
elif forgotten.lower() in ('0', 'false'):
conds.append(Spec.forgotten_at == NULL)
rows = await Spec.fetch_where(*conds).order_by(Spec.born_at)
return [cls(app, row) for row in rows]
@classmethod
async def create(cls, app: web.Application, **kwargs: Unpack[SpecimenCreateParams]) -> Self:
"""
Create a new specimen from the given data.
This will create the provided 'owner' if required.
"""
from .users import User
create_args = {}
if 'owner' in kwargs:
# Create owner and set owner_id
owner_args = kwargs['owner']
await User.validate_create_params(owner_args)
owner = await User.create(app, **owner_args)
owner_id = owner.row.user_id
if 'owner_id' in kwargs and not kwargs['owner_id'] == owner_id:
raise web.HTTPBadRequest(text="Provided `owner_id` does not match provided `owner`.")
else:
owner_id = int(kwargs['owner_id'])
create_args['owner_id'] = owner_id
if 'born_at' in kwargs:
create_args['born_at'] = datetime.fromisoformat(kwargs['born_at'])
if 'forgotten_at' in kwargs:
create_args['forgotten_at'] = datetime.fromisoformat(kwargs['forgotten_at'])
data = app[datamodelsv]
logger.info(f"Creating Specimen with {create_args=}")
row = await data.Specimen.create(**create_args)
return cls(app, row)
async def edit(self, **kwargs: Unpack[SpecimenEditParams]):
row = self.row
edit_args = {}
if 'owner_id' in kwargs:
edit_args['owner_id'] = kwargs['owner_id']
# TODO: We should probably check that the specified owner exists
if 'forgotten_at' in kwargs:
forg = kwargs['forgotten_at']
if forg is None:
# Allows unsetting the forgotten date
# This may error if the user already had a live specimen
edit_args['forgotten_at'] = None
else:
edit_args['forgotten_at'] = datetime.fromisoformat(forg)
if edit_args:
logger.info(f"Updating specimen {row=} with {kwargs}")
await row.update(**edit_args)
async def delete(self) -> SpecimenPayload:
payload = await self.prepare()
await self.row.delete()
return payload
async def get_owner(self):
from .users import User
return await User.fetch_from_id(self.app, self.row.owner_id)
async def prepare(self) -> SpecimenPayload:
owner = await self.get_owner()
if owner is None:
raise ValueError("Specimen Owner does not exist! This should never happen!")
results: SpecimenPayload = {
'specimen_id': self.row.specimen_id,
'owner_id': self.row.owner_id,
'owner': await owner.prepare(),
'born_at': self.row.born_at.isoformat(),
'forgotten_at': self.row.forgotten_at.isoformat() if self.row.forgotten_at else None
}
return results
@routes.view('/specimens')
@routes.view('/specimens/', name='specimens')
class SpecimensView(web.View):
async def post(self):
request = self.request
params = await request.json()
for key in create_fields:
if key in request:
params.setdefault(key, request[key])
await Specimen.validate_create_params(params)
logger.info(f"Creating a new Specimen with args: {params=}")
spec = await Specimen.create(self.request.app, **params)
logger.debug(f"Created specimen: {spec!r}")
payload = await spec.prepare()
return web.json_response(payload)
async def get(self):
request = self.request
filter_params = {}
keys = [
'specimen_id', 'owner_id', 'born_after', 'born_before',
'forgotten', 'forgotten_after', 'forgotten_before'
]
for key in keys:
value = request.query.get(key, request.get(key, None))
filter_params[key] = value
logger.info(f"Querying specimens with params: {filter_params=}")
specs = await Specimen.query(request.app, **filter_params)
payload = [await spec.prepare() for spec in specs]
return web.json_response(payload)
@routes.view('/specimens/{specimen_id}')
@routes.view('/specimens/{specimen_id}/', name='specimen')
class SpecimenView(web.View):
async def resolve_specimen(self):
request = self.request
spec_id = request.match_info['specimen_id']
spec = await Specimen.fetch_from_id(request.app, int(spec_id))
if spec is None:
raise web.HTTPNotFound(text="No specimen exists with the given ID.")
return spec
async def get(self):
spec = await self.resolve_specimen()
logger.info(f"Received GET for specimen {spec=}")
payload = await spec.prepare()
return web.json_response(payload)
async def patch(self):
spec = await self.resolve_specimen()
params = await self.request.json()
edit_data = {}
for key, value in params.items():
if key not in edit_fields:
raise web.HTTPBadRequest(text=f"You cannot update field '{key}' of Specimen!")
edit_data[key] = value
for key in edit_fields:
if key in self.request:
edit_data.setdefault(key, self.request[key])
logger.info(f"Received PATCH for specimen {spec} with params: {params}")
await spec.edit(**edit_data)
payload = await spec.prepare()
return web.json_response(payload)
async def delete(self):
spec = await self.resolve_specimen()
logger.info(f"Received DELETE for specimen {spec}")
payload = await spec.delete()
return web.json_response(payload)
@routes.route('*', "/specimens/{specimen_id}/owner")
@routes.route('*', "/specimens/{specimen_id}/owner{tail:/.*}")
async def specimen_owner_route(request: web.Request):
spec_id = int(request.match_info['specimen_id'])
spec = await Specimen.fetch_from_id(request.app, spec_id)
if spec is None:
raise web.HTTPNotFound(text="No specimen exists with the given ID.")
tail = request.match_info.get('tail', '')
new_path = "/users/{user_id}".format(user_id=spec.row.owner_id) + tail
logger.info(f"Redirecting {request=} to {new_path}")
new_request = request.clone(rel_url=new_path)
new_request['user_id'] = spec.row.owner_id
match_info = await request.app.router.resolve(new_request)
new_request._match_info = match_info
match_info.current_app = request.app
if match_info.handler:
return await match_info.handler(new_request)
else:
logger.info(f"Could not find handler matching {new_request}")
raise web.HTTPNotFound()

223
src/routes/transactions.py Normal file
View File

@@ -0,0 +1,223 @@
import logging
from datetime import datetime
from typing import Any, Literal, NamedTuple, Optional, Self, TypedDict, Unpack, overload, reveal_type, List, TYPE_CHECKING
from aiohttp import web
from data import Condition, condition
from data.conditions import NULL
from data.queries import JOINTYPE
from datamodels import DataModel
from .lib import ModelField, datamodelsv, dbvar
if TYPE_CHECKING:
from .users import UserCreateParams, UserPayload, User
routes = web.RouteTableDef()
logger = logging.getLogger(__name__)
class TransactionPayload(TypedDict):
transaction_id: int
user_id: int
user: 'UserPayload'
amount: int
description: str
reference: Optional[str]
created_at: str
class TransactionCreateParamsReq(TypedDict, total=True):
user_id: int
amount: int
description: str
class TransactionCreateParams(TransactionCreateParamsReq, total=False):
reference: str
fields = [
ModelField('transaction_id', int, False, False, False),
ModelField('user_id', int, True, True, False),
ModelField('amount', int, True, True, False),
ModelField('description', str, True, True, False),
ModelField('reference', str, False, True, False),
ModelField('created_at', str, False, False, False),
]
req_fields = {field.name for field in fields if field.required}
edit_fields = {field.name for field in fields if field.can_edit}
create_fields = {field.name for field in fields if field.can_create}
class Transaction:
def __init__(self, app: web.Application, row: DataModel.Transaction):
self.app = app
self.data = app[datamodelsv]
self.row = row
@classmethod
async def validate_create_params(cls, params):
if extra := next((key for key in params if key not in create_fields), None):
raise web.HTTPBadRequest(text=f"Invalid key '{extra}' passed to transaction creation.")
if missing := next((key for key in req_fields if key not in params), None):
raise web.HTTPBadRequest(text=f"Transaction params missing required key '{missing}'")
@classmethod
async def fetch_from_id(cls, app: web.Application, tid: int) -> Optional[Self]:
data = app[datamodelsv]
row = await data.Transaction.fetch(int(tid))
return cls(app, row) if row is not None else None
@classmethod
async def query(
cls,
app: web.Application,
transaction_id: Optional[int] = None,
user_id: Optional[int] = None,
reference: Optional[str] = None,
created_before: Optional[str] = None,
created_after: Optional[str] = None,
) -> List[Self]:
data = app[datamodelsv]
TXN = data.Transaction
conds = []
if transaction_id is not None:
conds.append(TXN.transaction_id == int(transaction_id))
if user_id is not None:
conds.append(TXN.user_id == int(user_id))
if reference is not None:
conds.append(TXN.reference == reference)
if created_before is not None:
cbefore = datetime.fromisoformat(created_before)
conds.append(TXN.created_at <= cbefore)
if created_after is not None:
cafter = datetime.fromisoformat(created_after)
conds.append(TXN.created_at >= cafter)
rows = await TXN.fetch_where(*conds).order_by(TXN.created_at)
return [cls(app, row) for row in rows]
@classmethod
async def create(cls, app: web.Application, **kwargs: Unpack[TransactionCreateParams]) -> Self:
data = app[datamodelsv]
create_args = {}
for key in ('user_id', 'description', 'amount', 'reference'):
create_args[key] = kwargs.get(key)
logger.info(f"Creating Transaction with {create_args=}")
row = await data.Transaction.create(**create_args)
return cls(app, row)
async def edit(self):
raise ValueError("Transactions are immutable.")
async def delete(self):
raise ValueError("Transactions cannot be deleted directly.")
async def get_user(self):
from .users import User
return await User.fetch_from_id(self.app, self.row.user_id)
async def prepare(self) -> TransactionPayload:
user = await self.get_user()
if user is None:
raise ValueError("Transaction owner does not exist! This cannot happen.")
results: TransactionPayload = {
'transaction_id': self.row.transaction_id,
'user_id': self.row.user_id,
'user': await user.prepare(details=False),
'amount': self.row.amount,
'description': self.row.description,
'reference': self.row.reference,
'created_at': self.row.created_at.isoformat(),
}
return results
@routes.view('/transactions')
@routes.view('/transactions/', name='transactions')
class TransactionsView(web.View):
async def post(self):
request = self.request
params = await request.json()
for key in create_fields:
if key in request:
params.setdefault(key, request[key])
await Transaction.validate_create_params(params)
logger.info(f"Creating a new Transaction with args: {params=}")
txn = await Transaction.create(self.request.app, **params)
logger.debug(f"Created transaction: {txn!r}")
payload = await txn.prepare()
return web.json_response(payload)
async def get(self):
request = self.request
filter_params = {}
keys = [
'transaction_id', 'user_id', 'reference',
'created_before', 'created_after',
]
for key in keys:
value = request.query.get(key, request.get(key, None))
filter_params[key] = value
logger.info(f"Querying transactions with params: {filter_params=}")
txns = await Transaction.query(request.app, **filter_params)
payload = [await txn.prepare() for txn in txns]
return web.json_response(payload)
@routes.view('/transactions/{transaction_id}')
@routes.view('/transactions/{transaction_id}/', name='transaction_id')
class TransactionView(web.View):
async def resolve_transaction(self):
request = self.request
txn_id = request.match_info['transaction_id']
txn = await Transaction.fetch_from_id(request.app, int(txn_id))
if txn is None:
raise web.HTTPNotFound(text="No transaction exists with the given ID.")
return txn
async def get(self):
txn = await self.resolve_transaction()
logger.info(f"Received GET for transaction {txn=}")
payload = await txn.prepare()
return web.json_response(payload)
async def patch(self):
raise web.HTTPBadRequest(text="Transactions are immutable and cannot be edited.")
async def delete(self):
raise web.HTTPBadRequest(text="Transactions cannot be individually deleted.")
@routes.route('*', "/transactions/{transaction_id}/user")
@routes.route('*', "/transactions/{transaction_id}/user{tail:/.*}")
async def transaction_user_route(request: web.Request):
txn_id = int(request.match_info['transaction_id'])
txn = await Transaction.fetch_from_id(request.app, txn_id)
if txn is None:
raise web.HTTPNotFound(text="No transaction exists with the given ID.")
tail = request.match_info.get('tail', '')
new_path = "/users/{user_id}".format(user_id=txn.row.user_id) + tail
logger.info(f"Redirecting {request=} to {new_path}")
new_request = request.clone(rel_url=new_path)
new_request['user_id'] = txn.row.user_id
match_info = await request.app.router.resolve(new_request)
new_request._match_info = match_info
match_info.current_app = request.app
if match_info.handler:
return await match_info.handler(new_request)
else:
logger.info(f"Could not find handler matching {new_request}")
raise web.HTTPNotFound()

420
src/routes/users.py Normal file
View File

@@ -0,0 +1,420 @@
"""
- `/users` with `POST`, `GET`, `PATCH`, `DELETE`
- `/users/{user_id}` with `GET`, `PATCH`, `DELETE`
- `/users/{user_id}/events` which is passed to `/events`
- `/users/{user_id}/specimen` which is passed to `/specimens/{specimen_id}`
- `/users/{user_id}/specimens` which is passed to `/specimens`
- `/users/{user_id}/wallet` with `GET`
- `/users/{user_id}/transactions` which is passed to `/transactions`
"""
import logging
from datetime import datetime
from typing import Any, Literal, NamedTuple, Optional, Self, TypedDict, Unpack, overload, reveal_type, List
from aiohttp import web
from data import Condition, condition
from data.conditions import NULL
from data.queries import JOINTYPE
from datamodels import DataModel
from modules.profiles.data import ProfileData
from .lib import ModelField, datamodelsv, dbvar, profiledatav
from .specimens import Specimen, SpecimenPayload
routes = web.RouteTableDef()
logger = logging.getLogger(__name__)
class UserPayload(TypedDict):
user_id: int
twitch_id: Optional[str]
name: Optional[str]
preferences: Optional[str]
created_at: str
class UserDetailsPayload(UserPayload):
specimen: Optional[SpecimenPayload]
inventory: List # TODO
wallet: int
class UserCreateParamsReq(TypedDict, total=True):
twitch_id: str
name: str
class UserCreateParams(UserCreateParamsReq, total=False):
preferences: str
class UserEditParams(TypedDict, total=False):
name: Optional[str]
preferences: Optional[str]
fields = [
ModelField('user_id', int, False, False, False),
ModelField('twitch_id', str, True, True, False),
ModelField('name', str, True, True, True),
ModelField('preferences', str, False, True, True),
ModelField('created_at', str, False, False, False),
]
req_fields = {field.name for field in fields if field.required}
edit_fields = {field.name for field in fields if field.can_edit}
create_fields = {field.name for field in fields if field.can_create}
class User:
def __init__(self, app: web.Application, row: DataModel.Dreamer):
self.app = app
self.data = app[datamodelsv]
self.profile_data = app[profiledatav]
self.row = row
self._pref_row: Optional[DataModel.UserPreferences] = None
async def get_prefs(self) -> DataModel.UserPreferences:
if self._pref_row is None:
self._pref_row = await self.data.UserPreferences.fetch_or_create(self.row.user_id)
return self._pref_row
@classmethod
async def validate_create_params(cls, params):
if extra := next((key for key in params if key not in create_fields), None):
raise web.HTTPBadRequest(text=f"Invalid key '{extra}' passed to user creation.")
if missing := next((key for key in req_fields if key not in params), None):
raise web.HTTPBadRequest(text=f"User params missing required key '{missing}'")
@classmethod
async def fetch_from_id(cls, app: web.Application, user_id: int):
data = app[datamodelsv]
row = await data.Dreamer.fetch(int(user_id))
return cls(app, row) if row is not None else None
@classmethod
async def query(
cls,
app: web.Application,
user_id: Optional[str] = None,
twitch_id: Optional[str] = None,
name: Optional[str] = None,
created_before: Optional[str] = None,
created_after: Optional[str] = None,
) -> List[Self]:
data = app[datamodelsv]
Dreamer = data.Dreamer
conds = []
if user_id is not None:
conds.append(Dreamer.user_id == int(user_id))
if twitch_id is not None:
conds.append(Dreamer.twitch_id == twitch_id)
if name is not None:
conds.append(Dreamer.name == name)
if created_before is not None:
cbefore = datetime.fromisoformat(created_before)
conds.append(Dreamer.created_at <= cbefore)
if created_after is not None:
cafter = datetime.fromisoformat(created_after)
conds.append(Dreamer.created_at >= cafter)
rows = await Dreamer.fetch_where(*conds).order_by(Dreamer.created_at)
return [cls(app, row) for row in rows]
@classmethod
async def create(cls, app: web.Application, **kwargs: Unpack[UserCreateParams]):
"""
Create a new User from the provided data.
This creates the associated UserProfile, TwitchProfile, and UserPreferences if needed.
If a profile already exists, this does *not* error.
Instead, this updates the existing User with the new data.
"""
data = app[datamodelsv]
twitch_id = kwargs['twitch_id']
name = kwargs['name']
prefs = kwargs.get('preferences')
# Quick sanity check on the twitch id
if not twitch_id or not twitch_id.isdigit():
raise web.HTTPBadRequest(text="Invalid 'twitch_id' passed to user creation!")
# First check if the profile already exists by querying the Dreamer database
rows = await data.Dreamer.fetch_where(twitch_id=twitch_id)
if rows:
logger.debug(f"Updating Dreamer for {twitch_id=} with {kwargs}")
dreamer = rows[0]
# A twitch profile with this twitch_id already exists
# But it is possible UserPreferences don't exist
if dreamer.preferences is None and dreamer.name is None:
await data.UserPreferences.fetch_or_create(dreamer.user_id, twitch_name=name, preferences=prefs)
dreamer = await dreamer.refresh()
# Now compare the existing data against the provided data and update if needed
if name != dreamer.name:
q = data.UserPreferences.table.update_where(profileid=dreamer.user_id)
q.set(twitch_name=name)
if prefs is not None:
q.set(preferences=prefs)
await q
dreamer = await dreamer.refresh()
else:
# Create from scratch
logger.info(f"Creating Dreamer for {twitch_id=} with {kwargs}")
# TODO: Should be in a transaction.. actually let's add transactions to the middleware..
profile_data = app[profiledatav]
user_profile = await profile_data.UserProfileRow.create(nickname=name)
await profile_data.TwitchProfileRow.create(
profileid=user_profile.profileid,
userid=twitch_id,
)
await data.UserPreferences.create(
profileid=user_profile.profileid,
twitch_name=name,
preferences=prefs
)
dreamer = await data.Dreamer.fetch(user_profile.profileid)
assert dreamer is not None
return cls(app, dreamer)
async def edit(self, **kwargs: Unpack[UserEditParams]):
data = self.data
# We can edit the name, and preferences
prefs = await self.get_prefs()
update_args = {}
if 'name' in kwargs:
update_args['twitch_name'] = kwargs['name']
if 'preferences' in kwargs:
update_args['preferences'] = kwargs['preferences']
if update_args:
logger.info(f"Updating dreamer {self.row=} with {kwargs}")
await prefs.update(**update_args)
async def delete(self) -> UserDetailsPayload:
payload = await self.prepare(details=True)
await self.row.delete()
return payload
async def get_wallet(self):
query = self.data.Transaction.table.select_where(user_id=self.row.user_id)
query.select(wallet="SUM(amount)")
query.with_no_adapter()
results = await query
return results[0]['wallet']
async def get_specimen(self) -> Optional[Specimen]:
data = self.data
active_specrows = await data.Specimen.fetch_where(
owner_id=self.row.user_id,
forgotten_at=NULL
)
if active_specrows:
row = active_specrows[0]
spec = Specimen(self.app, row)
else:
spec = None
return spec
async def get_inventory(self):
return []
@overload
async def prepare(self, details: Literal[True]=True) -> UserDetailsPayload:
...
@overload
async def prepare(self, details: Literal[False]=False) -> UserPayload:
...
async def prepare(self, details=False) -> UserPayload | UserDetailsPayload:
# Since we are working with view rows, make sure we refresh
row = self.row
await row.refresh()
base_user: UserPayload = {
'user_id': row.user_id,
'twitch_id': str(row.twitch_id) if row.twitch_id else None,
'name': row.name,
'preferences': row.preferences,
'created_at': row.created_at.isoformat(),
}
if details:
# Now add details
specimen = await self.get_specimen()
sp_payload = await specimen.prepare() if specimen is not None else None
inventory = [await item.prepare() for item in await self.get_inventory()]
user: UserPayload = base_user | {
'specimen': sp_payload,
'inventory': inventory,
'wallet': await self.get_wallet(),
}
else:
user = base_user
logger.debug(f"User prepared: {user}")
return user
@routes.view('/users')
@routes.view('/users/', name='users')
class UsersView(web.View):
async def post(self):
request = self.request
params = await request.json()
for key in create_fields:
if key in request:
params.setdefault(key, request[key])
await User.validate_create_params(params)
logger.info(f"Creating a new user with args: {params=}")
user = await User.create(self.request.app, **params)
logger.debug(f"Created user: {user!r}")
payload = await user.prepare(details=True)
return web.json_response(payload)
async def get(self):
request = self.request
filter_params = {}
keys = [
'user_id', 'twitch_id', 'name', 'created_before', 'created_after',
]
for key in keys:
value = request.query.get(key, request.get(key, None))
filter_params[key] = value
logger.info(f"Querying users with params: {filter_params=}")
users = await User.query(request.app, **filter_params)
payload = [await user.prepare(details=True) for user in users]
return web.json_response(payload)
@routes.view('/users/{user_id}')
@routes.view('/users/{user_id}/', name='user')
class UserView(web.View):
async def resolve_user(self):
request = self.request
user_id = request.match_info['user_id']
user = await User.fetch_from_id(request.app, int(user_id))
if user is None:
raise web.HTTPNotFound(text="No user exists with the given ID.")
return user
async def get(self):
user = await self.resolve_user()
logger.info(f"Received GET for user {user=}")
payload = await user.prepare(details=True)
return web.json_response(payload)
async def patch(self):
user = await self.resolve_user()
params = await self.request.json()
edit_data = {}
for key, value in params.items():
if key not in edit_fields:
raise web.HTTPBadRequest(text=f"You cannot update field '{key}' of User!")
edit_data[key] = value
for key in edit_fields:
if key in self.request:
edit_data.setdefault(key, self.request[key])
logger.info(f"Received PATCH for user {user} with params: {params}")
await user.edit(**edit_data)
payload = await user.prepare(details=True)
return web.json_response(payload)
async def delete(self):
user = await self.resolve_user()
logger.info(f"Received DELETE for user {user}")
payload = await user.delete()
return web.json_response(payload)
@routes.route('*', "/users/{user_id}{tail:/events}")
@routes.route('*', "/users/{user_id}{tail:/events/.*}")
@routes.route('*', "/users/{user_id}{tail:/transactions}")
@routes.route('*', "/users/{user_id}{tail:/transactions/.*}")
@routes.route('*', "/users/{user_id}{tail:/specimens}")
@routes.route('*', "/users/{user_id}{tail:/specimens/.*}")
async def user_prefix_routes(request: web.Request):
user_id = int(request.match_info['user_id'])
user = await User.fetch_from_id(request.app, user_id)
if user is None:
raise web.HTTPNotFound(text="No user exists with the given ID.")
new_path = request.match_info['tail']
logger.info(f"Redirecting {request=} to {new_path=} and setting {user_id=}")
new_request = request.clone(rel_url=new_path)
new_request['user_id'] = user_id
match_info = await request.app.router.resolve(new_request)
new_request._match_info = match_info
match_info.current_app = request.app
if match_info.handler:
return await match_info.handler(new_request)
else:
logger.info(f"Could not find handler matching {new_request}")
raise web.HTTPNotFound()
@routes.route('*', "/users/{user_id}/specimen")
@routes.route('*', "/users/{user_id}/specimen{tail:/.*}")
async def user_specimen_route(request: web.Request):
user_id = int(request.match_info['user_id'])
user = await User.fetch_from_id(request.app, user_id)
if user is None:
raise web.HTTPNotFound(text="No user exists with the given ID.")
tail = request.match_info.get('tail', '')
specimen = await user.get_specimen()
if request.method == 'POST' and not tail.strip('/'):
if specimen is None:
# Redirect to POST /specimens
# TODO: Would be nicer to use named handler here
new_path = '/specimens'
logger.info(f"Redirecting {request=} to POST /specimens")
new_request = request.clone(rel_url=new_path)
new_request['user_id'] = user_id
new_request['owner_id'] = user_id
match_info = await request.app.router.resolve(new_request)
new_request._match_info = match_info
match_info.current_app = request.app
return await match_info.handler(new_request)
else:
raise web.HTTPBadRequest(text="This user already has an active specimen!")
elif specimen is None:
raise web.HTTPNotFound(text="This user has no active specimen.")
else:
specimen_id = specimen.row.specimen_id
# Redirect to POST /specimens/{specimen_id}/...
new_path = f"/specimens/{specimen_id}".format(specimen_id=specimen_id) + tail
logger.info(f"Redirecting {request=} to {new_path}")
new_request = request.clone(rel_url=new_path)
new_request['user_id'] = user_id
new_request['owner_id'] = user_id
new_request['specimen_id'] = specimen_id
match_info = await request.app.router.resolve(new_request)
new_request._match_info = match_info
match_info.current_app = request.app
if match_info.handler:
return await match_info.handler(new_request)
else:
logger.info(f"Could not find handler matching {new_request}")
raise web.HTTPNotFound()
@routes.route('GET', "/users/{user_id}/wallet")
@routes.route('GET', "/users/{user_id}/wallet/")
async def user_wallet_route(request: web.Request):
user_id = int(request.match_info['user_id'])
user = await User.fetch_from_id(request.app, user_id)
if user is None:
raise web.HTTPNotFound(text="No user exists with the given ID.")
wallet = await user.get_wallet()
return web.json_response(wallet)