From dc551b34a9ce190e4030bbb5c4379438f906086c Mon Sep 17 00:00:00 2001 From: Interitio Date: Tue, 10 Jun 2025 13:00:37 +1000 Subject: [PATCH] feat(api): Finished initial route collection. --- data/schema.sql | 5 +- src/api.py | 23 +- src/datamodels.py | 8 +- src/routes/documents.py | 21 +- src/routes/events.py | 434 +++++++++++++++++++++++++++++++++++++ src/routes/lib.py | 2 + src/routes/specimens.py | 304 ++++++++++++++++++++++++++ src/routes/transactions.py | 223 +++++++++++++++++++ src/routes/users.py | 420 +++++++++++++++++++++++++++++++++++ 9 files changed, 1430 insertions(+), 10 deletions(-) create mode 100644 src/routes/events.py create mode 100644 src/routes/specimens.py create mode 100644 src/routes/transactions.py create mode 100644 src/routes/users.py diff --git a/data/schema.sql b/data/schema.sql index 189d905..75ddb84 100644 --- a/data/schema.sql +++ b/data/schema.sql @@ -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; -- }}} diff --git a/src/api.py b/src/api.py index dad5342..774f1f9 100644 --- a/src/api.py +++ b/src/api.py @@ -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 diff --git a/src/datamodels.py b/src/datamodels.py index 634a207..b9371b2 100644 --- a/src/datamodels.py +++ b/src/datamodels.py @@ -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_ = {} diff --git a/src/routes/documents.py b/src/routes/documents.py index 01bc660..faa8c65 100644 --- a/src/routes/documents.py +++ b/src/routes/documents.py @@ -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: diff --git a/src/routes/events.py b/src/routes/events.py new file mode 100644 index 0000000..4e32ae0 --- /dev/null +++ b/src/routes/events.py @@ -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() + diff --git a/src/routes/lib.py b/src/routes/lib.py index b4149c0..a36ebf8 100644 --- a/src/routes/lib.py +++ b/src/routes/lib.py @@ -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): diff --git a/src/routes/specimens.py b/src/routes/specimens.py new file mode 100644 index 0000000..14a5c04 --- /dev/null +++ b/src/routes/specimens.py @@ -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() diff --git a/src/routes/transactions.py b/src/routes/transactions.py new file mode 100644 index 0000000..60ec049 --- /dev/null +++ b/src/routes/transactions.py @@ -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() diff --git a/src/routes/users.py b/src/routes/users.py new file mode 100644 index 0000000..8aa5a71 --- /dev/null +++ b/src/routes/users.py @@ -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)