add api from standalone repo

This commit is contained in:
azpsen 2024-01-30 12:20:49 -06:00
commit da3eb98c48
32 changed files with 2387 additions and 0 deletions

19
api/.env Normal file
View File

@ -0,0 +1,19 @@
DB_URI=localhost
DB_PORT=27017
DB_NAME=tailfin
DB_USER="tailfin-api"
DB_PWD="tailfin-api-password"
# 60 * 24 * 7 -> 7 days
REFRESH_TOKEN_EXPIRE_MINUTES=10080
ACCESS_TOKEN_EXPIRE_MINUTES=30
JWT_ALGORITHM="HS256"
JWT_SECRET_KEY="please-change-me"
JWT_REFRESH_SECRET_KEY="change-me-i-beg-of-you"
TAILFIN_ADMIN_USERNAME="admin"
TAILFIN_ADMIN_PASSWORD="change-me-now"
TAILFIN_PORT=8081

21
api/LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 April Petersen
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

125
api/README.md Normal file
View File

@ -0,0 +1,125 @@
<p align="center">
<a href="" rel="nooperner">
<img width=200px height=200px src="logo.png" alt="Tailfin Logo"></a>
</p>
<h1 align="center">Tailfin</h1>
<h3 align="center">A self-hosted digital flight logbook</h3>
<p align="center">
<a href="LICENSE"><img src="https://img.shields.io/github/license/azpsen/tailfin-web?style=for-the-badge" /></a>
<a href="https://python.org/"><img src="https://img.shields.io/badge/python-3670A0?style=for-the-badge&logo=python&logoColor=ffdd54" alt="Python" /></a>
<a href="https://www.mongodb.com/"><img src="https://img.shields.io/badge/MongoDB-%234ea94b.svg?style=for-the-badge&logo=mongodb&logoColor=white" alt="MongoDB" /></a>
<a href="https://fastapi.tiangolo.com/"><img src="https://img.shields.io/badge/FastAPI-005571?style=for-the-badge&logo=fastapi" alt="FastAPI" /></a>
</p>
## Table of Contents
+ [About](#about)
+ [Getting Started](#getting_started)
+ [Configuration](#configuration)
+ [Usage](#usage)
+ [Roadmap](#roadmap)
## About <a name="about"></a>
Tailfin is a digital flight logbook designed to be hosted on a personal server, computer, or cloud solution. This is the
API segment and can be run independently. It is meant to be a base for future applications, both web and mobile.
I created this because I was disappointed with the options available for digital logbooks. The one provided by
ForeFlight is likely most commonly used, but my proclivity towards self-hosting drove me to seek out another solution.
Since I could not find any ready-made self-hosted logbooks, I decided to make my own.
## Getting Started <a name="getting_started"></a>
### Prerequisites
- python 3.11+
- mongodb 7.0.4
### Installation
1. Clone the repo
```
$ git clone https://git.github.com/azpsen/tailfin-api.git
$ cd tailfin-api
```
2. (Optional) Create and activate virtual environment
```
$ python -m venv tailfin-env
$ source tailfin-env/bin/activate
```
3. Install python requirements
```
$ pip install -r requirements.txt
```
4. Configure the database connection
The default configuration assumes a running instance of MongoDB on `localhost:27017`, secured with username and
password `tailfin-api` and `tailfin-api-password`. This can (and should!) be changed by
modifying `.env`, as detailed in [Configuration](#configuration). Note that the MongoDB instance must be set up with
proper authentication before starting the server. I hope to eventually release a docker image that will simplify this
whole process.
5. Start the server
```
$ python app.py
```
## Configuration <a name="configuration"></a>
To configure Tailfin, modify the `.env` file. Some of these options should be changed before running the server. All
available options are detailed below:
`DB_URI`: Address of MongoDB instance. Default: `localhost`
<br />
`DB_PORT`: Port of MongoDB instance. Default: `27017`
<br />
`DB_NAME`: Name of the database to be used by Tailfin. Default: `tailfin`
`DB_USER`: Username for MongoDB authentication. Default: `tailfin-api`
<br />
`DB_PWD`: Password for MongoDB authentication. Default: `tailfin-api-password`
`REFRESH_TOKEN_EXPIRE_MINUTES`: Duration in minutes to keep refresh token active before invalidating it. Default:
`10080` (7 days)
<br />
`ACCESS_TOKEN_EXPIRE_MINUTES`: Duration in minutes to keep access token active before invalidating it. Default: `30`
`JWT_ALGORITHM`: Encryption algorithm to use for access and refresh tokens. Default: `HS256`
<br />
`JWT_SECRET_KEY`: Secret key used to encrypt and decrypt access tokens. Default: `please-change-me`
<br />
`JWT_REFRESH_SECRET_KEY`: Secret key used to encrypt and decrypt refresh tokens. Default: `change-me-i-beg-of-you`
`TAILFIN_ADMIN_USERNAME`: Username of the default admin user that is created on startup if no admin users exist.
Default: `admin`
<br />
`TAILFIN_ADMIN_PASSWORD`: Password of the default admin user that is created on startup if no admin users exist.
Default: `change-me-now`
`TAILFIN_PORT`: Port to run the local Tailfin API server on. Default: `8081`
## Usage <a name="usage"></a>
Once the server is running, full API documentation is available at `localhost:8081/docs`
## Roadmap <a name="roadmap"></a>
- [x] Multi-user authentication
- [x] Basic flight logging CRUD endpoints
- [x] Aircraft management and association with flight logs
- [x] Attach photos to log entries
- [ ] GPS track recording
- [ ] Implement JWT refresh tokens
- [ ] PDF Export
- [ ] Import from other log applications
- [ ] Integrate database of airports and waypoints that can be queried to find nearest

8
api/app.py Normal file
View File

@ -0,0 +1,8 @@
import uvicorn
from app.config import get_settings
if __name__ == '__main__':
settings = get_settings()
# Start the app
uvicorn.run("app.api:app", host=settings.tailfin_url, port=settings.tailfin_port, reload=True)

0
api/app/__init__.py Normal file
View File

36
api/app/api.py Normal file
View File

@ -0,0 +1,36 @@
import logging
import sys
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from database.utils import create_admin_user
from routes import users, flights, auth, aircraft, img
logger = logging.getLogger("api")
logging.basicConfig(format='%(asctime)s - %(levelname)s: %(message)s', level=logging.DEBUG)
handler = logging.StreamHandler(sys.stdout)
logger.addHandler(handler)
@asynccontextmanager
async def lifespan(app: FastAPI):
await create_admin_user()
yield
# Initialize FastAPI
app = FastAPI(lifespan=lifespan)
# Allow CORS
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"],
allow_headers=["*"])
# 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(img.router, tags=["Images"], prefix="/img")
app.include_router(auth.router, tags=["Auth"], prefix="/auth")

32
api/app/config.py Normal file
View File

@ -0,0 +1,32 @@
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
db_uri: str = "localhost"
db_port: int = 27017
db_name: str = "tailfin"
db_user: str
db_pwd: str
access_token_expire_minutes: int = 30
refresh_token_expire_minutes: int = 60 * 24 * 7
jwt_algorithm: str = "HS256"
jwt_secret_key: str = "please-change-me"
jwt_refresh_secret_key: str = "change-me-i-beg-of-you"
tailfin_admin_username: str = "admin"
tailfin_admin_password: str = "change-me-now"
tailfin_url: str = "0.0.0.0"
tailfin_port: int = 8081
@lru_cache
def get_settings():
return Settings()

71
api/app/deps.py Normal file
View File

@ -0,0 +1,71 @@
from datetime import datetime
from typing import Annotated
from fastapi import Depends, HTTPException
from fastapi.security import OAuth2PasswordBearer
from jose import jwt
from pydantic import ValidationError
from app.config import get_settings, Settings
from database.tokens import is_blacklisted
from database.users import get_user_system_info, get_user_system_info_id
from schemas.user import TokenPayload, AuthLevel, UserDisplaySchema, TokenSchema
reusable_oath = OAuth2PasswordBearer(
tokenUrl="/auth/login",
scheme_name="JWT"
)
async def get_current_user(settings: Annotated[Settings, Depends(get_settings)],
token: str = Depends(reusable_oath)) -> UserDisplaySchema:
try:
payload = jwt.decode(
token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm]
)
token_data = TokenPayload(**payload)
if datetime.fromtimestamp(token_data.exp) < datetime.now():
raise HTTPException(401, "Token expired", {"WWW-Authenticate": "Bearer"})
except (jwt.JWTError, ValidationError):
raise HTTPException(401, "Could not validate credentials", {"WWW-Authenticate": "Bearer"})
blacklisted = await is_blacklisted(token)
if blacklisted:
raise HTTPException(401, "Token expired", {"WWW-Authenticate": "Bearer"})
user = await get_user_system_info_id(id=token_data.sub)
if user is None:
raise HTTPException(404, "Could not find user")
return user
async def get_current_user_token(settings: Annotated[Settings, Depends(get_settings)],
token: str = Depends(reusable_oath)) -> (UserDisplaySchema, TokenSchema):
try:
payload = jwt.decode(
token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm]
)
token_data = TokenPayload(**payload)
if datetime.fromtimestamp(token_data.exp) < datetime.now():
raise HTTPException(401, "Token expired", {"WWW-Authenticate": "Bearer"})
except (jwt.JWTError, ValidationError):
raise HTTPException(401, "Could not validate credentials", {"WWW-Authenticate": "Bearer"})
blacklisted = await is_blacklisted(token)
if blacklisted:
raise HTTPException(401, "Token expired", {"WWW-Authenticate": "Bearer"})
user = await get_user_system_info_id(id=token_data.sub)
if user is None:
raise HTTPException(404, "Could not find user")
return user, token
async def admin_required(user: Annotated[UserDisplaySchema, Depends(get_current_user)]):
if user.level < AuthLevel.ADMIN:
raise HTTPException(403, "Access unauthorized")

0
api/database/__init__.py Normal file
View File

136
api/database/aircraft.py Normal file
View File

@ -0,0 +1,136 @@
from typing import Any
from fastapi import HTTPException
from pymongo.errors import WriteError
from database.db import aircraft_collection
from utils import to_objectid
from schemas.aircraft import AircraftDisplaySchema, AircraftCreateSchema, aircraft_display_helper, aircraft_add_helper
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": to_objectid(user)}):
aircraft.append(AircraftDisplaySchema(**aircraft_display_helper(doc)))
return aircraft
async def retrieve_aircraft_by_tail(tail_no: str) -> AircraftDisplaySchema:
"""
Retrieve details about the requested aircraft
:param tail_no: Tail number of desired aircraft
:return: Aircraft details
"""
aircraft = await aircraft_collection.find_one({"tail_no": tail_no})
if aircraft is None:
raise HTTPException(404, "Aircraft not found")
return AircraftDisplaySchema(**aircraft_display_helper(aircraft))
async def retrieve_aircraft_by_id(id: str) -> AircraftDisplaySchema:
"""
Retrieve details about the requested aircraft
:param tail_no: Tail number of desired aircraft
:return: Aircraft details
"""
aircraft = await aircraft_collection.find_one({"_id": to_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) -> to_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, user: str) -> AircraftDisplaySchema:
"""
Update given aircraft in the database
:param body: Updated aircraft data
:param id: ID of aircraft to update
:param user: ID of updating user
:return: Updated aircraft
"""
aircraft = await aircraft_collection.find_one({"_id": to_objectid(id)})
if aircraft is None:
raise HTTPException(404, "Aircraft not found")
updated_aircraft = await aircraft_collection.update_one({"_id": to_objectid(id)},
{"$set": aircraft_add_helper(body.model_dump(), user)})
if updated_aircraft is None:
raise HTTPException(500, "Failed to update aircraft")
aircraft = await aircraft_collection.find_one({"_id": to_objectid(id)})
if aircraft is None:
raise HTTPException(500, "Failed to fetch updated aircraft")
return AircraftDisplaySchema(**aircraft_display_helper(aircraft))
async def update_aircraft_field(field: str, value: Any, id: str) -> AircraftDisplaySchema:
"""
Update a single field of the given aircraft in the database
:param field: Field to update
:param value: Value to set field to
:param id: ID of aircraft to update
:return: Updated aircraft
"""
aircraft = await aircraft_collection.find_one({"_id": to_objectid(id)})
if aircraft is None:
raise HTTPException(404, "Aircraft not found")
try:
updated_aircraft = await aircraft_collection.update_one({"_id": to_objectid(id)}, {"$set": {field: value}})
except WriteError as e:
raise HTTPException(400, e.details)
if updated_aircraft is None:
raise HTTPException(500, "Failed to update flight")
return AircraftDisplaySchema(**aircraft_display_helper(aircraft))
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": to_objectid(id)})
if aircraft is None:
raise HTTPException(404, "Aircraft not found")
await aircraft_collection.delete_one({"_id": to_objectid(id)})
return AircraftDisplaySchema(**aircraft_display_helper(aircraft))

29
api/database/db.py Normal file
View File

@ -0,0 +1,29 @@
import logging
import motor.motor_asyncio
from app.config import get_settings, Settings
logger = logging.getLogger("api")
settings: Settings = get_settings()
# Connect to MongoDB instance
mongo_str = f"mongodb://{settings.db_user}:{settings.db_pwd}@{settings.db_uri}:{settings.db_port}?authSource={settings.db_name}"
client = motor.motor_asyncio.AsyncIOMotorClient(mongo_str)
db_client = client[settings.db_name]
# Test db connection
try:
client.admin.command("ping")
logger.info("Pinged MongoDB deployment. Successfully connected to MongoDB.")
except Exception as e:
logger.error(e)
# Get db collections
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"]

273
api/database/flights.py Normal file
View File

@ -0,0 +1,273 @@
import logging
from datetime import datetime
from typing import Dict, Union
from bson import ObjectId
from utils import to_objectid
from fastapi import HTTPException
from pydantic import ValidationError
from schemas.aircraft import aircraft_class_dict, aircraft_category_dict
from .aircraft import retrieve_aircraft_by_tail, update_aircraft_field
from .db import flight_collection, aircraft_collection
from schemas.flight import FlightConciseSchema, FlightDisplaySchema, FlightCreateSchema, flight_display_helper, \
flight_add_helper, FlightPatchSchema, fs_keys
from .img import delete_image
logger = logging.getLogger("api")
async def retrieve_flights(user: str = "", sort: str = "date", order: int = -1, filter: str = "",
filter_val: str = "") -> list[FlightConciseSchema]:
"""
Retrieve a list of flights, optionally filtered by user
:param user: User to filter flights by
:param sort: Parameter to sort results by
:param order: Sort order
:param filter: Field to filter flights by
:param filter_val: Value to filter field by
:return: List of flights
"""
filter_options = {}
if user != "":
filter_options["user"] = to_objectid(user)
if filter != "" and filter_val != "":
if filter not in fs_keys:
raise HTTPException(400, f"Invalid filter field: {filter}")
filter_options[filter] = filter_val
flights = []
async for flight in flight_collection.find(filter_options).sort({sort: order}):
flights.append(FlightConciseSchema(**flight_display_helper(flight)))
return flights
async def retrieve_totals(user: str, start_date: datetime = None, end_date: datetime = None) -> dict:
"""
Retrieve total times for the given user
:param user:
:return:
"""
match: Dict[str, Union[Dict, ObjectId]] = {"user": to_objectid(user)}
if start_date is not None:
match.setdefault("date", {}).setdefault("$gte", start_date)
if end_date is not None:
match.setdefault("date", {}).setdefault("$lte", end_date)
by_class_pipeline = [
{"$match": {"user": to_objectid(user)}},
{"$lookup": {
"from": "flight",
"let": {"aircraft": "$tail_no"},
"pipeline": [
{"$match": {
"$expr": {
"$eq": ["$$aircraft", "$aircraft"]
}
}}
],
"as": "flight_data"
}},
{"$unwind": "$flight_data"},
{"$group": {
"_id": {
"aircraft_category": "$aircraft_category",
"aircraft_class": "$aircraft_class"
},
"time_total": {
"$sum": "$flight_data.time_total"
},
}},
{"$group": {
"_id": "$_id.aircraft_category",
"classes": {
"$push": {
"aircraft_class": "$_id.aircraft_class",
"time_total": "$time_total",
}
},
}},
{"$project": {
"_id": 0,
"aircraft_category": "$_id",
"classes": 1,
}},
]
class_cursor = aircraft_collection.aggregate(by_class_pipeline)
by_class_list = await class_cursor.to_list(None)
totals_pipeline = [
{"$match": {"user": to_objectid(user)}},
{"$group": {
"_id": None,
"time_total": {"$sum": "$time_total"},
"time_solo": {"$sum": "$time_solo"},
"time_night": {"$sum": "$time_night"},
"time_pic": {"$sum": "$time_pic"},
"time_sic": {"$sum": "$time_sic"},
"time_instrument": {"$sum": "$time_instrument"},
"time_sim": {"$sum": "$time_sim"},
"time_xc": {"$sum": "$time_xc"},
"landings_day": {"$sum": "$landings_day"},
"landings_night": {"$sum": "$landings_night"},
"xc_dual_recvd": {"$sum": {"$min": ["$time_xc", "$dual_recvd"]}},
"xc_solo": {"$sum": {"$min": ["$time_xc", "$time_solo"]}},
"xc_pic": {"$sum": {"$min": ["$time_xc", "$time_pic"]}},
"night_dual_recvd": {"$sum": {"$min": ["$time_night", "$dual_recvd"]}},
"night_pic": {"$sum": {"$min": ["$time_night", "$time_pic"]}}
}},
{"$project": {"_id": 0}},
]
totals_cursor = flight_collection.aggregate(totals_pipeline)
totals_list = await totals_cursor.to_list(None)
if not totals_list and not by_class_list:
return {}
totals_dict = dict(totals_list[0])
for entry in by_class_list:
entry["aircraft_category"] = aircraft_category_dict[entry["aircraft_category"]]
for cls in entry["classes"]:
cls["aircraft_class"] = aircraft_class_dict[cls["aircraft_class"]]
result = {
"by_class": by_class_list,
"totals": totals_dict
}
return result
async def retrieve_flight(id: str) -> FlightDisplaySchema:
"""
Get detailed information about the given flight
:param id: ID of flight to retrieve
:return: Flight information
"""
flight = await flight_collection.find_one({"_id": to_objectid(id)})
if flight is None:
raise HTTPException(404, "Flight not found")
return FlightDisplaySchema(**flight_display_helper(flight))
async def insert_flight(body: FlightCreateSchema, id: str) -> ObjectId:
"""
Insert a new flight into the database
:param body: Flight data
:param id: ID of creating user
:return: ID of inserted flight
"""
aircraft = await retrieve_aircraft_by_tail(body.aircraft)
if aircraft is None:
raise HTTPException(404, "Aircraft not found")
# Update hobbs of aircraft to reflect new hobbs end
if body.hobbs_end and body.hobbs_end > 0 and body.hobbs_end != aircraft.hobbs:
await update_aircraft_field("hobbs", body.hobbs_end, aircraft.id)
# Insert flight into database
flight = await flight_collection.insert_one(flight_add_helper(body.model_dump(), id))
return flight.inserted_id
async def update_flight(body: FlightCreateSchema, id: str) -> str:
"""
Update given flight in the database
:param body: Updated flight data
:param id: ID of flight to update
:return: ID of updated flight
"""
flight = await flight_collection.find_one({"_id": to_objectid(id)})
if flight is None:
raise HTTPException(404, "Flight not found")
aircraft = await retrieve_aircraft_by_tail(body.aircraft)
if aircraft is None:
raise HTTPException(404, "Aircraft not found")
# Update hobbs of aircraft to reflect new hobbs end
if body.hobbs_end and body.hobbs_end and 0 < aircraft.hobbs != body.hobbs_end:
await update_aircraft_field("hobbs", body.hobbs_end, aircraft.id)
# Update flight in database
updated_flight = await flight_collection.update_one({"_id": to_objectid(id)}, {"$set": body.model_dump()})
if updated_flight is None:
raise HTTPException(500, "Failed to update flight")
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": to_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": to_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
:param id: ID of flight to delete
:return: Deleted flight information
"""
flight = await flight_collection.find_one({"_id": to_objectid(id)})
if flight is None:
raise HTTPException(404, "Flight not found")
# Delete associated images
if "images" in flight:
for image in flight["images"]:
await delete_image(image)
await flight_collection.delete_one({"_id": to_objectid(id)})
return FlightDisplaySchema(**flight_display_helper(flight))

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

@ -0,0 +1,85 @@
import io
import mimetypes
import os
from gridfs import NoFile
from .db import db_client as db, files_collection
import motor.motor_asyncio
from utils import to_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": to_objectid(image_id)})
if info is None:
raise HTTPException(404, "Image not found")
file_extension = os.path.splitext(info["filename"])[1]
media_type = "image/webp" if file_extension == ".webp" else mimetypes.types_map.get(file_extension)
return {**info["metadata"], 'contentType': media_type if media_type else ""}
async def retrieve_image(image_id: str = "") -> tuple[io.BytesIO, str, 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, file type
"""
metadata = await retrieve_image_metadata(image_id)
stream = io.BytesIO()
try:
await fs.download_to_stream(to_objectid(image_id), stream)
except NoFile:
raise HTTPException(404, "Image not found")
stream.seek(0)
return stream, metadata["user"] if metadata["user"] else "", metadata["contentType"]
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(to_objectid(image_id))
except NoFile:
raise HTTPException(404, "Image not found")
except Exception as e:
raise HTTPException(500, e)
return True

View File

@ -0,0 +1,68 @@
import csv
from datetime import datetime
from fastapi import UploadFile, HTTPException
from pydantic import ValidationError
from database.flights import insert_flight
from schemas.flight import flight_add_helper, FlightCreateSchema, fs_keys, fs_types
mfb_types = {
"Tail Number": "aircraft",
"Hold": "holds_instrument",
"Landings": "landings_day",
"FS Night Landings": "landings_night",
"X-Country": "time_xc",
"Night": "time_night",
"Simulated Instrument": "time_sim_instrument",
"Ground Simulator": "time_sim",
"Dual Received": "dual_recvd",
"SIC": "time_sic",
"PIC": "time_pic",
"Flying Time": "time_total",
"Hobbs Start": "hobbs_start",
"Hobbs End": "hobbs_end",
"Engine Start": "time_start",
"Engine End": "time_stop",
"Flight Start": "time_off",
"Flight End": "time_down",
"Comments": "comments",
}
async def import_from_csv_mfb(file: UploadFile, user: str):
content = await file.read()
decoded_content = content.decode("utf-8").splitlines()
decoded_content[0] = decoded_content[0].replace('\ufeff', '', 1)
reader = csv.DictReader(decoded_content)
flights = []
for row in reader:
entry = {}
for label, value in dict(row).items():
if len(value) and label in mfb_types:
entry[mfb_types[label]] = value
else:
if label == "Date":
entry["date"] = datetime.strptime(value, "%Y-%m-%d")
elif label == "Route":
r = str(value).split(" ")
l = len(r)
route = ""
start = ""
end = ""
if l == 1:
start = r[0]
elif l >= 2:
start = r[0]
end = r[-1]
route = " ".join(r[1:-1])
entry["route"] = route
entry["waypoint_from"] = start
entry["waypoint_to"] = end
flights.append(entry)
# print(flights)
for entry in flights:
# try:
await insert_flight(FlightCreateSchema(**entry), user)
# except ValidationError as e:
# raise HTTPException(400, e.json())

25
api/database/tokens.py Normal file
View File

@ -0,0 +1,25 @@
from .db import token_collection
async def is_blacklisted(token: str) -> bool:
"""
Check if a token is still valid or if it is blacklisted
:param token: Token to check
:return: True if token is blacklisted, else False
"""
db_token = await token_collection.find_one({"token": token})
if db_token:
return True
return False
async def blacklist_token(token: str) -> str:
"""
Add given token to the blacklist (invalidate it)
:param token: Token to invalidate
:return: Database ID of blacklisted token
"""
db_token = await token_collection.insert_one({"token": token})
return str(db_token.inserted_id)

140
api/database/users.py Normal file
View File

@ -0,0 +1,140 @@
import logging
from bson import ObjectId
from utils import to_objectid
from fastapi import HTTPException
from .db import user_collection, flight_collection
from routes.utils import get_hashed_password
from schemas.user import UserDisplaySchema, UserCreateSchema, UserSystemSchema, AuthLevel, user_helper, \
create_user_helper, system_user_helper
logger = logging.getLogger("api")
async def retrieve_users() -> list[UserDisplaySchema]:
"""
Retrieve a list of all users in the database
:return: List of users
"""
users = []
async for user in user_collection.find():
users.append(UserDisplaySchema(**user_helper(user)))
return users
async def add_user(user_data: UserCreateSchema) -> ObjectId:
"""
Add a user to the database
:param user_data: User data to insert into database
:return: ID of inserted user
"""
user = await user_collection.insert_one(create_user_helper(user_data.model_dump()))
return user.inserted_id
async def get_user_info_id(id: str) -> UserDisplaySchema:
"""
Get user information from given user ID
:param id: ID of user to retrieve
:return: User information
"""
user = await user_collection.find_one({"_id": to_objectid(id)})
if user:
return UserDisplaySchema(**user_helper(user))
async def get_user_info(username: str) -> UserDisplaySchema:
"""
Get user information from given username
:param username: Username of user to retrieve
:return: User information
"""
user = await user_collection.find_one({"username": username})
if user:
return UserDisplaySchema(**user_helper(user))
async def get_user_system_info_id(id: str) -> UserSystemSchema:
"""
Get user information and password hash from given ID
:param id: ID of user to retrieve
:return: User information and password
"""
user = await user_collection.find_one({"_id": to_objectid(id)})
if user:
return UserSystemSchema(**system_user_helper(user))
async def get_user_system_info(username: str) -> UserSystemSchema:
"""
Get user information and password hash from given username
:param username: Username of user to retrieve
:return: User information and password
"""
user = await user_collection.find_one({"username": username})
if user:
return UserSystemSchema(**system_user_helper(user))
async def delete_user(id: str) -> UserDisplaySchema:
"""
Delete given user and all associated flights from the database
:param id: ID of user to delete
:return: Information of deleted user
"""
user = await user_collection.find_one({"_id": to_objectid(id)})
if user is None:
raise HTTPException(404, "User not found")
await user_collection.delete_one({"_id": to_objectid(id)})
# Delete all flights associated with user
await flight_collection.delete_many({"user": to_objectid(id)})
return UserDisplaySchema(**user_helper(user))
async def edit_profile(user_id: str, username: str = None, password: str = None,
auth_level: AuthLevel = None) -> UserDisplaySchema:
"""
Update the profile of the given user
:param user_id: ID of user to update
:param username: New username
:param password: New password
:param auth_level: New authorization level
:return: Error message if user not found or access unauthorized, else 200
"""
user = await get_user_info_id(user_id)
if user is None:
raise HTTPException(404, "User not found")
if username:
existing_users = await user_collection.count_documents({"username": username})
if existing_users > 0:
raise HTTPException(400, "Username not available")
if auth_level:
if auth_level is not AuthLevel(user.level) and AuthLevel(user.level) < AuthLevel.ADMIN:
logger.info("Unauthorized attempt by %s to change auth level", user.username)
raise HTTPException(403, "Unauthorized attempt to change auth level")
if username:
user_collection.update_one({"_id": to_objectid(user_id)}, {"$set": {"username": username}})
if password:
hashed_password = get_hashed_password(password)
user_collection.update_one({"_id": to_objectid(user_id)}, {"$set": {"password": hashed_password}})
if auth_level:
user_collection.update_one({"_id": to_objectid(user_id)}, {"$set": {"level": auth_level}})
updated_user = await get_user_info_id(user_id)
return updated_user

39
api/database/utils.py Normal file
View File

@ -0,0 +1,39 @@
import logging
from app.config import get_settings
from .db import user_collection
from routes.utils import get_hashed_password
from schemas.user import AuthLevel, UserCreateSchema
from .users import add_user
logger = logging.getLogger("api")
# UTILS #
async def create_admin_user():
"""
Create default admin user if no admin users are present in the database
:return: None
"""
if await user_collection.count_documents({"level": AuthLevel.ADMIN.value}) == 0:
logger.info("No admin users exist. Creating default admin user...")
settings = get_settings()
admin_username = settings.tailfin_admin_username
logger.info("Setting admin username to 'TAILFIN_ADMIN_USERNAME': %s", admin_username)
admin_password = settings.tailfin_admin_password
logger.info("Setting admin password to 'TAILFIN_ADMIN_PASSWORD'")
hashed_password = get_hashed_password(admin_password)
user = await add_user(
UserCreateSchema(username=admin_username, password=hashed_password, level=AuthLevel.ADMIN.value))
if user is None:
raise Exception("Failed to create default admin user")
logger.info("Default admin user created with username %s", admin_username)

BIN
api/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

6
api/requirements.txt Normal file
View File

@ -0,0 +1,6 @@
uvicorn~=0.24.0.post1
fastapi~=0.105.0
pydantic~=2.5.2
passlib[bcrypt]~=1.7.4
motor~=3.3.2
python-jose[cryptography]~=3.3.0

0
api/routes/__init__.py Normal file
View File

166
api/routes/aircraft.py Normal file
View File

@ -0,0 +1,166 @@
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, category_class
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('/categories', summary="Get valid aircraft categories", status_code=200, response_model=dict)
async def get_categories() -> dict:
"""
Get a list of valid aircraft categories
:return: List of categories
"""
return {"categories": list(category_class.keys())}
@router.get('/class', summary="Get valid aircraft classes for the given class", status_code=200, response_model=dict)
async def get_categories(category: str = "Airplane") -> dict:
"""
Get a list of valid aircraft classes for the given class
:return: List of classes
"""
if category not in category_class.keys():
raise HTTPException(404, "Category not found")
return {"classes": category_class[category]}
@router.get('/id/{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.get('/tail/{tail_no}', summary="Get details of a given aircraft", response_model=AircraftDisplaySchema,
status_code=200)
async def get_aircraft_by_tail(tail_no: str,
user: UserDisplaySchema = Depends(get_current_user)) -> AircraftDisplaySchema:
"""
Get all details of a given aircraft
:param tail_no: Tail number of requested aircraft
:param user: Currently logged-in user
:return: Aircraft details
"""
aircraft = await db.retrieve_aircraft_by_tail(tail_no)
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
"""
try:
await db.retrieve_aircraft_by_tail(aircraft_body.tail_no)
except HTTPException:
aircraft = await db.insert_aircraft(aircraft_body, user.id)
return {"id": str(aircraft)}
raise HTTPException(400, "Aircraft with tail number " + aircraft_body.tail_no + " already exists", )
@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, user.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

64
api/routes/auth.py Normal file
View File

@ -0,0 +1,64 @@
import logging
from typing import Annotated
from fastapi import Depends, APIRouter, HTTPException
from fastapi.security import OAuth2PasswordRequestForm
from app.config import Settings, get_settings
from app.deps import get_current_user_token
from database import tokens, users
from schemas.user import TokenSchema, UserDisplaySchema
from routes.utils import verify_password, create_access_token, create_refresh_token
router = APIRouter()
logger = logging.getLogger("api")
@router.post('/login', summary="Create access and refresh tokens for user", status_code=200, response_model=TokenSchema)
async def login(form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
settings: Annotated[Settings, Depends(get_settings)]) -> TokenSchema:
"""
Log in as given user - create associated JWT for API access
:return: JWT for given user
"""
# Get requested user
user = await users.get_user_system_info(username=form_data.username)
if user is None:
raise HTTPException(401, "Invalid username or password")
# Verify given password
hashed_pass = user.password
if not verify_password(form_data.password, hashed_pass):
raise HTTPException(401, "Invalid username or password")
# Create access and refresh tokens
return TokenSchema(
access_token=create_access_token(settings, str(user.id)),
refresh_token=create_refresh_token(settings, str(user.id))
)
@router.post('/logout', summary="Invalidate current user's token", status_code=200)
async def logout(user_token: (UserDisplaySchema, TokenSchema) = Depends(get_current_user_token)) -> dict:
"""
Log out given user by adding JWT to a blacklist database
:return: Logout message
"""
user, token = user_token
# Blacklist token
blacklisted = await tokens.blacklist_token(token)
if not blacklisted:
logger.debug("Failed to add token to blacklist")
return {"msg": "Logout failed"}
return {"msg": "Logout successful"}
# @router.post('/refresh', summary="Refresh JWT token", status_code=200)
# async def refresh(form: OAuth2RefreshRequestForm = Depends()):
# if request.method == 'POST':
# form = await request.json()

238
api/routes/flights.py Normal file
View File

@ -0,0 +1,238 @@
import logging
from datetime import datetime
from typing import Any, List
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 database.import_flights import import_from_csv_mfb
from schemas.flight import FlightConciseSchema, FlightDisplaySchema, FlightCreateSchema, FlightByDateSchema, \
FlightSchema
from schemas.user import UserDisplaySchema, AuthLevel
router = APIRouter()
logger = logging.getLogger("flights")
@router.get('/', summary="Get flights logged by the currently logged-in user", status_code=200)
async def get_flights(user: UserDisplaySchema = Depends(get_current_user), sort: str = "date", order: int = -1,
filter: str = "", filter_val: str = "") -> list[
FlightConciseSchema]:
"""
Get a list of the flights logged by the currently logged-in user
:param user: Current user
:param sort: Attribute to sort results by
:param order: Order of sorting (asc/desc)
:param filter: Field to filter results by
:param filter_val: Value to filter field by
:return: List of flights
"""
flights = await db.retrieve_flights(user.id, sort, order, filter, filter_val)
return flights
@router.get('/by-date', summary="Get flights logged by the current user, categorized by date", status_code=200,
response_model=dict)
async def get_flights_by_date(user: UserDisplaySchema = Depends(get_current_user), sort: str = "date",
order: int = -1, filter: str = "", filter_val: str = "") -> dict:
"""
Get a list of the flights logged by the currently logged-in user, categorized by year, month, and day
:param user: Current user
:param sort: Attribute to sort results by
:param order: Order of sorting (asc/desc)
:param filter: Field to filter results by
:param filter_val: Value to filter field by
:return:
"""
flights = await db.retrieve_flights(user.id, sort, order, filter, filter_val)
flights_ordered: FlightByDateSchema = {}
for flight in flights:
date = flight.date
flights_ordered.setdefault(date.year, {}).setdefault(date.month, {}).setdefault(date.day, []).append(flight)
return flights_ordered
@router.get('/totals', summary="Get total statistics for the current user", status_code=200, response_model=dict)
async def get_flight_totals(user: UserDisplaySchema = Depends(get_current_user), start_date: str = "",
end_date: str = "") -> dict:
"""
Get the total statistics for the currently logged-in user
:param user: Current user
:param start_date: Only count statistics after this date (optional)
:param end_date: Only count statistics before this date (optional)
:return: Dict of totals
"""
try:
start = datetime.strptime(start_date, "%Y-%m-%d") if start_date != "" else None
end = datetime.strptime(end_date, "%Y-%m-%d") if end_date != "" else None
except (TypeError, ValueError):
raise HTTPException(400, "Date range not processable")
return await db.retrieve_totals(user.id, start, end)
@router.get('/all', summary="Get all flights logged by all users", status_code=200,
dependencies=[Depends(admin_required)], response_model=list[FlightConciseSchema])
async def get_all_flights(sort: str = "date", order: int = -1) -> list[FlightConciseSchema]:
"""
Get a list of all flights logged by any user
:param sort: Attribute to sort results by
:param order: Order of sorting (asc/desc)
:return: List of flights
"""
flights = await db.retrieve_flights(sort=sort, order=order)
return flights
@router.get('/{flight_id}', summary="Get details of a given flight", response_model=FlightDisplaySchema,
status_code=200)
async def get_flight(flight_id: str, user: UserDisplaySchema = Depends(get_current_user)) -> FlightDisplaySchema:
"""
Get all details of a given flight
:param flight_id: ID of requested flight
:param user: Currently logged-in user
:return: Flight details
"""
flight = await db.retrieve_flight(flight_id)
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")
return flight
@router.post('/', summary="Add a flight logbook entry", status_code=200)
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: ID of newly created log
"""
flight_create = FlightCreateSchema(**flight_body.model_dump(), images=[])
flight = await db.insert_flight(flight_create, user.id)
return {"id": str(flight)}
@router.post('/{log_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:
"""
Update the given flight with new information
:param flight_id: ID of flight to update
:param flight_body: New flight information to update with
: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(flight_body, flight_id)
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:
"""
Delete the given flight
:param flight_id: ID of flight to delete
:param user: Currently logged-in user
:return: 200
"""
flight = await get_flight(flight_id, user)
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")
deleted = await db.delete_flight(flight_id)
return deleted
@router.post('/import', summary="Import flights from given file")
async def import_flights(flights: UploadFile = File(...), type: str = "mfb",
user: UserDisplaySchema = Depends(get_current_user)):
"""
Import flights from a given file (csv). Note that all aircraft included must be created first
:param flights: File of flights to import
:param type: Type of import (mfb: MyFlightBook)
:param user: Current user
:return:
"""
await import_from_csv_mfb(flights, user.id)

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

@ -0,0 +1,66 @@
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, media_type = await img.retrieve_image(image_id)
if not user.id == user_created and not user.level == AuthLevel.ADMIN:
raise HTTPException(403, "Access denied")
return StreamingResponse(stream, headers={'Content-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)

144
api/routes/users.py Normal file
View File

@ -0,0 +1,144 @@
import logging
from fastapi import APIRouter, HTTPException, Depends, Request
from pydantic import ValidationError
from app.deps import get_current_user, admin_required
from database import users as db, users
from schemas.user import AuthLevel, UserCreateSchema, UserDisplaySchema, UserUpdateSchema, PasswordUpdateSchema
from routes.utils import get_hashed_password, verify_password
router = APIRouter()
logger = logging.getLogger("api")
@router.post('/', summary="Add user to database", status_code=201, dependencies=[Depends(admin_required)])
async def add_user(body: UserCreateSchema) -> dict:
"""
Add user to database.
:return: ID of newly created user
"""
auth_level = body.level if body.level is not None else AuthLevel.USER
existing_user = await db.get_user_info(body.username)
if existing_user is not None:
logger.info("User %s already exists at auth level %s", existing_user.username, existing_user.level)
raise HTTPException(400, "Username already exists")
logger.info("Creating user %s with auth level %s", body.username, auth_level)
hashed_password = get_hashed_password(body.password)
user = UserCreateSchema(username=body.username, password=hashed_password, level=auth_level.value)
added_user = await db.add_user(user)
if added_user is None:
raise HTTPException(500, "Failed to add user")
return {"id": str(added_user)}
@router.delete('/{user_id}', summary="Delete given user and all associated flights", status_code=200,
dependencies=[Depends(admin_required)])
async def remove_user(user_id: str) -> UserDisplaySchema:
"""
Delete given user from database along with all flights associated with said user
:param user_id: ID of user to delete
:return: None
"""
# Delete user from database
deleted = await db.delete_user(user_id)
if not deleted:
logger.info("Attempt to delete nonexistent user %s", user_id)
raise HTTPException(401, "User does not exist")
return deleted
@router.get('/', summary="Get a list of all users", status_code=200, response_model=list[UserDisplaySchema],
dependencies=[Depends(admin_required)])
async def get_users() -> list[UserDisplaySchema]:
"""
Get a list of all users
:return: List of users in the database
"""
users = await db.retrieve_users()
return users
@router.get('/me', status_code=200, response_model=UserDisplaySchema)
async def get_profile(user: UserDisplaySchema = Depends(get_current_user)) -> UserDisplaySchema:
"""
Return basic user information for the currently logged-in user
:return: Username and auth level of current user
"""
return user
@router.get('/{user_id}', status_code=200, dependencies=[Depends(admin_required)], response_model=UserDisplaySchema)
async def get_user_profile(user_id: str) -> UserDisplaySchema:
"""
Get profile of the given user
:param user_id: ID of the requested user
:return: Username and auth level of the requested user
"""
user = await db.get_user_info_id(id=user_id)
if user is None:
logger.warning("User %s not found", user_id)
raise HTTPException(404, "User not found")
return user
@router.put('/me', summary="Update the profile of the currently logged-in user", response_model=UserDisplaySchema)
async def update_profile(body: UserUpdateSchema,
user: UserDisplaySchema = Depends(get_current_user)) -> UserDisplaySchema:
"""
Update the profile of the currently logged-in user. Cannot update password this way
:param body: New information to insert
:param user: Currently logged-in user
:return: Updated user profile
"""
return await db.edit_profile(user.id, username=body.username, auth_level=body.level)
@router.put('/me/password', summary="Update the password of the currently logged-in user", status_code=200)
async def update_password(body: PasswordUpdateSchema,
user: UserDisplaySchema = Depends(get_current_user)):
"""
Update the password of the currently logged-in user. Requires password confirmation
:param body: Password confirmation and new password
:param user: Currently logged-in user
:return: None
"""
# Get current user's password
user = await users.get_user_system_info(username=user.username)
# Verify password confirmation
if not verify_password(body.current_password, user.password):
raise HTTPException(403, "Invalid password")
# Update the user's password
await db.edit_profile(user.id, password=body.new_password)
@router.put('/{user_id}', summary="Update profile of the given user", status_code=200,
dependencies=[Depends(admin_required)], response_model=UserDisplaySchema)
async def update_user_profile(user_id: str, body: UserUpdateSchema) -> UserDisplaySchema:
"""
Update the profile of the given user
:param user_id: ID of the user to update
:param body: New user information to insert
:return: Error messages if request is invalid, else 200
"""
return await db.edit_profile(user_id, body.username, body.password, body.level)

41
api/routes/utils.py Normal file
View File

@ -0,0 +1,41 @@
from datetime import datetime, timedelta
from typing import Any
from jose import jwt
from passlib.context import CryptContext
from app.config import Settings
password_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def get_hashed_password(password: str) -> str:
return password_context.hash(password)
def verify_password(password: str, hashed_pass: str) -> bool:
return password_context.verify(password, hashed_pass)
def create_access_token(settings: Settings, subject: str | Any,
expires_delta: int = None) -> str:
if expires_delta is not None:
expires_delta = datetime.utcnow() + expires_delta
else:
expires_delta = datetime.utcnow() + timedelta(minutes=settings.access_token_expire_minutes)
to_encode = {"exp": expires_delta, "sub": str(subject)}
encoded_jwt = jwt.encode(to_encode, settings.jwt_secret_key, settings.jwt_algorithm)
return encoded_jwt
def create_refresh_token(settings: Settings, subject: str | Any,
expires_delta: int = None) -> str:
if expires_delta is not None:
expires_delta = datetime.utcnow() + expires_delta
else:
expires_delta = datetime.utcnow() + timedelta(minutes=settings.refresh_token_expire_minutes)
to_encode = {"exp": expires_delta, "sub": str(subject)}
encoded_jwt = jwt.encode(to_encode, settings.jwt_refresh_secret_key, settings.jwt_algorithm)
return encoded_jwt

0
api/schemas/__init__.py Normal file
View File

171
api/schemas/aircraft.py Normal file
View File

@ -0,0 +1,171 @@
from enum import Enum
from utils import to_objectid
from pydantic import BaseModel, field_validator
from pydantic_core.core_schema import ValidationInfo
from schemas.utils import PyObjectId, PositiveFloat
category_class = {
"Airplane": [
"Single-Engine Land",
"Multi-Engine Land",
"Single-Engine Sea",
"Multi-Engine Sea",
],
"Rotorcraft": [
"Helicopter",
"Gyroplane",
],
"Powered Lift": [
"Powered Lift",
],
"Glider": [
"Glider",
],
"Lighter-Than-Air": [
"Airship",
"Balloon",
],
"Powered Parachute": [
"Powered Parachute Land",
"Powered Parachute Sea",
],
"Weight-Shift Control": [
"Weight-Shift Control Land",
"Weight-Shift Control Sea",
],
}
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"
aircraft_category_dict = {cls.name: cls.value for cls in AircraftCategory}
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"
aircraft_class_dict = {cls.name: cls.value for cls in AircraftClass}
class AircraftCreateSchema(BaseModel):
tail_no: str
make: str
model: str
aircraft_category: AircraftCategory
aircraft_class: AircraftClass
hobbs: 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
# HELPERS #
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"] = to_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

169
api/schemas/flight.py Normal file
View File

@ -0,0 +1,169 @@
import datetime
from typing import Optional, Dict, Union, List, get_args
from utils import to_objectid
from pydantic import BaseModel
from schemas.utils import PositiveFloatNullable, PositiveFloat, PositiveInt, PyObjectId
class FlightSchema(BaseModel):
date: datetime.datetime
aircraft: str
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: PositiveFloat
time_pic: PositiveFloat
time_sic: PositiveFloat
time_night: PositiveFloat
time_solo: PositiveFloat
time_xc: PositiveFloat
dist_xc: PositiveFloat
landings_day: PositiveInt
landings_night: PositiveInt
time_instrument: PositiveFloat
time_sim_instrument: PositiveFloat
holds_instrument: PositiveInt
dual_given: PositiveFloat
dual_recvd: PositiveFloat
time_sim: PositiveFloat
time_ground: PositiveFloat
tags: 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
class FlightDisplaySchema(FlightCreateSchema):
user: PyObjectId
id: PyObjectId
class FlightConciseSchema(BaseModel):
user: PyObjectId
id: PyObjectId
aircraft: str
date: datetime.date
aircraft: str
waypoint_from: Optional[str] = None
waypoint_to: Optional[str] = None
time_total: PositiveFloat
comments: Optional[str] = None
FlightByDateSchema = Dict[int, Union[Dict[int, 'FlightByDateSchema'], FlightConciseSchema]]
fs_keys = list(FlightPatchSchema.__annotations__.keys()) + list(FlightDisplaySchema.__annotations__.keys())
fs_types = {label: get_args(type_)[0] if get_args(type_) else str(type_) for label, type_ in
FlightSchema.__annotations__.items() if len(get_args(type_)) > 0}
# HELPERS #
def flight_display_helper(flight: dict) -> dict:
"""
Convert given db response to a format usable by FlightDisplaySchema
:param flight: Database response
:return: Usable dict
"""
flight["id"] = str(flight["_id"])
flight["user"] = str(flight["user"])
return flight
async def flight_concise_helper(flight: dict) -> dict:
"""
Convert given db response to a format usable by FlightConciseSchema
:param flight: Database response
:return: Usable dict
"""
flight["id"] = str(flight["_id"])
flight["user"] = str(flight["user"])
return flight
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
"""
flight["user"] = to_objectid(user)
return flight

149
api/schemas/user.py Normal file
View File

@ -0,0 +1,149 @@
from enum import Enum
from typing import Optional
from pydantic import BaseModel, Field, field_validator
def validate_username(value: str):
length = len(value)
if length < 4 or length > 32:
raise ValueError("Username must be between 4 and 32 characters long")
if any(not (x.isalnum() or x == "_" or x == " ") for x in value):
raise ValueError("Username must only contain letters, numbers, underscores, and dashes")
return value
def validate_password(value: str):
length = len(value)
if length < 8 or length > 16:
raise ValueError("Password must be between 8 and 16 characters long")
return value
class AuthLevel(Enum):
GUEST = 0
USER = 1
ADMIN = 2
def __lt__(self, other):
if self.__class__ is other.__class__:
return self.value < other.value
return NotImplemented
def __gt__(self, other):
if self.__class__ is other.__class__:
return self.value > other.value
return NotImplemented
def __eq__(self, other):
if self.__class__ is other.__class__:
return self.value == other.value
return NotImplemented
class UserBaseSchema(BaseModel):
username: str
class UserLoginSchema(UserBaseSchema):
password: str
class UserCreateSchema(UserBaseSchema):
password: str
level: AuthLevel = Field(AuthLevel.USER)
@field_validator("username")
@classmethod
def _valid_username(cls, value):
return validate_username(value)
@field_validator("password")
@classmethod
def _valid_password(cls, value):
return validate_password(value)
class UserUpdateSchema(BaseModel):
username: Optional[str] = None
level: Optional[AuthLevel] = AuthLevel.USER
@field_validator("username")
@classmethod
def _valid_username(cls, value):
return validate_username(value)
class UserDisplaySchema(UserBaseSchema):
id: str
level: AuthLevel
class UserSystemSchema(UserDisplaySchema):
password: str
class PasswordUpdateSchema(BaseModel):
current_password: str = ...
new_password: str = ...
@field_validator("new_password")
@classmethod
def _valid_password(cls, value):
return validate_password(value)
class TokenSchema(BaseModel):
access_token: str
refresh_token: str
class TokenPayload(BaseModel):
sub: Optional[str]
exp: Optional[int]
# HELPERS #
def user_helper(user) -> dict:
"""
Convert given db response into a format usable by UserDisplaySchema
:param user: Database response
:return: Usable dict
"""
return {
"id": str(user["_id"]),
"username": user["username"],
"level": user["level"],
}
def system_user_helper(user) -> dict:
"""
Convert given db response to a format usable by UserSystemSchema
:param user: Database response
:return: Usable dict
"""
return {
"id": str(user["_id"]),
"username": user["username"],
"password": user["password"],
"level": user["level"],
}
def create_user_helper(user) -> dict:
"""
Convert given db response to a format usable by UserCreateSchema
:param user: Database response
:return: Usable dict
"""
return {
"username": user["username"],
"password": user["password"],
"level": user["level"].value,
}

49
api/schemas/utils.py Normal file
View File

@ -0,0 +1,49 @@
from typing import Any, Annotated
from bson import ObjectId
from pydantic import Field, AfterValidator
from pydantic_core import core_schema
def round_two_decimal_places(value: Any) -> Any:
"""
Round the given value to two decimal places if it is a float, otherwise return the original value
:param value: Value to round
:return: Rounded value
"""
if isinstance(value, float):
return round(value, 2)
return value
PositiveInt = Annotated[int, Field(default=0, ge=0)]
PositiveFloat = Annotated[float, Field(default=0., ge=0), AfterValidator(round_two_decimal_places)]
PositiveFloatNullable = Annotated[float, Field(ge=0), AfterValidator(round_two_decimal_places)]
class PyObjectId(str):
@classmethod
def __get_pydantic_core_schema__(
cls, _source_type: Any, _handler: Any
) -> core_schema.CoreSchema:
return core_schema.json_or_python_schema(
json_schema=core_schema.str_schema(),
python_schema=core_schema.union_schema([
core_schema.is_instance_schema(ObjectId),
core_schema.chain_schema([
core_schema.str_schema(),
core_schema.no_info_plain_validator_function(cls.validate),
])
]),
serialization=core_schema.plain_serializer_function_ser_schema(
lambda x: str(x)
),
)
@classmethod
def validate(cls, value) -> ObjectId:
if not ObjectId.is_valid(value):
raise ValueError("Invalid ObjectId")
return ObjectId(value)

17
api/utils.py Normal file
View File

@ -0,0 +1,17 @@
from bson import ObjectId
from bson.errors import InvalidId
from fastapi import HTTPException
def to_objectid(id: str) -> ObjectId:
"""
Try to convert a given string to an ObjectId
:param id: ID in string form to convert
:return: Converted ObjectId
"""
try:
oid = ObjectId(id)
return oid
except InvalidId:
raise HTTPException(400, f"{id} is not a recognized ID")