diff --git a/app/api/rest/__init__.py b/app/api/rest/__init__.py index d32af44..f5870b3 100644 --- a/app/api/rest/__init__.py +++ b/app/api/rest/__init__.py @@ -3,7 +3,9 @@ rest_api_router = APIRouter() from app.api.rest.v1.accounts.controllers import router as v1_accounts_router +from app.api.rest.v1.sessions.controllers import router as v1_sessions_router from app.api.rest.v1.scores.controllers import router as v1_scores_router rest_api_router.include_router(v1_accounts_router) +rest_api_router.include_router(v1_sessions_router) rest_api_router.include_router(v1_scores_router) diff --git a/app/api/rest/v1/sessions/controllers.py b/app/api/rest/v1/sessions/controllers.py new file mode 100644 index 0000000..61cdffb --- /dev/null +++ b/app/api/rest/v1/sessions/controllers.py @@ -0,0 +1,83 @@ +from uuid import UUID + +from fastapi import APIRouter +from fastapi import status + +from app import logger +from app.api.rest import responses +from app.api.rest.responses import Success +from app.api.rest.v1.sessions.models import Session +from app.errors import ServiceError +from app.services import sessions + +router = APIRouter() + + +def determine_status_code(error: ServiceError) -> int: + match error: + case ServiceError.CREDENTIALS_NOT_FOUND: + return status.HTTP_401_UNAUTHORIZED + case ServiceError.CREDENTIALS_INCORRECT: + return status.HTTP_401_UNAUTHORIZED + case ServiceError.INTERNAL_SERVER_ERROR: + return status.HTTP_500_INTERNAL_SERVER_ERROR + case ServiceError.SESSIONS_NOT_FOUND: + return status.HTTP_404_NOT_FOUND + case _: + logger.warning( + "Unhandled error code in sessions rest api controller", + service_error=error, + ) + return status.HTTP_500_INTERNAL_SERVER_ERROR + + +# TODO: fetch_many, down the stack +@router.get("/v1/sessions") +async def fetch_all() -> Success[list[Session]]: + data = await sessions.fetch_all() + if isinstance(data, ServiceError): + status_code = determine_status_code(data) + return responses.failure( + error=data, + message="Failed to fetch sessions", + status_code=status_code, + ) + + resp = [Session.parse_obj(rec) for rec in data] + return responses.success( + content=resp, + meta={}, + ) + + +@router.get("/v1/sessions/{session_id}") +async def fetch_one(session_id: UUID) -> Success[Session]: + data = await sessions.fetch_one(session_id) + if isinstance(data, ServiceError): + status_code = determine_status_code(data) + return responses.failure( + error=data, + message="Failed to fetch session", + status_code=status_code, + ) + + resp = Session.parse_obj(data) + return responses.success(content=resp) + + +# TODO: PATCH /v1/sessions/{session_id} + + +@router.delete("/v1/sessions/{session_id}") +async def delete(session_id: UUID) -> Success[Session]: + data = await sessions.delete(session_id) + if isinstance(data, ServiceError): + status_code = determine_status_code(data) + return responses.failure( + error=data, + message="Failed to delete session", + status_code=status_code, + ) + + resp = Session.parse_obj(data) + return responses.success(content=resp) diff --git a/app/api/rest/v1/sessions/models.py b/app/api/rest/v1/sessions/models.py new file mode 100644 index 0000000..107056b --- /dev/null +++ b/app/api/rest/v1/sessions/models.py @@ -0,0 +1,49 @@ +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel + + +# input models + + +class LoginForm(BaseModel): + username: str + password: str + + +# TODO: PresenceUpdate, SessionUpdate models + + +# output models + + +class Presence(BaseModel): + account_id: int + username: str + utc_offset: int + country: str + privileges: int + game_mode: int + latitude: float + longitude: float + action: int + info_text: str + beatmap_md5: str + beatmap_id: int + mods: int + receive_match_updates: bool + spectator_host_session_id: UUID | None + away_message: str | None + multiplayer_match_id: int | None + last_communicated_at: datetime + last_np_beatmap_id: int | None + + +class Session(BaseModel): + session_id: UUID + account_id: int + presence: Presence + expires_at: datetime + created_at: datetime + # updated_at: datetime diff --git a/app/services/sessions.py b/app/services/sessions.py new file mode 100644 index 0000000..88110f7 --- /dev/null +++ b/app/services/sessions.py @@ -0,0 +1,97 @@ +from datetime import datetime +from uuid import UUID +from uuid import uuid4 + +from app import logger +from app import security +from app.errors import ServiceError +from app.repositories import accounts +from app.repositories import sessions +from app.repositories.sessions import Session + + +async def create( + username: str, + password: str, + utc_offset: int, + latitude: float, + longitude: float, +) -> Session | ServiceError: + try: + account = await accounts.fetch_by_username(username) + if account is None: + return ServiceError.CREDENTIALS_NOT_FOUND + + if not security.check_password( + password=password, + hashword=account["password"].encode(), + ): + return ServiceError.CREDENTIALS_INCORRECT + + session_id = uuid4() + session = await sessions.create( + session_id, + account["account_id"], + presence={ + "account_id": account["account_id"], + "username": account["username"], + "utc_offset": utc_offset, + "country": account["country"], + "privileges": account["privileges"], + "game_mode": 0, # TODO? + "latitude": latitude, + "longitude": longitude, + "action": 0, + "info_text": "", + "beatmap_md5": "", + "beatmap_id": 0, + "mods": 0, + "receive_match_updates": False, + "spectator_host_session_id": None, + "away_message": None, + "multiplayer_match_id": None, + "last_communicated_at": datetime.now(), + "last_np_beatmap_id": None, + }, + ) + except Exception as exc: # pragma: no cover + logger.error("Failed to create session", exc_info=exc) + return ServiceError.INTERNAL_SERVER_ERROR + + return session + + +async def fetch_all() -> list[Session] | ServiceError: + try: + _sessions = await sessions.fetch_all() + except Exception as exc: # pragma: no cover + logger.error("Failed to fetch sessions", exc_info=exc) + return ServiceError.INTERNAL_SERVER_ERROR + + return _sessions + + +async def fetch_one(session_id: UUID) -> Session | ServiceError: + try: + session = await sessions.fetch_by_id(session_id) + except Exception as exc: # pragma: no cover + logger.error("Failed to fetch session", exc_info=exc) + return ServiceError.INTERNAL_SERVER_ERROR + + if session is None: + return ServiceError.SESSIONS_NOT_FOUND + + return session + + +async def delete(session_id: UUID) -> Session | ServiceError: + try: + session = await sessions.delete_by_id(session_id) + except Exception as exc: # pragma: no cover + logger.error("Failed to delete session", exc_info=exc) + return ServiceError.INTERNAL_SERVER_ERROR + + if session is None: + return ServiceError.SESSIONS_NOT_FOUND + + return session