diff --git a/api/app/api.py b/api/app/api.py index d343252..6ebe0f5 100644 --- a/api/app/api.py +++ b/api/app/api.py @@ -6,7 +6,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from database.utils import create_admin_user -from routes import users, flights, auth +from routes import users, flights, auth, aircraft logger = logging.getLogger("api") @@ -31,4 +31,5 @@ app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, # Add subroutes app.include_router(users.router, tags=["Users"], prefix="/users") app.include_router(flights.router, tags=["Flights"], prefix="/flights") +app.include_router(aircraft.router, tags=["Aircraft"], prefix="/aircraft") app.include_router(auth.router, tags=["Auth"], prefix="/auth") diff --git a/api/database/aircraft.py b/api/database/aircraft.py new file mode 100644 index 0000000..98c676a --- /dev/null +++ b/api/database/aircraft.py @@ -0,0 +1,87 @@ +from bson import ObjectId +from fastapi import HTTPException + +from database.db import aircraft_collection +from database.utils import aircraft_display_helper, aircraft_add_helper +from schemas.aircraft import AircraftDisplaySchema, AircraftCreateSchema + + +async def retrieve_aircraft(user: str = "") -> list[AircraftDisplaySchema]: + """ + Retrieve a list of aircraft, optionally filtered by user + + :param user: User to filter aircraft by + :return: List of aircraft + """ + aircraft = [] + if user == "": + async for doc in aircraft_collection.find(): + aircraft.append(AircraftDisplaySchema(**aircraft_display_helper(doc))) + else: + async for doc in aircraft_collection.find({"user": ObjectId(user)}): + aircraft.append(AircraftDisplaySchema(**aircraft_display_helper(doc))) + + return aircraft + + +async def retrieve_aircraft_by_id(id: str) -> AircraftDisplaySchema: + """ + Retrieve details about the requested aircraft + + :param id: ID of desired aircraft + :return: Aircraft details + """ + aircraft = await aircraft_collection.find_one({"_id": ObjectId(id)}) + + if aircraft is None: + raise HTTPException(404, "Aircraft not found") + + return AircraftDisplaySchema(**aircraft_display_helper(aircraft)) + + +async def insert_aircraft(body: AircraftCreateSchema, id: str) -> ObjectId: + """ + Insert a new aircraft into the database + + :param body: Aircraft data + :param id: ID of creating user + :return: ID of inserted aircraft + """ + aircraft = await aircraft_collection.insert_one(aircraft_add_helper(body.model_dump(), id)) + return aircraft.inserted_id + + +async def update_aircraft(body: AircraftCreateSchema, id: str) -> AircraftDisplaySchema: + """ + Update given aircraft in the database + + :param body: Updated aircraft data + :param id: ID of aircraft to update + :return: ID of updated aircraft + """ + aircraft = await aircraft_collection.find_one({"_id": ObjectId(id)}) + + if aircraft is None: + raise HTTPException(404, "Aircraft not found") + + updated_aircraft = await aircraft_collection.update_one({"_id": ObjectId(id)}, {"$set": body.model_dump()}) + if updated_aircraft is None: + raise HTTPException(500, "Failed to update flight") + + return id + + +async def delete_aircraft(id: str) -> AircraftDisplaySchema: + """ + Delete the given aircraft from the database + + :param id: ID of aircraft to delete + :return: Deleted aircraft information + """ + aircraft = await aircraft_collection.find_one({"_id": ObjectId(id)}) + + if aircraft is None: + raise HTTPException(404, "Aircraft not found") + + await aircraft_collection.delete_one({"_id": ObjectId(id)}) + return AircraftDisplaySchema(**aircraft_display_helper(aircraft)) diff --git a/api/database/db.py b/api/database/db.py index bed56f5..f9199a4 100644 --- a/api/database/db.py +++ b/api/database/db.py @@ -24,4 +24,5 @@ except Exception as e: # Get db collections user_collection = db_client["user"] flight_collection = db_client["flight"] +aircraft_collection = db_client["aircraft"] token_collection = db_client["token_blacklist"] diff --git a/api/database/flights.py b/api/database/flights.py index 1c2e0c3..c597cd4 100644 --- a/api/database/flights.py +++ b/api/database/flights.py @@ -88,8 +88,7 @@ async def retrieve_flight(id: str) -> FlightDisplaySchema: :param id: ID of flight to retrieve :return: Flight information """ - oid = ObjectId(id) - flight = await flight_collection.find_one({"_id": oid}) + flight = await flight_collection.find_one({"_id": ObjectId(id)}) if flight is None: raise HTTPException(404, "Flight not found") diff --git a/api/database/utils.py b/api/database/utils.py index c27130e..d0e7259 100644 --- a/api/database/utils.py +++ b/api/database/utils.py @@ -3,6 +3,7 @@ import logging from bson import ObjectId from app.config import get_settings +from schemas.aircraft import AircraftCategory, AircraftClass from .db import user_collection from routes.utils import get_hashed_password from schemas.user import AuthLevel, UserCreateSchema @@ -13,6 +14,7 @@ logger = logging.getLogger("api") def user_helper(user) -> dict: """ Convert given db response into a format usable by UserDisplaySchema + :param user: Database response :return: Usable dict """ @@ -26,6 +28,7 @@ def user_helper(user) -> dict: def system_user_helper(user) -> dict: """ Convert given db response to a format usable by UserSystemSchema + :param user: Database response :return: Usable dict """ @@ -40,6 +43,7 @@ def system_user_helper(user) -> dict: def create_user_helper(user) -> dict: """ Convert given db response to a format usable by UserCreateSchema + :param user: Database response :return: Usable dict """ @@ -53,6 +57,7 @@ def create_user_helper(user) -> dict: def flight_display_helper(flight: dict) -> dict: """ Convert given db response to a format usable by FlightDisplaySchema + :param flight: Database response :return: Usable dict """ @@ -65,6 +70,7 @@ def flight_display_helper(flight: dict) -> dict: def flight_add_helper(flight: dict, user: str) -> dict: """ Convert given flight schema and user string to a format that can be inserted into the db + :param flight: Flight request body :param user: User that created flight :return: Combined dict that can be inserted into db @@ -73,6 +79,36 @@ def flight_add_helper(flight: dict, user: str) -> dict: return flight +def aircraft_add_helper(aircraft: dict, user: str) -> dict: + """ + Convert given aircraft dict to a format that can be inserted into the db + + :param aircraft: Aircraft request body + :param user: User that created aircraft + :return: Combined dict that can be inserted into db + """ + aircraft["user"] = ObjectId(user) + aircraft["aircraft_category"] = aircraft["aircraft_category"].name + aircraft["aircraft_class"] = aircraft["aircraft_class"].name + return aircraft + + +def aircraft_display_helper(aircraft: dict) -> dict: + """ + Convert given db response into a format usable by AircraftDisplaySchema + + :param aircraft: + :return: USable dict + """ + aircraft["id"] = str(aircraft["_id"]) + aircraft["user"] = str(aircraft["user"]) + if aircraft["aircraft_category"] is not AircraftCategory: + aircraft["aircraft_category"] = AircraftCategory.__members__.get(aircraft["aircraft_category"]) + if aircraft["aircraft_class"] is not AircraftClass: + aircraft["aircraft_class"] = AircraftClass.__members__.get(aircraft["aircraft_class"]) + return aircraft + + # UTILS # async def create_admin_user(): diff --git a/api/routes/aircraft.py b/api/routes/aircraft.py new file mode 100644 index 0000000..a63326f --- /dev/null +++ b/api/routes/aircraft.py @@ -0,0 +1,117 @@ +import logging + +from fastapi import APIRouter, Depends, HTTPException + +from app.deps import get_current_user, admin_required +from database import aircraft as db +from schemas.aircraft import AircraftDisplaySchema, AircraftCreateSchema +from schemas.user import UserDisplaySchema, AuthLevel + +router = APIRouter() + +logger = logging.getLogger("aircraft") + + +@router.get('/', summary="Get aircraft created by the currently logged-in user", status_code=200) +async def get_aircraft(user: UserDisplaySchema = Depends(get_current_user)) -> list[AircraftDisplaySchema]: + """ + Get a list of aircraft created by the currently logged-in user + + :param user: Current user + :return: List of aircraft + """ + aircraft = await db.retrieve_aircraft(user.id) + return aircraft + + +@router.get('/all', summary="Get all aircraft created by all users", status_code=200, + dependencies=[Depends(admin_required)], response_model=list[AircraftDisplaySchema]) +async def get_all_aircraft() -> list[AircraftDisplaySchema]: + """ + Get a list of all aircraft created by any user + + :return: List of aircraft + """ + aircraft = await db.retrieve_aircraft() + return aircraft + + +@router.get('/{aircraft_id}', summary="Get details of a given aircraft", response_model=AircraftDisplaySchema, + status_code=200) +async def get_aircraft_by_id(aircraft_id: str, + user: UserDisplaySchema = Depends(get_current_user)) -> AircraftDisplaySchema: + """ + Get all details of a given aircraft + + :param aircraft_id: ID of requested aircraft + :param user: Currently logged-in user + :return: Aircraft details + """ + aircraft = await db.retrieve_aircraft_by_id(aircraft_id) + if str(aircraft.user) != user.id and AuthLevel(user.level) != AuthLevel.ADMIN: + logger.info("Attempted access to unauthorized aircraft by %s", user.username) + raise HTTPException(403, "Unauthorized access") + + return aircraft + + +@router.post('/', summary="Add an aircraft", status_code=200) +async def add_aircraft(aircraft_body: AircraftCreateSchema, + user: UserDisplaySchema = Depends(get_current_user)) -> dict: + """ + Add an aircraft to the database + + :param aircraft_body: Information associated with new aircraft + :param user: Currently logged-in user + :return: Error message if request invalid, else ID of newly created aircraft + """ + + aircraft = await db.insert_aircraft(aircraft_body, user.id) + + return {"id": str(aircraft)} + + +@router.put('/{aircraft_id}', summary="Update the given aircraft with new information", status_code=200) +async def update_aircraft(aircraft_id: str, aircraft_body: AircraftCreateSchema, + user: UserDisplaySchema = Depends(get_current_user)) -> dict: + """ + Update the given aircraft with new information + + :param aircraft_id: ID of aircraft to update + :param aircraft_body: New aircraft information to update with + :param user: Currently logged-in user + :return: Updated aircraft + """ + aircraft = await get_aircraft_by_id(aircraft_id, user) + if aircraft is None: + raise HTTPException(404, "Aircraft not found") + + if str(aircraft.user) != user.id and AuthLevel(user.level) != AuthLevel.ADMIN: + logger.info("Attempted access to unauthorized aircraft by %s", user.username) + raise HTTPException(403, "Unauthorized access") + + updated_aircraft_id = await db.update_aircraft(aircraft_body, aircraft_id) + + return {"id": str(updated_aircraft_id)} + + +@router.delete('/{aircraft_id}', summary="Delete the given aircraft", status_code=200, + response_model=AircraftDisplaySchema) +async def delete_aircraft(aircraft_id: str, + user: UserDisplaySchema = Depends(get_current_user)) -> AircraftDisplaySchema: + """ + Delete the given aircraft + + :param aircraft_id: ID of aircraft to delete + :param user: Currently logged-in user + :return: 200 + """ + aircraft = await get_aircraft_by_id(aircraft_id, user) + + if str(aircraft.user) != user.id and AuthLevel(user.level) != AuthLevel.ADMIN: + logger.info("Attempted access to unauthorized aircraft by %s", user.username) + raise HTTPException(403, "Unauthorized access") + + deleted = await db.delete_aircraft(aircraft_id) + + return deleted diff --git a/api/routes/flights.py b/api/routes/flights.py index 6f5b837..d274a5b 100644 --- a/api/routes/flights.py +++ b/api/routes/flights.py @@ -1,14 +1,12 @@ -import json import logging from datetime import datetime -from typing import Dict, Union, List from fastapi import APIRouter, HTTPException, Depends from app.deps import get_current_user, admin_required from database import flights as db -from schemas.flight import FlightConciseSchema, FlightDisplaySchema, FlightCreateSchema, FlightByDateSchema +from schemas.flight import FlightConciseSchema, FlightDisplaySchema, FlightCreateSchema, FlightByDateSchema from schemas.user import UserDisplaySchema, AuthLevel router = APIRouter() @@ -27,7 +25,6 @@ async def get_flights(user: UserDisplaySchema = Depends(get_current_user), sort: :param order: Order of sorting (asc/desc) :return: List of flights """ - # l = get_flight_list(filters=[[{"field": "user", "operator": "eq", "value": user.id}]]) flights = await db.retrieve_flights(user.id, sort, order) return flights diff --git a/api/schemas/aircraft.py b/api/schemas/aircraft.py new file mode 100644 index 0000000..0ce002a --- /dev/null +++ b/api/schemas/aircraft.py @@ -0,0 +1,101 @@ +from enum import Enum +from typing import Annotated + +from pydantic import BaseModel, field_validator, Field +from pydantic_core.core_schema import ValidationInfo + +from schemas.flight import PyObjectId + + +class AircraftCategory(Enum): + airplane = "Airplane" + rotorcraft = "Rotorcraft" + powered_lift = "Powered Lift" + glider = "Glider" + lighter_than_air = "Lighter-Than-Air" + ppg = "Powered Parachute" + weight_shift = "Weight-Shift Control" + + +class AircraftClass(Enum): + # Airplane + sel = "Single-Engine Land" + ses = "Single-Engine Sea" + mel = "Multi-Engine Land" + mes = "Multi-Engine Sea" + + # Rotorcraft + helicopter = "Helicopter" + gyroplane = "Gyroplane" + + # Powered Lift + powered_lift = "Powered Lift" + + # Glider + glider = "Glider" + + # Lighther-than-air + airship = "Airship" + balloon = "Balloon" + + # Powered Parachute + ppl = "Powered Parachute Land" + pps = "Powered Parachute Sea" + + # Weight-Shift + wsl = "Weight-Shift Control Land" + wss = "Weight-Shift Control Sea" + + +PositiveFloat = Annotated[float, Field(default=0., ge=0)] + + +class AircraftCreateSchema(BaseModel): + tail_no: str + make: str + model: str + aircraft_category: AircraftCategory + aircraft_class: AircraftClass + + hobbs: PositiveFloat + tach: PositiveFloat + + @field_validator('aircraft_class') + def validate_class(cls, v: str, info: ValidationInfo, **kwargs): + """ + Dependent field validator for aircraft class. Ensures class corresponds to the correct category + + :param v: Value of aircraft_class + :param values: Other values in schema + :param kwargs: + :return: v + """ + if 'aircraft_category' in info.data.keys(): + category = info.data['aircraft_category'] + if category == AircraftCategory.airplane and v not in [AircraftClass.sel, AircraftClass.mel, + AircraftClass.ses, AircraftClass.mes]: + raise ValueError("Class must be SEL, MEL, SES, or MES for Airplane category") + elif category == AircraftCategory.rotorcraft and v not in [AircraftClass.helicopter, + AircraftClass.gyroplane]: + raise ValueError("Class must be Helicopter or Gyroplane for Rotorcraft category") + elif category == AircraftCategory.powered_lift and not v == AircraftClass.powered_lift: + raise ValueError("Class must be Powered Lift for Powered Lift category") + elif category == AircraftCategory.glider and not v == AircraftClass.glider: + raise ValueError("Class must be Glider for Glider category") + elif category == AircraftCategory.lighter_than_air and v not in [ + AircraftClass.airship, AircraftClass.balloon]: + raise ValueError("Class must be Airship or Balloon for Lighter-Than-Air category") + elif category == AircraftCategory.ppg and v not in [AircraftClass.ppl, + AircraftClass.pps]: + raise ValueError("Class must be Powered Parachute Land or " + "Powered Parachute Sea for Powered Parachute category") + elif category == AircraftCategory.weight_shift and v not in [AircraftClass.wsl, + AircraftClass.wss]: + raise ValueError("Class must be Weight-Shift Control Land or Weight-Shift " + "Control Sea for Weight-Shift Control category") + return v + + +class AircraftDisplaySchema(AircraftCreateSchema): + user: PyObjectId + id: PyObjectId