diff --git a/src/cocat/app/app.py b/src/cocat/app/app.py index 5f62e61..0b133c5 100644 --- a/src/cocat/app/app.py +++ b/src/cocat/app/app.py @@ -1,22 +1,31 @@ import os from contextlib import asynccontextmanager +from datetime import datetime from functools import partial from pathlib import Path from typing import Any from anyio import sleep_forever from anyio.abc import TaskStatus -from fastapi import Depends, FastAPI, WebSocket, WebSocketDisconnect +from fastapi import Depends, FastAPI, HTTPException, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware from sqlalchemy import select from wire_file import AsyncFileClient from wiredb import AsyncChannel, Room, RoomManager +from ..db import DB as CrdtDB from .db import User, get_db from .schemas import RoomUsers, UserCreate, UserRead, UserRooms, UserUpdate from .users import get_backend +def _dt_to_iso(dt: Any) -> str: + """Return ISO 8601 timestamp without timezone suffix.""" + if isinstance(dt, datetime): + return dt.strftime("%Y-%m-%dT%H:%M:%S") + return str(dt).replace("Z", "").split("+")[0] + + class StoredRoom(Room): # pragma: nocover def __init__(self, directory: str, *args: Any, **kwargs: Any): super().__init__(*args, **kwargs) @@ -120,6 +129,99 @@ async def get_room_users(room_id: str): "users": [usr.User.email for usr in users if room_id in usr.User.rooms] } + @app.get("/rooms/{room_id}/catalogues") + async def get_room_catalogues( + room_id: str, + user: User = Depends(backend.current_active_user), + ): + if room_id not in user.rooms: + raise HTTPException(status_code=403, detail="Not authorized") + room = await self.room_manager.get_room(room_id) + crdt_db = CrdtDB(doc=room.doc) + return { + "catalogues": [ + { + "uuid": str(cat._uuid), + "name": cat.name, + "nb_events": len(cat.events), + "author": cat._map.get("author", ""), + "tags": list( + cat._map.get("tags", {}).keys() + if hasattr(cat._map.get("tags", None), "keys") + else [] + ), + "attributes": dict(cat._map.get("attributes", {}) or {}), + } + for cat in crdt_db.catalogues + ] + } + + @app.get("/catalogues/{uuid}") + async def get_catalogue( + uuid: str, + user: User = Depends(backend.current_active_user), + ): + for room_id in user.rooms: + room = await self.room_manager.get_room(room_id) + crdt_db = CrdtDB(doc=room.doc) + for cat in crdt_db.catalogues: + if str(cat._uuid) == uuid: + return { + "uuid": uuid, + "room_id": room_id, + "name": cat.name, + "nb_events": len(cat.events), + "author": cat._map.get("author", ""), + "tags": list( + cat._map.get("tags", {}).keys() + if hasattr(cat._map.get("tags", None), "keys") + else [] + ), + "attributes": dict(cat._map.get("attributes", {}) or {}), + } + raise HTTPException(status_code=404, detail="Catalogue not found") + + @app.get("/catalogues/{uuid}/events") + async def get_catalogue_events( + uuid: str, + user: User = Depends(backend.current_active_user), + ): + for room_id in user.rooms: + room = await self.room_manager.get_room(room_id) + crdt_db = CrdtDB(doc=room.doc) + for cat in crdt_db.catalogues: + if str(cat._uuid) == uuid: + events = sorted(cat.events, key=lambda e: e.start) + return { + "uuid": uuid, + "name": cat.name, + "room_id": room_id, + "author": cat._map.get("author", ""), + "tags": list( + cat._map.get("tags", {}).keys() + if hasattr(cat._map.get("tags", None), "keys") + else [] + ), + "events": [ + { + "uuid": str(e._uuid), + "start": _dt_to_iso(e.start), + "stop": _dt_to_iso(e.stop), + "author": e._map.get("author", ""), + "tags": list( + e._map.get("tags", {}).keys() + if hasattr(e._map.get("tags", None), "keys") + else [] + ), + "attributes": dict( + e._map.get("attributes", {}) or {} + ), + } + for e in events + ], + } + raise HTTPException(status_code=404, detail="Catalogue not found") + class YWebSocket(AsyncChannel): # pragma: nocover def __init__(self, websocket: WebSocket, path: str) -> None: diff --git a/tests/test_rest_catalogues.py b/tests/test_rest_catalogues.py new file mode 100644 index 0000000..4d2a263 --- /dev/null +++ b/tests/test_rest_catalogues.py @@ -0,0 +1,123 @@ +from uuid import uuid4 + +import httpx +import pytest +from anyio import sleep +from wire_websocket import AsyncWebSocketClient + +from cocat import DB + +pytestmark = pytest.mark.anyio + + +async def test_room_catalogues(server, user, room_id): + host, port = server + username, password = user + data = {"username": username, "password": password} + response = httpx.post(f"http://{host}:{port}/auth/jwt/login", data=data) + cookie = response.cookies.get("fastapiusersauth") + cookies = httpx.Cookies() + cookies.set("fastapiusersauth", cookie) + + # unauthenticated + response = httpx.get(f"http://{host}:{port}/rooms/{room_id}/catalogues") + assert response.status_code == 401 + + # room not accessible by user + response = httpx.get( + f"http://{host}:{port}/rooms/{uuid4()}/catalogues", cookies=cookies + ) + assert response.status_code == 403 + + # empty room + response = httpx.get( + f"http://{host}:{port}/rooms/{room_id}/catalogues", cookies=cookies + ) + assert response.status_code == 200 + assert response.json() == {"catalogues": []} + + # create catalogue with two events via WebSocket + async with AsyncWebSocketClient( + id=f"room/{room_id}", + host=f"http://{host}", + port=port, + cookies=cookies, + ) as client: + db = DB(doc=client.doc) + cat = db.create_catalogue(name="Solar Wind Events", author="tester") + cat_uuid = str(cat._uuid) + e1 = db.create_event(start="2024-03-15", stop="2024-03-16", author="tester") + e2 = db.create_event(start="2024-01-10", stop="2024-01-11", author="tester") + cat.add_events(e1) + cat.add_events(e2) + await sleep(0.2) + + response = httpx.get( + f"http://{host}:{port}/rooms/{room_id}/catalogues", cookies=cookies + ) + assert response.status_code == 200 + catalogues = response.json()["catalogues"] + assert len(catalogues) == 1 + assert catalogues[0]["uuid"] == cat_uuid + assert catalogues[0]["name"] == "Solar Wind Events" + assert catalogues[0]["nb_events"] == 2 + + +async def test_catalogue_and_events(server, user, room_id): + host, port = server + username, password = user + data = {"username": username, "password": password} + response = httpx.post(f"http://{host}:{port}/auth/jwt/login", data=data) + cookie = response.cookies.get("fastapiusersauth") + cookies = httpx.Cookies() + cookies.set("fastapiusersauth", cookie) + + # not found + fake_uuid = str(uuid4()) + assert ( + httpx.get( + f"http://{host}:{port}/catalogues/{fake_uuid}", cookies=cookies + ).status_code + == 404 + ) + assert ( + httpx.get( + f"http://{host}:{port}/catalogues/{fake_uuid}/events", cookies=cookies + ).status_code + == 404 + ) + + # create data + async with AsyncWebSocketClient( + id=f"room/{room_id}", + host=f"http://{host}", + port=port, + cookies=cookies, + ) as client: + db = DB(doc=client.doc) + cat = db.create_catalogue(name="Solar Wind Events", author="tester") + cat_uuid = str(cat._uuid) + e1 = db.create_event(start="2024-03-15", stop="2024-03-16", author="tester") + e2 = db.create_event(start="2024-01-10", stop="2024-01-11", author="tester") + cat.add_events(e1) + cat.add_events(e2) + await sleep(0.2) + + # GET /catalogues/{uuid} + response = httpx.get(f"http://{host}:{port}/catalogues/{cat_uuid}", cookies=cookies) + assert response.status_code == 200 + data = response.json() + assert data["uuid"] == cat_uuid + assert data["name"] == "Solar Wind Events" + assert data["room_id"] == room_id + assert data["nb_events"] == 2 + + # GET /catalogues/{uuid}/events — sorted by start + response = httpx.get( + f"http://{host}:{port}/catalogues/{cat_uuid}/events", cookies=cookies + ) + assert response.status_code == 200 + events = response.json()["events"] + assert len(events) == 2 + assert events[0]["start"] == "2024-01-10T00:00:00" + assert events[1]["start"] == "2024-03-15T00:00:00"