mirror of
https://github.com/rommapp/romm.git
synced 2026-10-12 02:21:58 +00:00
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:
@@ -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]
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user