Implement image uploads and flight patching

This commit is contained in:
april 2024-01-12 15:48:36 -06:00
parent 7a0ea052f1
commit 5ab412d82a
7 changed files with 316 additions and 16 deletions

View File

@ -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, aircraft
from routes import users, flights, auth, aircraft, img
logger = logging.getLogger("api")
@ -32,4 +32,5 @@ app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True,
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(img.router, tags=["Images"], prefix="/img")
app.include_router(auth.router, tags=["Auth"], prefix="/auth")

View File

@ -25,4 +25,5 @@ except Exception as e:
user_collection = db_client["user"]
flight_collection = db_client["flight"]
aircraft_collection = db_client["aircraft"]
files_collection = db_client.fs.files
token_collection = db_client["token_blacklist"]

View File

@ -1,20 +1,24 @@
import logging
from datetime import datetime
from typing import Dict, Union
from typing import Dict, Union, Any, get_args, List, get_origin, _type_check, get_type_hints
from bson import ObjectId
from bson.errors import InvalidId
from fastapi import HTTPException
from pydantic import parse_obj_as, TypeAdapter, ValidationError, create_model
from schemas.aircraft import AircraftCreateSchema, aircraft_add_helper, AircraftCategory, AircraftClass, \
aircraft_class_dict, aircraft_category_dict
from .aircraft import retrieve_aircraft_by_tail, update_aircraft, update_aircraft_field, retrieve_aircraft
from .db import flight_collection, aircraft_collection
from schemas.flight import FlightConciseSchema, FlightDisplaySchema, FlightCreateSchema, flight_display_helper, \
flight_add_helper
flight_add_helper, FlightPatchSchema
logger = logging.getLogger("api")
fs_keys = list(FlightCreateSchema.__annotations__.keys())
fs_keys.extend(list(FlightDisplaySchema.__annotations__.keys()))
async def retrieve_flights(user: str = "", sort: str = "date", order: int = -1, filter: str = "",
filter_val: str = "") -> list[FlightConciseSchema]:
@ -32,8 +36,6 @@ async def retrieve_flights(user: str = "", sort: str = "date", order: int = -1,
if user != "":
filter_options["user"] = ObjectId(user)
if filter != "" and filter_val != "":
fs_keys = list(FlightCreateSchema.__annotations__.keys())
fs_keys.extend(list(FlightDisplaySchema.__annotations__.keys()))
if filter not in fs_keys:
raise HTTPException(400, f"Invalid filter field: {filter}")
filter_options[filter] = filter_val
@ -214,6 +216,44 @@ async def update_flight(body: FlightCreateSchema, id: str) -> str:
return id
async def update_flight_fields(id: str, update: dict) -> str:
"""
Update a single field of the given flight in the database
:param id: ID of flight to update
:param update: Dictionary of fields and values to update
:return: ID of updated flight
"""
for field in update.keys():
if field not in fs_keys:
raise HTTPException(400, f"Invalid update field: {field}")
flight = await flight_collection.find_one({"_id": ObjectId(id)})
if flight is None:
raise HTTPException(404, "Flight not found")
try:
parsed_update = FlightPatchSchema.model_validate(update)
except ValidationError as e:
raise HTTPException(422, e.errors())
update_dict = {field: value for field, value in parsed_update.model_dump().items() if field in update.keys()}
if "aircraft" in update_dict.keys():
aircraft = await retrieve_aircraft_by_tail(update_dict["aircraft"])
if aircraft is None:
raise HTTPException(404, "Aircraft not found")
updated_flight = await flight_collection.update_one({"_id": ObjectId(id)}, {"$set": update_dict})
if updated_flight is None:
raise HTTPException(500, "Failed to update flight")
return id
async def delete_flight(id: str) -> FlightDisplaySchema:
"""
Delete the given flight from the database

82
api/database/img.py Normal file
View File

@ -0,0 +1,82 @@
import io
from gridfs import NoFile
from .db import db_client as db, files_collection
import motor.motor_asyncio
from bson import ObjectId
from fastapi import UploadFile, File, HTTPException
fs = motor.motor_asyncio.AsyncIOMotorGridFSBucket(db)
async def upload_image(image: UploadFile = File(...), user: str = "") -> dict:
"""
Take an image file and add it to the database, returning the filename and ID of the added image
:param image: Image to upload
:param user: ID of user uploading image to encode in image metadata
:return: Dictionary with filename and file_id of newly added image
"""
image_data = await image.read()
metadata = {"user": user}
file_id = await fs.upload_from_stream(image.filename, io.BytesIO(image_data), metadata=metadata)
return {"filename": image.filename, "file_id": str(file_id)}
async def retrieve_image_metadata(image_id: str = "") -> dict:
"""
Retrieve the metadata of a given image
:param image_id: ID of image to retrieve metadata of
:return: Image metadata
"""
info = await files_collection.find_one({"_id": ObjectId(image_id)})
if info is None:
raise HTTPException(404, "Image not found")
return info["metadata"]
async def retrieve_image(image_id: str = "") -> tuple[io.BytesIO, str]:
"""
Retrieve the given image file from the database along with the user who created it
:param image_id: ID of image to retrieve
:return: BytesIO stream of image file, ID of user that uploaded the image
"""
metadata = await retrieve_image_metadata(image_id)
print(metadata)
stream = io.BytesIO()
try:
await fs.download_to_stream(ObjectId(image_id), stream)
except NoFile:
raise HTTPException(404, "Image not found")
stream.seek(0)
return stream, metadata["user"] if metadata["user"] else ""
async def delete_image(image_id: str = ""):
"""
Delete the given image from the database
:param image_id: ID of image to delete
:return: True if deleted
"""
try:
await fs.delete(ObjectId(image_id))
except NoFile:
raise HTTPException(404, "Image not found")
except Exception as e:
raise HTTPException(500, e)
return True

View File

@ -1,12 +1,16 @@
import logging
from datetime import datetime
from typing import Any
from fastapi import APIRouter, HTTPException, Depends
from fastapi import APIRouter, HTTPException, Depends, Form, UploadFile, File
from app.deps import get_current_user, admin_required
from database import flights as db
from database.flights import update_flight_fields
from database.img import upload_image
from schemas.flight import FlightConciseSchema, FlightDisplaySchema, FlightCreateSchema, FlightByDateSchema
from schemas.flight import FlightConciseSchema, FlightDisplaySchema, FlightCreateSchema, FlightByDateSchema, \
FlightSchema
from schemas.user import UserDisplaySchema, AuthLevel
router = APIRouter()
@ -109,20 +113,49 @@ async def get_flight(flight_id: str, user: UserDisplaySchema = Depends(get_curre
@router.post('/', summary="Add a flight logbook entry", status_code=200)
async def add_flight(flight_body: FlightCreateSchema, user: UserDisplaySchema = Depends(get_current_user)) -> dict:
async def add_flight(flight_body: FlightSchema, user: UserDisplaySchema = Depends(get_current_user)) -> dict:
"""
Add a flight logbook entry
:param flight_body: Information associated with new flight
:param images: Images associated with the new flight log
:param user: Currently logged-in user
:return: Error message if request invalid, else ID of newly created log
:return: ID of newly created log
"""
flight = await db.insert_flight(flight_body, user.id)
flight_create = FlightCreateSchema(**flight_body.model_dump(), images=[])
flight = await db.insert_flight(flight_create, user.id)
return {"id": str(flight)}
@router.post('/{flight_id}/add_images', summary="Add images to a flight log")
async def add_images(log_id: str, images: list[UploadFile] = File(...),
user: UserDisplaySchema = Depends(get_current_user)):
"""
Add images to a flight logbook entry
:param log_id: ID of flight log to add images to
:param images: Images to add
:param user: Currently logged-in user
:return: ID of updated flight
"""
flight = await db.retrieve_flight(log_id)
if not str(flight.user) == user.id and not user.level == AuthLevel.ADMIN:
raise HTTPException(403, "Unauthorized access")
image_ids = flight.images
if images:
for image in images:
image_response = await upload_image(image, user.id)
image_ids.append(image_response["file_id"])
return await update_flight_fields(log_id, dict(images=image_ids))
@router.put('/{flight_id}', summary="Update the given flight with new information", status_code=200)
async def update_flight(flight_id: str, flight_body: FlightCreateSchema,
user: UserDisplaySchema = Depends(get_current_user)) -> dict:
@ -132,7 +165,7 @@ async def update_flight(flight_id: str, flight_body: FlightCreateSchema,
:param flight_id: ID of flight to update
:param flight_body: New flight information to update with
:param user: Currently logged-in user
:return: Updated flight
:return: ID of updated flight
"""
flight = await get_flight(flight_id, user)
if flight is None:
@ -147,6 +180,29 @@ async def update_flight(flight_id: str, flight_body: FlightCreateSchema,
return {"id": str(updated_flight_id)}
@router.patch('/{flight_id}', summary="Update a single field of the given flight with new information", status_code=200)
async def patch_flight(flight_id: str, update: dict,
user: UserDisplaySchema = Depends(get_current_user)) -> dict:
"""
Update a single field of the given flight
:param flight_id: ID of flight to update
:param update: Dictionary of fields and values to update
:param user: Currently logged-in user
:return: ID of updated flight
"""
flight = await get_flight(flight_id, user)
if flight is None:
raise HTTPException(404, "Flight not found")
if str(flight.user) != user.id and AuthLevel(user.level) != AuthLevel.ADMIN:
logger.info("Attempted access to unauthorized flight by %s", user.username)
raise HTTPException(403, "Unauthorized access")
updated_flight_id = await db.update_flight_fields(flight_id, update)
return {"id": str(updated_flight_id)}
@router.delete('/{flight_id}', summary="Delete the given flight", status_code=200, response_model=FlightDisplaySchema)
async def delete_flight(flight_id: str, user: UserDisplaySchema = Depends(get_current_user)) -> FlightDisplaySchema:
"""

69
api/routes/img.py Normal file
View File

@ -0,0 +1,69 @@
import logging
import mimetypes
import os
from fastapi import APIRouter, UploadFile, File, Path, Depends, HTTPException
from starlette.responses import StreamingResponse
from app.deps import get_current_user
from database import img
from schemas.user import UserDisplaySchema, AuthLevel
router = APIRouter()
logger = logging.getLogger("img")
@router.get("/{image_id}", description="Retrieve an image from the database")
async def get_image(user: UserDisplaySchema = Depends(get_current_user),
image_id: str = Path(..., description="ID of image to retrieve")) -> StreamingResponse:
"""
Retrieve an image from the database
:param user: Current user
:param image_id: ID of image to retrieve
:return: Stream associated with requested image
"""
stream, user_created = await img.retrieve_image(image_id)
if not user.id == user_created and not user.level == AuthLevel.ADMIN:
raise HTTPException(403, "Access denied")
file_extension = os.path.splitext(image_id)[1]
media_type = mimetypes.types_map.get(file_extension)
return StreamingResponse(stream, media_type=media_type)
@router.post("/upload", description="Upload an image to the database")
async def upload_image(user: UserDisplaySchema = Depends(get_current_user),
image: UploadFile = File(..., description="Image file to upload")) -> dict:
"""
Upload the given image to the database
:param user: Current user
:param image: Image to upload
:return: Image filename and id
"""
return await img.upload_image(image, str(user.id))
@router.delete("/{image_id}", description="Delete the given image from the database")
async def delete_image(user: UserDisplaySchema = Depends(get_current_user),
image_id: str = Path(..., description="ID of image to delete")):
"""
Delete the given image from the database
:param user: Current user
:param image_id: ID of image to delete
:return:
"""
metadata = await img.retrieve_image_metadata(image_id)
if not user.id == metadata["user"] and not user.level == AuthLevel.ADMIN:
raise HTTPException(403, "Access denied")
if metadata is None:
raise HTTPException(404, "Image not found")
return await img.delete_image(image_id)

View File

@ -1,13 +1,15 @@
import datetime
import typing
from typing import Optional, Dict, Union, List
from bson import ObjectId
from fastapi import UploadFile, File
from pydantic import BaseModel
from schemas.utils import PositiveFloatNullable, PositiveFloat, PositiveInt, PyObjectId
class FlightCreateSchema(BaseModel):
class FlightSchema(BaseModel):
date: datetime.datetime
aircraft: str
waypoint_from: Optional[str] = None
@ -43,10 +45,60 @@ class FlightCreateSchema(BaseModel):
time_sim: PositiveFloat
time_ground: PositiveFloat
tags: list[str] = []
tags: List[str] = []
pax: list[str] = []
crew: list[str] = []
pax: List[str] = []
crew: List[str] = []
comments: Optional[str] = None
class FlightCreateSchema(FlightSchema):
images: List[str] = []
class FlightPatchSchema(BaseModel):
date: Optional[datetime.datetime] = None
aircraft: Optional[str] = None
waypoint_from: Optional[str] = None
waypoint_to: Optional[str] = None
route: Optional[str] = None
hobbs_start: Optional[PositiveFloatNullable] = None
hobbs_end: Optional[PositiveFloatNullable] = None
time_start: Optional[datetime.datetime] = None
time_off: Optional[datetime.datetime] = None
time_down: Optional[datetime.datetime] = None
time_stop: Optional[datetime.datetime] = None
time_total: Optional[PositiveFloat] = None
time_pic: Optional[PositiveFloat] = None
time_sic: Optional[PositiveFloat] = None
time_night: Optional[PositiveFloat] = None
time_solo: Optional[PositiveFloat] = None
time_xc: Optional[PositiveFloat] = None
dist_xc: Optional[PositiveFloat] = None
landings_day: Optional[PositiveInt] = None
landings_night: Optional[PositiveInt] = None
time_instrument: Optional[PositiveFloat] = None
time_sim_instrument: Optional[PositiveFloat] = None
holds_instrument: Optional[PositiveInt] = None
dual_given: Optional[PositiveFloat] = None
dual_recvd: Optional[PositiveFloat] = None
time_sim: Optional[PositiveFloat] = None
time_ground: Optional[PositiveFloat] = None
tags: Optional[List[str]] = None
pax: Optional[List[str]] = None
crew: Optional[List[str]] = None
images: Optional[List[str]] = None
comments: Optional[str] = None
@ -76,7 +128,6 @@ FlightByDateSchema = Dict[int, Union[Dict[int, 'FlightByDateSchema'], FlightConc
# HELPERS #
def flight_display_helper(flight: dict) -> dict:
"""
Convert given db response to a format usable by FlightDisplaySchema