diff --git a/backend/src/flotilla/api/events_api.py b/backend/src/flotilla/api/events_api.py new file mode 100644 index 000000000..fa2a585ed --- /dev/null +++ b/backend/src/flotilla/api/events_api.py @@ -0,0 +1,133 @@ +from http import HTTPStatus +from logging import getLogger +from typing import List + +from fastapi import APIRouter, Body, Depends, Path, Response, Security +from flotilla_openapi.models.event import Event +from flotilla_openapi.models.event_request import EventRequest +from flotilla_openapi.models.problem_details import ProblemDetails +from pytest import Session + +from flotilla.api.authentication import authentication_scheme +from flotilla.database.crud import ( + DBException, + create_event, + read_event_by_id, + read_events, + remove_event, +) +from flotilla.database.db import get_db +from flotilla.database.models import EventDBModel + +logger = getLogger("api") + +router = APIRouter() + +NOT_FOUND_DESCRIPTION = "Not Found - No event with given id" +INTERNAL_SERVER_ERROR_DESCRIPTION = "Internal Server Error" + + +@router.get( + "/events", + responses={ + HTTPStatus.OK.value: { + "model": List[Event], + "description": "Request successful.", + }, + }, + tags=["Events"], + summary="Lookup events", + dependencies=[Security(authentication_scheme)], +) +async def get_events( + db: Session = Depends(get_db), +) -> List[Event]: + """Lookup events.""" + db_events: List[EventDBModel] = read_events(db) + events: List[Event] = [event.get_api_event() for event in db_events] + return events + + +@router.post( + "/events", + responses={ + HTTPStatus.CREATED.value: {"model": Event, "description": "Request successful"}, + }, + tags=["Events"], + summary="Create new event", + dependencies=[Security(authentication_scheme)], +) +async def post_event( + response: Response, + db: Session = Depends(get_db), + event_request: EventRequest = Body(None, description="Time entry update"), +) -> Event: + """Add a new event to the robot schedule""" + try: + event_id: int = create_event( + db, + event_request.robot_id, + event_request.mission_id, + event_request.start_time, + ) + db_event: EventDBModel = read_event_by_id(db, event_id) + event: EventDBModel = db_event.get_api_event() + except DBException: + logger.exception(f"An error occured while creating event.") + response.status_code = HTTPStatus.INTERNAL_SERVER_ERROR.value + return ProblemDetails(title=INTERNAL_SERVER_ERROR_DESCRIPTION) + response.status_code = HTTPStatus.CREATED.value + return event + + +@router.delete( + "/events/{event_id}", + responses={ + HTTPStatus.NO_CONTENT.value: {"description": "Event successfully deleted"}, + }, + tags=["Events"], + summary="Delete event with specified id", + dependencies=[Security(authentication_scheme)], +) +async def delete_event( + response: Response, + db: Session = Depends(get_db), + event_id: int = Path(None, description=""), +) -> None: + """Deletes an event from the robot schedule. Can only be used for events that have not started yet.""" + try: + remove_event(db, event_id) + except DBException: + logger.exception(f"Could not delete event with id {event_id}.") + response.status_code = HTTPStatus.NOT_FOUND.value + return ProblemDetails(title=NOT_FOUND_DESCRIPTION) + response.status_code = HTTPStatus.NO_CONTENT.value + return None + + +@router.get( + "/events/{event_id}", + responses={ + HTTPStatus.OK.value: { + "model": Event, + "description": "Request successful.", + }, + }, + tags=["Events"], + summary="Lookup event with specified id", + dependencies=[Security(authentication_scheme)], +) +async def get_event( + response: Response, + db: Session = Depends(get_db), + event_id: int = Path(None, description=""), +) -> Event: + """Lookup event with specified id. Can only be used for events that have not started yet.""" + try: + db_event: EventDBModel = read_event_by_id(db, event_id) + event: EventDBModel = db_event.get_api_event() + except DBException: + logger.exception(f"Could not get event with id {event_id}.") + response.status_code = HTTPStatus.NOT_FOUND.value + return ProblemDetails(title=NOT_FOUND_DESCRIPTION) + return event diff --git a/backend/src/flotilla/api/robots_api.py b/backend/src/flotilla/api/robots_api.py index 631077871..a5a0e5af1 100644 --- a/backend/src/flotilla/api/robots_api.py +++ b/backend/src/flotilla/api/robots_api.py @@ -42,9 +42,7 @@ summary="List all robots on the asset.", dependencies=[Security(authentication_scheme)], ) -async def get_robots( - response: Response, db: Session = Depends(get_db) -) -> List[RobotDBModel]: +async def get_robots(db: Session = Depends(get_db)) -> List[Robot]: db_robots: List[RobotDBModel] = read_robots(db) robots: List[Robot] = [robot.get_api_robot() for robot in db_robots] return robots @@ -66,12 +64,12 @@ async def get_robot( response: Response, robot_id: int = Path(None, description=""), db: Session = Depends(get_db), -) -> RobotDBModel: +) -> Robot: try: db_robot: RobotDBModel = read_robot_by_id(db=db, robot_id=robot_id) robot: Robot = db_robot.get_api_robot() except DBException: - logger.error(f"Could not get robot with id {robot_id}.") + logger.exception(f"Could not get robot with id {robot_id}.") response.status_code = HTTPStatus.NOT_FOUND.value return ProblemDetails( title=NOT_FOUND_DESCRIPTION, status=HTTPStatus.NOT_FOUND.value @@ -113,7 +111,7 @@ async def post_start_robot( report_status=ReportStatus.in_progress, ) except DBException: - logger.error(f"Could not get robot with id {robot_id}.") + logger.exception(f"Could not get robot with id {robot_id}.") response.status_code = HTTPStatus.NOT_FOUND.value return ProblemDetails(title=NOT_FOUND_DESCRIPTION) except HTTPError as e: @@ -154,7 +152,7 @@ async def post_stop_robot( host=robot.host, port=robot.port ) except DBException: - logger.error(f"Could not get robot with id {robot_id}.") + logger.exception(f"Could not get robot with id {robot_id}.") response.status_code = HTTPStatus.NOT_FOUND.value return ProblemDetails(title=NOT_FOUND_DESCRIPTION) except HTTPError as e: diff --git a/backend/src/flotilla/database/crud.py b/backend/src/flotilla/database/crud.py index 2fa47d73f..bec065a54 100644 --- a/backend/src/flotilla/database/crud.py +++ b/backend/src/flotilla/database/crud.py @@ -1,8 +1,14 @@ +import datetime from typing import List, Optional from sqlalchemy.orm import Session -from flotilla.database.models import ReportDBModel, ReportStatus, RobotDBModel +from flotilla.database.models import ( + EventDBModel, + ReportDBModel, + ReportStatus, + RobotDBModel, +) class DBException(Exception): @@ -37,6 +43,20 @@ def read_report_by_id(db: Session, report_id: int) -> ReportDBModel: return report +def read_events(db: Session) -> List[EventDBModel]: + events: List[EventDBModel] = db.query(EventDBModel).all() + return events + + +def read_event_by_id(db: Session, event_id: int) -> EventDBModel: + event: Optional[EventDBModel] = ( + db.query(EventDBModel).filter(EventDBModel.id == event_id).first() + ) + if not event: + raise DBException(f"No event with id {event_id}") + return event + + def create_report( db: Session, robot_id: int, @@ -54,3 +74,29 @@ def create_report( db.add(report) db.commit() return report.id + + +def create_event( + db: Session, + robot_id: int, + echo_mission_id: int, + start_time: datetime.datetime, +) -> int: + + event: EventDBModel = EventDBModel( + robot_id=robot_id, + echo_mission_id=echo_mission_id, + report_id=None, + start_time=start_time, + estimated_duration=datetime.timedelta(hours=1), + ) + db.add(event) + db.commit() + return event.id + + +def remove_event(db: Session, event_id: int) -> None: + event: EventDBModel = read_event_by_id(db, event_id) + db.delete(event) + db.commit() + return diff --git a/backend/src/flotilla/database/models.py b/backend/src/flotilla/database/models.py index 6f9c71545..288e7ad2f 100644 --- a/backend/src/flotilla/database/models.py +++ b/backend/src/flotilla/database/models.py @@ -1,6 +1,7 @@ import datetime import enum +from flotilla_openapi.models.event import Event from flotilla_openapi.models.report import Report from flotilla_openapi.models.report_entry import ReportEntry from flotilla_openapi.models.robot import Robot @@ -132,6 +133,15 @@ class EventDBModel(Base): # TODO: robot_id and report_id.robot_id can now point at different robots. # Should there be a constraint forcing an event to point at only one robot? + def get_api_event(self) -> Event: + return Event( + id=self.id, + robot_id=self.robot_id, + mission_id=self.echo_mission_id, + start_time=self.start_time, + end_time=datetime.datetime.now(), + ) + class ReportEntryDBModel(Base): __tablename__ = "entry" diff --git a/backend/src/flotilla/main.py b/backend/src/flotilla/main.py index 38c77d8c5..92c0ca5e8 100644 --- a/backend/src/flotilla/main.py +++ b/backend/src/flotilla/main.py @@ -5,6 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware from flotilla.api.authentication import authenticator +from flotilla.api.events_api import router as events_router from flotilla.api.missions_api import router as missions_router from flotilla.api.reports_api import router as reports_router from flotilla.api.robots_api import router as robots_router @@ -46,6 +47,7 @@ def startup_event(): app.include_router(robots_router) app.include_router(missions_router) app.include_router(reports_router) +app.include_router(events_router) if __name__ == "__main__": diff --git a/backend/tests/test_api/test_events_api.py b/backend/tests/test_api/test_events_api.py new file mode 100644 index 000000000..7ca6c4ac0 --- /dev/null +++ b/backend/tests/test_api/test_events_api.py @@ -0,0 +1,86 @@ +import json +from datetime import datetime +from http import HTTPStatus + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from flotilla_openapi.models.event_request import EventRequest + + +def test_get_events(test_app: FastAPI): + + with TestClient(test_app) as client: + response = client.get("/events") + assert response.status_code == HTTPStatus.OK.value + + +@pytest.mark.parametrize( + "event_id, expected_status_code", + [ + ( + 2, + HTTPStatus.OK.value, + ), + ( + 4, + HTTPStatus.NOT_FOUND.value, + ), + ], +) +def test_get_event( + test_app: FastAPI, + event_id: int, + expected_status_code: int, +): + + with TestClient(test_app) as client: + response = client.get(f"/events/{event_id}") + assert response.status_code == expected_status_code + + +@pytest.mark.parametrize( + "event_request, expected_status_code", + [ + ( + EventRequest(robot_id=1, mission_id=234, start_time=datetime.now()), + HTTPStatus.CREATED.value, + ), + ], +) +def test_post_event( + test_app: FastAPI, + event_request: EventRequest, + expected_status_code: int, +): + + with TestClient(test_app) as client: + response = client.post( + f"/events", + data=json.dumps(event_request.dict(), default=str), + ) + assert response.status_code == expected_status_code + + +@pytest.mark.parametrize( + "event_id, expected_status_code", + [ + ( + 1, + HTTPStatus.NO_CONTENT.value, + ), + ( + 5, + HTTPStatus.NOT_FOUND.value, + ), + ], +) +def test_delete_event( + test_app: FastAPI, + event_id: int, + expected_status_code: int, +): + + with TestClient(test_app) as client: + response = client.delete(f"/events/{event_id}") + assert response.status_code == expected_status_code