diff --git a/gs/backend/api/v1/aro/endpoints/user.py b/gs/backend/api/v1/aro/endpoints/user.py index be8400978..414b4ccb1 100644 --- a/gs/backend/api/v1/aro/endpoints/user.py +++ b/gs/backend/api/v1/aro/endpoints/user.py @@ -1,3 +1,68 @@ -from fastapi import APIRouter +from fastapi import APIRouter, HTTPException, status + +from gs.backend.api.v1.aro.models.requests import ChangeUserInfo, CreateUser, GetUserData +from gs.backend.data.data_wrappers.aro_wrapper.aro_user_data_wrapper import add_user, get_user_by_id, modify_user +from gs.backend.data.tables.aro_user_tables import AROUsers aro_user_router = APIRouter(tags=["ARO", "User Information"]) + + +@aro_user_router.post("/create_user/", status_code=status.HTTP_200_OK) +def create_user(payload: CreateUser) -> dict[str, AROUsers]: + """ + :param payload: Payload of type CreateUser, contains first_name, last_name, + call_sign, email, phone numer + all of type str + """ + try: + user = add_user( + call_sign=payload.call_sign, + email=payload.email, + f_name=payload.first_name, + l_name=payload.last_name, + phone_number=payload.phone_number, + ) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid input: {e}") from e + + return {"user": user} + + +@aro_user_router.get("/", status_code=status.HTTP_200_OK) +def get_user(payload: GetUserData) -> dict[str, AROUsers]: + """ + :param payload: Payload of type GetUserData, contains user_id of type str + """ + try: + user_id = payload.user_id + + except KeyError as e: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Missing user_id") from e + try: + user = get_user_by_id(user_id) + return {"user": user} + + except ValueError as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"User does not exist, {e}") from e + + +@aro_user_router.put("/", status_code=status.HTTP_200_OK) +def change_user_info(payload: ChangeUserInfo) -> dict[str, AROUsers]: + """ + :params payload: Payload of type ChangeUserInfo, contains user_id of type UUID, first_name, + last_name, call_sign + """ + try: + user_id = payload.user_id + + except KeyError as e: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Missing user_id") from e + + allowed_fields = {"first_name", "last_name", "call_sign"} + update_fields = {k: v for k, v in payload.model_dump().items() if v is not None and k in allowed_fields} + + try: + return {"data": modify_user(userid=user_id, **update_fields)} + + except ValueError as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) from e diff --git a/gs/backend/api/v1/aro/models/requests.py b/gs/backend/api/v1/aro/models/requests.py index e69de29bb..0ae3bfc26 100644 --- a/gs/backend/api/v1/aro/models/requests.py +++ b/gs/backend/api/v1/aro/models/requests.py @@ -0,0 +1,34 @@ +from uuid import UUID + +from pydantic import BaseModel + + +class CreateUser(BaseModel): + """ + Base model for create user, used for the create user endpoint + """ + + first_name: str + last_name: str + call_sign: str + email: str + phone_number: str + + +class ChangeUserInfo(BaseModel): + """ + Base model for changing user info, used for the change user info endpoint + """ + + user_id: UUID + first_name: str | None = None + last_name: str | None = None + call_sign: str | None = None + + +class GetUserData(BaseModel): + """ + Base model for getting user info, used by the get user info endpoint + """ + + user_id: UUID diff --git a/gs/backend/data/data_wrappers/aro_wrapper/aro_user_data_wrapper.py b/gs/backend/data/data_wrappers/aro_wrapper/aro_user_data_wrapper.py index fbb7aa89a..4c6396143 100644 --- a/gs/backend/data/data_wrappers/aro_wrapper/aro_user_data_wrapper.py +++ b/gs/backend/data/data_wrappers/aro_wrapper/aro_user_data_wrapper.py @@ -1,3 +1,4 @@ +from typing import Any from uuid import UUID from sqlmodel import select @@ -62,3 +63,38 @@ def delete_user_by_id(userid: UUID) -> list[AROUsers]: raise ValueError("User ID does not exist") return get_all_users() + + +# gets the user with given id +def get_user_by_id(userid: UUID) -> AROUsers: + """ + Use the user.id to delete a user from table + + :param userid: identifier unique to the user + """ + with get_db_session() as session: + user = session.get(AROUsers, userid) + + if user: + return user + else: + raise ValueError("User ID does not exist") + + +def modify_user(userid: UUID, **kwargs: dict[str, Any]) -> AROUsers: + """ + Modifies the target user + + :param userid: identifier unique to the user + """ + with get_db_session() as session: + user = session.get(AROUsers, userid) + if not user: + raise ValueError("User does not exist based on ID") + + for field, value in kwargs.items(): + setattr(user, field, value) + + session.commit() + session.refresh(user) + return user diff --git a/gs/backend/state_machine/state_enums.py b/gs/backend/state_machine/state_enums.py new file mode 100644 index 000000000..bcea28415 --- /dev/null +++ b/gs/backend/state_machine/state_enums.py @@ -0,0 +1,27 @@ +from enum import StrEnum, auto + + +class StateMachineStates(StrEnum): + """ + The states of the ground station state machine + Defaults to disconnected + """ + + # UPLINK STATES + DISCONNECTED = auto() + ATTEMPTING_CONNECTION = auto() + AWAITING_ACK = auto() + UPLINKING = auto() + DOWNLINKING = auto() + + # DISCONNECT STATES + AWAITING_DISCONNECT = auto() + SEND_DISCONNECT_ACK = auto() + + # EMERGENCY STATES + ENTERING_EMERGENCY = auto() + AWAITING_CONNECTION = auto() + SEND_CONNECTION_ACK = auto() + EMERGENCY_UPLINK = auto() + + SERVER_SIDE_ERROR = auto() diff --git a/gs/backend/state_machine/state_machine.py b/gs/backend/state_machine/state_machine.py new file mode 100644 index 000000000..47b0e9260 --- /dev/null +++ b/gs/backend/state_machine/state_machine.py @@ -0,0 +1,125 @@ +from gs.backend.state_machine.state_enums import StateMachineStates +from gs.backend.state_machine.state_transition_enums import StateTransition + + +class StateMachine: + """ + Ground station state machine. + """ + + def __init__(self, default_state: StateMachineStates) -> None: + """ + Initialize the state machine and sets the default state and default transitional state + + :params default_state: default state which must come from one of the enums in StateMachineStates + """ + self.state = default_state + self.transitional_state: StateTransition = StateTransition.NO_TRANSITION_TRIGGERED + + def switch_state(self, transitional_state: StateTransition) -> None: + """ + :params transitional_state: The transitional state of the state machine, used to update state + """ + self.transitional_state = transitional_state + + match self.state: + case StateMachineStates.DISCONNECTED: + match self.transitional_state: + case StateTransition.ENTER_EMERGENCY: + self.state = StateMachineStates.ENTERING_EMERGENCY + case StateTransition.BEGIN_UPLINK: + self.state = StateMachineStates.ATTEMPTING_CONNECTION + case _: + self.state = StateMachineStates.SERVER_SIDE_ERROR + + case StateMachineStates.ATTEMPTING_CONNECTION: + match self.transitional_state: + case StateTransition.CONNECTION_ESTABLISHED: + self.state = StateMachineStates.AWAITING_ACK + case StateTransition.ERROR: + self.state = StateMachineStates.DISCONNECTED + case _: + self.state = StateMachineStates.SERVER_SIDE_ERROR + + case StateMachineStates.AWAITING_ACK: + match self.transitional_state: + case StateTransition.ACK_RECEIVED: + self.state = StateMachineStates.UPLINKING + case StateTransition.ERROR: + self.state = StateMachineStates.DISCONNECTED + case _: + self.state = StateMachineStates.SERVER_SIDE_ERROR + + case StateMachineStates.UPLINKING: + match self.transitional_state: + case StateTransition.UPLINK_FINISHED: + self.state = StateMachineStates.DOWNLINKING + case StateTransition.DISCONNECTING: + self.state = StateMachineStates.AWAITING_DISCONNECT + case StateTransition.ERROR: + self.state = StateMachineStates.DISCONNECTED + case _: + self.state = StateMachineStates.SERVER_SIDE_ERROR + + case StateMachineStates.AWAITING_DISCONNECT: + match self.transitional_state: + case StateTransition.DISCONNECT_CMD_RECEIVED: + self.state = StateMachineStates.SEND_DISCONNECT_ACK + case StateTransition.ERROR: + self.state = StateMachineStates.DISCONNECTED + case _: + self.state = StateMachineStates.SERVER_SIDE_ERROR + + case StateMachineStates.SEND_DISCONNECT_ACK: + match self.transitional_state: + case StateTransition.DISCONNECT_COMPLETE: + self.state = StateMachineStates.DISCONNECTED + case StateTransition.ERROR: + self.state = StateMachineStates.DISCONNECTED + case _: + self.state = StateMachineStates.SERVER_SIDE_ERROR + + case StateMachineStates.DOWNLINKING: + match self.transitional_state: + case StateTransition.DOWNLINKING_FINISHED: + self.state = StateMachineStates.UPLINKING + case StateTransition.ERROR: + self.state = StateMachineStates.DISCONNECTED + case _: + self.state = StateMachineStates.SERVER_SIDE_ERROR + + case StateMachineStates.ENTERING_EMERGENCY: + match self.transitional_state: + case StateTransition.EMERGENCY_INITIATED: + self.state = StateMachineStates.AWAITING_CONNECTION + case StateTransition.ERROR: + self.state = StateMachineStates.DISCONNECTED + case _: + self.state = StateMachineStates.SERVER_SIDE_ERROR + + case StateMachineStates.AWAITING_CONNECTION: + match self.transitional_state: + case StateTransition.CONNECTION_RECEIVED: + self.state = StateMachineStates.SEND_CONNECTION_ACK + case StateTransition.ERROR: + self.state = StateMachineStates.DISCONNECTED + case _: + self.state = StateMachineStates.SERVER_SIDE_ERROR + + case StateMachineStates.SEND_CONNECTION_ACK: + match self.transitional_state: + case StateTransition.CONNECTION_ACK_SENT: + self.state = StateMachineStates.EMERGENCY_UPLINK + case StateTransition.ERROR: + self.state = StateMachineStates.DISCONNECTED + case _: + self.state = StateMachineStates.SERVER_SIDE_ERROR + + case StateMachineStates.EMERGENCY_UPLINK: + match self.transitional_state: + case StateTransition.EMERGENCY_UPLINK_FINISHED: + self.state = StateMachineStates.DISCONNECTED + case StateTransition.ERROR: + self.state = StateMachineStates.DISCONNECTED + case _: + self.state = StateMachineStates.SERVER_SIDE_ERROR diff --git a/gs/backend/state_machine/state_transition_enums.py b/gs/backend/state_machine/state_transition_enums.py new file mode 100644 index 000000000..86f0a014e --- /dev/null +++ b/gs/backend/state_machine/state_transition_enums.py @@ -0,0 +1,33 @@ +from enum import StrEnum, auto + + +class StateTransition(StrEnum): + """ + The transition states of the ground station state machine + """ + + # TODO rename / clarify the specific transition states + ERROR = auto() + + # UPLINK TRANSITION STATES + BEGIN_UPLINK = auto() + CONNECTION_ESTABLISHED = auto() + ACK_RECEIVED = auto() + + # UPLINK / DOWNLINK TRANSITION STATES + UPLINK_FINISHED = auto() + DOWNLINKING_FINISHED = auto() + + # DISCONNECT TRANSITION STATES + DISCONNECTING = auto() + DISCONNECT_CMD_RECEIVED = auto() + DISCONNECT_COMPLETE = auto() + + # EMERGENCY TRANSITION STATES + ENTER_EMERGENCY = auto() + EMERGENCY_INITIATED = auto() + CONNECTION_RECEIVED = auto() + CONNECTION_ACK_SENT = auto() + EMERGENCY_UPLINK_FINISHED = auto() + + NO_TRANSITION_TRIGGERED = auto() # NOT USED IN STATE MACHINE diff --git a/python_test/test_user_api_endpoint.py b/python_test/test_user_api_endpoint.py new file mode 100644 index 000000000..ef4e98d17 --- /dev/null +++ b/python_test/test_user_api_endpoint.py @@ -0,0 +1,22 @@ +from fastapi.testclient import TestClient +from gs.backend.main import app + + +def test_create_user(): + with TestClient(app) as client: + json_obj = { + "call_sign": "KEVWAN", + "email": "kevwan19@gmail.com", + "first_name": "kevin", + "last_name": "wan", + "phone_number": "18008888888", + } + + res = client.post("/api/v1/aro/user/create_user/", json=json_obj) + assert res.status_code == 200 + user = res.model_dump().get("user") + assert user.call_sign == "KEVWAN" + assert user.email == "kevwan19@gmail.com" + assert user.first_name == "kevin" + assert user.last_name == "wan" + assert user.phone_number == "18008888888"