feat(api): Finished initial route collection.
This commit is contained in:
@@ -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;
|
||||
|
||||
-- }}}
|
||||
|
||||
|
||||
23
src/api.py
23
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
|
||||
|
||||
|
||||
|
||||
@@ -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_ = {}
|
||||
|
||||
@@ -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
434
src/routes/events.py
Normal 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()
|
||||
|
||||
@@ -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
304
src/routes/specimens.py
Normal 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
223
src/routes/transactions.py
Normal 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
420
src/routes/users.py
Normal 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)
|
||||
Reference in New Issue
Block a user