Merge pull request #4956 from beemines/feat/play-sessions-all-devices

feat: read play sessions across devices and page sync sessions
This commit is contained in:
Georges-Antoine Assi
2026-09-30 16:00:34 -04:00
committed by GitHub
6 changed files with 133 additions and 14 deletions
+12 -7
View File
@@ -1,9 +1,11 @@
from datetime import datetime
from typing import Annotated
from fastapi import HTTPException, Request, status
from fastapi import Depends, HTTPException, Request, status
from pydantic import BaseModel, Field, model_validator
from decorators.auth import protected_route
from endpoints.responses.base import PAGE_QUERY, PageParams
from endpoints.responses.play_session import (
PlaySessionIngestResponse,
PlaySessionIngestResult,
@@ -101,22 +103,25 @@ def ingest_play_sessions(
@protected_route(router.get, "", [Scope.ROMS_USER_READ])
def get_play_sessions(
request: Request,
page: Annotated[PageParams, Depends(PAGE_QUERY)],
rom_id: int | None = None,
device_id: str | None = None,
start_after: datetime | None = None,
end_before: datetime | None = None,
limit: int = 50,
offset: int = 0,
) -> list[PlaySessionSchema]:
effective_device_id = device_id or token_device_id(request)
# A device-bound client may read its own device with roms.user.read alone.
if (
device_id is None or device_id != token_device_id(request)
) and Scope.DEVICES_READ not in request.auth.scopes:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
sessions = db_play_session_handler.get_sessions(
user_id=request.user.id,
rom_id=rom_id,
device_id=effective_device_id,
device_id=device_id,
start_after=start_after,
end_before=end_before,
limit=limit if start_after is None and end_before is None else None,
offset=offset,
limit=page.limit if start_after is None and end_before is None else None,
offset=page.offset,
)
return [PlaySessionSchema.model_validate(s) for s in sessions]
+6 -4
View File
@@ -1,13 +1,14 @@
import asyncio
from collections import Counter
from datetime import datetime
from typing import Annotated
from fastapi import BackgroundTasks, HTTPException, Request, status
from fastapi import BackgroundTasks, Depends, HTTPException, Request, status
from pydantic import Field, model_validator
from config import TASK_TIMEOUT
from decorators.auth import protected_route
from endpoints.responses.base import BaseModel
from endpoints.responses.base import PAGE_QUERY, BaseModel, PageParams
from endpoints.responses.play_session import (
PlaySessionIngestResponse,
PlaySessionIngestResult,
@@ -507,14 +508,15 @@ def complete_sync_session(
@protected_route(router.get, "/sessions", [Scope.DEVICES_READ])
def get_sync_sessions(
request: Request,
page: Annotated[PageParams, Depends(PAGE_QUERY)],
device_id: str | None = None,
limit: int = 50,
) -> list[SyncSessionSchema]:
"""List sync sessions for the current user."""
sessions = db_sync_session_handler.get_sessions(
user_id=request.user.id,
device_id=device_id,
limit=limit,
limit=page.limit,
offset=page.offset,
)
return [SyncSessionSchema.model_validate(s) for s in sessions]
@@ -221,6 +221,7 @@ class DBSyncSessionsHandler(DBBaseHandler):
device_id: str | None = None,
status: SyncSessionStatus | None = None,
limit: int = 50,
offset: int = 0,
session: Session = INJECTED_SESSION,
) -> Sequence[SyncSession]:
query = select(SyncSession).filter_by(user_id=user_id)
@@ -231,5 +232,9 @@ class DBSyncSessionsHandler(DBBaseHandler):
if status:
query = query.filter_by(status=status)
query = query.order_by(SyncSession.initiated_at.desc()).limit(limit)
query = (
query.order_by(SyncSession.initiated_at.desc(), SyncSession.id.desc())
.limit(limit)
.offset(offset)
)
return session.scalars(query).all()
+1 -1
View File
@@ -677,7 +677,7 @@ class TestBoundTokenInference:
list_resp = client.get(
f"/api/play-sessions?device_id={other.id}",
headers={"Authorization": f"Bearer {creds['access_token']}"},
headers={"Authorization": f"Bearer {access_token}"},
)
assert list_resp.status_code == status.HTTP_200_OK
assert len(list_resp.json()) >= 1
+75 -1
View File
@@ -6,7 +6,7 @@ from fastapi import status
from fastapi.testclient import TestClient
from main import app
from tests.audit_events import recorded_events
from tests.factories import make_rom
from tests.factories import make_device_token, make_rom
from config import OAUTH_ACCESS_TOKEN_EXPIRE_SECONDS
from handler.auth import oauth_handler
@@ -21,6 +21,7 @@ from handler.database.base_handler import sync_session
from models.device import Device
from models.permission import HiddenEntity, PermEntity
from models.platform import Platform
from models.play_session import PlaySession
from models.rom import Rom, RomUserStatus
from models.user import User
from utils.datetime import to_utc
@@ -662,6 +663,79 @@ class TestPlaySessionRomUserUpdates:
class TestPlaySessionQuery:
@pytest.mark.parametrize(
"scopes, target, expected",
[
("roms.user.read devices.read", None, [0, 1, 2]),
("roms.user.read devices.read", "other", [1]),
("roms.user.read", "own", [0]),
("roms.user.read", None, None),
("roms.user.read", "other", None),
],
)
def test_device_token_reads(
self,
client,
admin_user,
editor_user,
device,
rom,
scopes: str,
target: str | None,
expected: list[int] | None,
):
other_device = db_device_handler.add_device(
Device(id=str(uuid.uuid4()), user_id=admin_user.id, name="Other device")
)
owners = [
(admin_user.id, device.id),
(admin_user.id, other_device.id),
(admin_user.id, None),
(editor_user.id, None),
]
now = datetime.now(timezone.utc).replace(microsecond=0)
sessions = db_play_session_handler.add_sessions(
[
PlaySession(
user_id=user_id,
device_id=device_id,
rom_id=rom.id,
start_time=now - timedelta(hours=hours),
end_time=now - timedelta(hours=hours) + timedelta(minutes=30),
duration_ms=30 * 60 * 1000,
)
for hours, (user_id, device_id) in enumerate(owners, start=1)
]
)
_, raw = make_device_token(admin_user, device.id, scopes=scopes)
params = {
"own": {"device_id": device.id},
"other": {"device_id": other_device.id},
}
response = client.get(
"/api/play-sessions",
params=params.get(target or "", {}),
headers={"Authorization": f"Bearer {raw}"},
)
if expected is None:
assert response.status_code == status.HTTP_403_FORBIDDEN
return
assert response.status_code == status.HTTP_200_OK
assert [s["id"] for s in response.json()] == [sessions[i].id for i in expected]
@pytest.mark.parametrize("params", [{"limit": 0}, {"limit": -1}, {"offset": -1}])
def test_rejects_invalid_paging(
self, client, access_token: str, params: dict[str, int]
):
response = client.get(
"/api/play-sessions",
params=params,
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT
def test_filter_by_rom_id(
self, client, access_token: str, rom: Rom, platform: Platform
):
+33
View File
@@ -551,6 +551,39 @@ class TestSyncSessions:
assert len(data) == 1
assert data[0]["device_id"] == dev_a.id
def test_list_sessions_paginates(self, client, access_token: str, admin_user: User):
device = db_device_handler.add_device(
Device(id="session-dev-page", user_id=admin_user.id)
)
created = [
db_sync_session_handler.create_session(
device_id=device.id, user_id=admin_user.id
)
for _ in range(3)
]
headers = {"Authorization": f"Bearer {access_token}"}
first = client.get("/api/sync/sessions?limit=2&offset=0", headers=headers)
rest = client.get("/api/sync/sessions?limit=2&offset=2", headers=headers)
assert first.status_code == status.HTTP_200_OK
assert rest.status_code == status.HTTP_200_OK
ids = [s["id"] for s in first.json() + rest.json()]
assert sorted(ids) == sorted(s.id for s in created)
@pytest.mark.parametrize(
"params", [{"limit": 0}, {"limit": 10_001}, {"offset": -1}]
)
def test_list_sessions_rejects_invalid_paging(
self, client, access_token: str, params: dict[str, int]
):
response = client.get(
"/api/sync/sessions",
params=params,
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT
def test_get_session(self, client, access_token: str, admin_user: User):
device = db_device_handler.add_device(
Device(id="session-dev-get", user_id=admin_user.id)