From 41a301b273323a7d3412c0d94891f6f17a237a9b Mon Sep 17 00:00:00 2001 From: april Date: Mon, 18 Dec 2023 16:14:57 -0600 Subject: [PATCH 01/66] Write basic authentication and log API --- api/app.py | 153 +++++++++++++++++++++++++++++++++++++++++ api/database/models.py | 68 ++++++++++++++++++ api/requirements.txt | 3 + 3 files changed, 224 insertions(+) create mode 100644 api/app.py create mode 100644 api/database/models.py create mode 100644 api/requirements.txt diff --git a/api/app.py b/api/app.py new file mode 100644 index 0000000..4e7bad6 --- /dev/null +++ b/api/app.py @@ -0,0 +1,153 @@ +import json +from datetime import timedelta, datetime, timezone + +import bcrypt +from flask import Flask, request, Response, jsonify, session + +from database.models import Flight, User, AuthLevel +from mongoengine import connect, ValidationError, DoesNotExist +from flask_jwt_extended import create_access_token, get_jwt , get_jwt_identity, unset_jwt_cookies, jwt_required, JWTManager + +api = Flask(__name__) + +api.config["JWT_SECRET_KEY"] = "please-remember-to-change-me" +api.config["JWT_ACCESS_TOKEN_EXPORES"] = timedelta(hours=1) +jwt = JWTManager(api) + + +connect('tailfin') + + +@api.after_request +def refresh_expiring_jwts(response): + try: + exp_timestamp = get_jwt()["exp"] + now = datetime.now(timezone.utc) + target_timestamp = datetime.timestamp(now + timedelta(minutes=30)) + if target_timestamp > exp_timestamp: + access_token = create_access_token(identity=get_jwt_identity()) + data = response.get_json() + if type(data) is dict: + data["access_token"] = access_token + response.data = json.dumps(data) + return response + except (RuntimeError, KeyError): + # No valid JWT, return original response + return response + + +@api.route('/add_user', methods=["POST"]) +@jwt_required() +def add_user(): + user = User.objects.get(username=get_jwt_identity()) + if user.level != AuthLevel.ADMIN: + return '', 401 + + username = request.json.get("username", None) + password = request.json.get("password", None) + auth_level = request.json.get("auth_level", None) + + try: + existing_user = User.objects.get(username=username) + print(existing_user.to_json()) + return jsonify({"msg": "Username already exists"}) + except DoesNotExist: + hashed_password = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()) + user = User(username=username, password=hashed_password, level=auth_level).save() + return jsonify({"id": user.id}), 200 + + +@api.route('/users', methods=["GET"]) +@jwt_required() +def get_users(): + user = User.objects.get(username=get_jwt_identity()) + if user.level != AuthLevel.ADMIN: + return '', 401 + + users = User.objects.to_json() + return users, 200 + + +@api.route('/login', methods=["POST"]) +def create_token(): + username = request.json.get("username", None) + password = request.json.get("password", None) + + try: + user = User.objects.get(username=username) + except DoesNotExist: + return jsonify({"msg": "Invalid username or password"}), 401 + else: + if bcrypt.checkpw(password.encode('utf-8'), user.password.encode('utf-8')): + access_token = create_access_token(identity=username) + response = {"access_token": access_token} + return jsonify(response), 200 + return jsonify({"msg": "Invalid username or password"}), 401 + + +@api.route('/logout', methods=["POST"]) +def logout(): + response = jsonify({"msg": "logout successful"}) + unset_jwt_cookies(response) + return response + + +@api.route('/profile', methods=["GET"]) +@jwt_required() +def get_profile(): + user = User.objects.get(username=get_jwt_identity()) + print(user.to_json()) + return jsonify({"username": user.username, "auth_level:": str(user.level)}), 200 + + +@api.route('/flights', methods=['GET']) +@jwt_required() +def get_flights(): + user = User.objects.get(username=get_jwt_identity()).id + flights = Flight.objects(user=user).to_json() + return flights, 200 + + +@api.route('/flights/', methods=['GET']) +@jwt_required() +def get_flight(flight_id): + user = User.objects.get(username=get_jwt_identity()).id + flight = Flight.objects(id=flight_id).to_json() + if flight.user != user: + return '', 401 + return flight, 200 + + +@api.route('/flights', methods=['POST']) +@jwt_required() +def add_flight(): + user = User.objects(username=get_jwt_identity()) + + body = request.get_json() + try: + flight = Flight(user=user, **body).save() + except ValidationError: + return jsonify({"msg": "Invalid request"}) + id = flight.id + return jsonify({'id': str(id)}), 201 + + +@api.route('/flights/', methods=['PUT']) +def update_flight(flight_id): + body = request.get_json() + Flight.objects(id=flight_id).update(**body) + return '', 200 + + +@api.route('/flights/', methods=['DELETE']) +def delete_flight(index): + Flight.objects(id=id).delete() + return '', 200 + + +if __name__ == '__main__': + if User.objects(level=AuthLevel.ADMIN).count() == 0: + hashed_password = bcrypt.hashpw("admin".encode('utf-8'), bcrypt.gensalt()) + User(username="admin", password=hashed_password, level=AuthLevel.ADMIN).save() + + api.run() \ No newline at end of file diff --git a/api/database/models.py b/api/database/models.py new file mode 100644 index 0000000..9cb971d --- /dev/null +++ b/api/database/models.py @@ -0,0 +1,68 @@ +from enum import Enum + +from mongoengine import * + + +class AuthLevel(Enum): + GUEST = 0 + USER = 1 + ADMIN = 2 + + +class User(Document): + username = StringField(required=True, unique=True) + password = StringField(required=True) + level = EnumField(AuthLevel, default=AuthLevel.USER) + + +class Flight(Document): + user = ObjectIdField(required=True) + + date = DateField(required=True, unique=False) + aircraft = StringField(default="") + waypoint_from = StringField(default="") + waypoint_to = StringField(default="") + route = StringField(default="") + + hobbs_start = DecimalField() + hobbs_end = DecimalField() + tach_start = DecimalField() + tach_end = DecimalField() + + time_start = DateTimeField() + time_off = DateTimeField() + time_down = DateTimeField() + time_stop = DateTimeField() + + time_total = DecimalField(default=0) + time_pic = DecimalField(default=0) + time_sic = DecimalField(default=0) + time_night = DecimalField(default=0) + time_solo = DecimalField() + + time_xc = DecimalField() + dist_xc = DecimalField() + + takeoffs_day = IntField() + landings_day = IntField() + takeoffs_night = IntField() + landings_night = IntField() + landings_all = IntField() + + time_instrument = DecimalField() + time_sim_instrument = DecimalField() + holds_instrument = DecimalField() + + dual_given = DecimalField() + dual_recvd = DecimalField() + time_sim = DecimalField() + time_ground = DecimalField() + + tags = ListField(StringField()) + + pax = ListField(StringField()) + crew = ListField(StringField()) + + comments = StringField() + + photos = ListField(ImageField()) diff --git a/api/requirements.txt b/api/requirements.txt new file mode 100644 index 0000000..72f413f --- /dev/null +++ b/api/requirements.txt @@ -0,0 +1,3 @@ +bcrypt~=4.1.2 +flask~=3.0.0 +mongoengine~=0.27.0 \ No newline at end of file From c0a33397fc70000131c874603d9762ef05778896 Mon Sep 17 00:00:00 2001 From: april Date: Tue, 19 Dec 2023 08:55:32 -0600 Subject: [PATCH 02/66] Add environment variable config --- api/app.py | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/api/app.py b/api/app.py index 4e7bad6..32084a9 100644 --- a/api/app.py +++ b/api/app.py @@ -1,4 +1,4 @@ -import json +import json, os, sys from datetime import timedelta, datetime, timezone import bcrypt @@ -10,8 +10,14 @@ from flask_jwt_extended import create_access_token, get_jwt , get_jwt_identity, api = Flask(__name__) -api.config["JWT_SECRET_KEY"] = "please-remember-to-change-me" -api.config["JWT_ACCESS_TOKEN_EXPORES"] = timedelta(hours=1) +try: + api.config["JWT_SECRET_KEY"] = os.environ["TAILFIN_DB_KEY"] +except KeyError: + api.logger.error("Please set 'TAILFIN_DB_KEY' environment variable") + exit(1) + +print(os.environ.get("TAILFIN_DB_KEY")) +api.config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(hours=1) jwt = JWTManager(api) @@ -49,7 +55,6 @@ def add_user(): try: existing_user = User.objects.get(username=username) - print(existing_user.to_json()) return jsonify({"msg": "Username already exists"}) except DoesNotExist: hashed_password = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()) @@ -147,7 +152,22 @@ def delete_flight(index): if __name__ == '__main__': if User.objects(level=AuthLevel.ADMIN).count() == 0: - hashed_password = bcrypt.hashpw("admin".encode('utf-8'), bcrypt.gensalt()) - User(username="admin", password=hashed_password, level=AuthLevel.ADMIN).save() + api.logger.info("No admin users exist. Creating default admin user...") + try: + admin_username = os.environ["TAILFIN_ADMIN_USERNAME"] + api.logger.info("Setting admin username to 'TAILFIN_ADMIN_USERNAME': %s", admin_username) + except KeyError: + admin_username = "admin" + api.logger.info("'TAILFIN_ADMIN_USERNAME' not set, using default username 'admin'") + try: + admin_password = os.environ["TAILFIN_ADMIN_PASSWORD"] + api.logger.info("Setting admin password to 'TAILFIN_ADMIN_PASSWORD'") + except KeyError: + admin_password = "admin" + api.logger.warning("'TAILFIN_ADMIN_PASSWORD' not set, using default password 'admin'\n" + "Change this as soon as possible") + hashed_password = bcrypt.hashpw(admin_password.encode('utf-8'), bcrypt.gensalt()) + User(username=admin_username, password=hashed_password, level=AuthLevel.ADMIN).save() + api.logger.info("Default admin user created with username %s", User.objects.get(level=AuthLevel.ADMIN).username) - api.run() \ No newline at end of file + api.run() From 3ec92573e147fa6e731fc76bb2d89c428311f184 Mon Sep 17 00:00:00 2001 From: april Date: Tue, 19 Dec 2023 10:58:07 -0600 Subject: [PATCH 03/66] Add auth levels and logging --- api/app.py | 114 +++++++++++++++++++++++++++++++++-------- api/database/models.py | 20 +++++++- 2 files changed, 112 insertions(+), 22 deletions(-) diff --git a/api/app.py b/api/app.py index 32084a9..1cdd4c5 100644 --- a/api/app.py +++ b/api/app.py @@ -1,8 +1,10 @@ +import functools import json, os, sys from datetime import timedelta, datetime, timezone import bcrypt from flask import Flask, request, Response, jsonify, session +from pymongo import database from database.models import Flight, User, AuthLevel from mongoengine import connect, ValidationError, DoesNotExist @@ -16,7 +18,6 @@ except KeyError: api.logger.error("Please set 'TAILFIN_DB_KEY' environment variable") exit(1) -print(os.environ.get("TAILFIN_DB_KEY")) api.config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(hours=1) jwt = JWTManager(api) @@ -24,6 +25,20 @@ jwt = JWTManager(api) connect('tailfin') +def auth_level_required(level): + def auth_inner(func): + def auth_wrapper(*args, **kwargs): + user = User.objects.get(username=get_jwt_identity()) + if AuthLevel(user.level) < level: + api.logger.warning("Attempted access to unauthorized resource by %s", user.username) + return '', 403 + else: + return func(*args, **kwargs) + auth_wrapper.__name__ = func.__name__ + return auth_wrapper + return auth_inner + + @api.after_request def refresh_expiring_jwts(response): try: @@ -31,6 +46,7 @@ def refresh_expiring_jwts(response): now = datetime.now(timezone.utc) target_timestamp = datetime.timestamp(now + timedelta(minutes=30)) if target_timestamp > exp_timestamp: + api.logger.info("Refreshing expiring JWT") access_token = create_access_token(identity=get_jwt_identity()) data = response.get_json() if type(data) is dict: @@ -39,36 +55,49 @@ def refresh_expiring_jwts(response): return response except (RuntimeError, KeyError): # No valid JWT, return original response + api.logger.info("No valid JWT, cannot refresh expiry") return response -@api.route('/add_user', methods=["POST"]) +@api.route('/users', methods=["POST"]) @jwt_required() +@auth_level_required(AuthLevel.ADMIN) def add_user(): - user = User.objects.get(username=get_jwt_identity()) - if user.level != AuthLevel.ADMIN: - return '', 401 - username = request.json.get("username", None) password = request.json.get("password", None) - auth_level = request.json.get("auth_level", None) + auth_level = AuthLevel(request.json.get("auth_level", None)) try: existing_user = User.objects.get(username=username) + api.logger.info("User %s already exists at auth level %s", existing_user.username, existing_user.level) return jsonify({"msg": "Username already exists"}) except DoesNotExist: + api.logger.info("Creating user %s with auth level %s", username, auth_level) + hashed_password = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()) - user = User(username=username, password=hashed_password, level=auth_level).save() - return jsonify({"id": user.id}), 200 + user = User(username=username, password=hashed_password, level=auth_level.value) + user.save() + + return jsonify({"id": user.id}), 201 + + +@api.route('/users/', methods=['DELETE']) +@jwt_required() +@auth_level_required(AuthLevel.ADMIN) +def remove_user(user_id): + try: + User.objects.get(id=user_id).delete() + except DoesNotExist: + api.logger.info("Attempt to delete nonexistent user %s by %s", user_id, get_jwt_identity()) + return {"msg": "User does not exist"}, 401 + Flight.objects(user=user_id).delete() + return '', 200 @api.route('/users', methods=["GET"]) @jwt_required() +@auth_level_required(AuthLevel.ADMIN) def get_users(): - user = User.objects.get(username=get_jwt_identity()) - if user.level != AuthLevel.ADMIN: - return '', 401 - users = User.objects.to_json() return users, 200 @@ -85,8 +114,10 @@ def create_token(): else: if bcrypt.checkpw(password.encode('utf-8'), user.password.encode('utf-8')): access_token = create_access_token(identity=username) + api.logger.info("%s successfully logged in", username) response = {"access_token": access_token} return jsonify(response), 200 + api.logger.info("Failed login attempt from %s", request.remote_addr) return jsonify({"msg": "Invalid username or password"}), 401 @@ -100,11 +131,43 @@ def logout(): @api.route('/profile', methods=["GET"]) @jwt_required() def get_profile(): - user = User.objects.get(username=get_jwt_identity()) - print(user.to_json()) + try: + user = User.objects.get(username=get_jwt_identity()) + except DoesNotExist: + api.logger.warning("User %s not found", get_jwt_identity()) + return {"msg": "User not found"}, 401 return jsonify({"username": user.username, "auth_level:": str(user.level)}), 200 +@api.route('/profile', methods=["PUT"]) +@jwt_required() +def update_profile(): + try: + user = User.objects.get(username=get_jwt_identity()) + except DoesNotExist: + api.logger.warning("User %s not found", get_jwt_identity()) + return {"msg": "user not found"}, 401 + body = request.get_json() + + username = request.json.get("username", None) + password = request.json.get("password", None) + auth_level = request.json.get("level", None) + + if username: + existing_users = User.objects(username=username).count() + if existing_users != 0: + return jsonify({"msg": "Username not available"}) + if password: + hashed_password = bcrypt.hashpw(password.encode('UTF-8'), bcrypt.gensalt()) + if auth_level: + if AuthLevel(user.level) < AuthLevel.ADMIN: + api.logger.warning("Unauthorized attempt to change auth level of %s", user.username) + return jsonify({"msg": "Unauthorized attempt to change auth level"}), 403 + + user.update(**body) + return '', 200 + + @api.route('/flights', methods=['GET']) @jwt_required() def get_flights(): @@ -113,13 +176,22 @@ def get_flights(): return flights, 200 +@api.route('/flights/all', methods=['GET']) +@jwt_required() +@auth_level_required(AuthLevel.ADMIN) +def get_all_flights(): + flights = Flight.objects.to_json() + return flights, 200 + + @api.route('/flights/', methods=['GET']) @jwt_required() def get_flight(flight_id): user = User.objects.get(username=get_jwt_identity()).id flight = Flight.objects(id=flight_id).to_json() - if flight.user != user: - return '', 401 + if flight.user != user and AuthLevel(user.level) != AuthLevel.ADMIN: + api.logger.warning("Attempted access to unauthorized flight by %s", user.username) + return {"msg": "Unauthorized access"}, 403 return flight, 200 @@ -144,14 +216,14 @@ def update_flight(flight_id): return '', 200 -@api.route('/flights/', methods=['DELETE']) -def delete_flight(index): - Flight.objects(id=id).delete() +@api.route('/flights/', methods=['DELETE']) +def delete_flight(flight_id): + Flight.objects(id=flight_id).delete() return '', 200 if __name__ == '__main__': - if User.objects(level=AuthLevel.ADMIN).count() == 0: + if User.objects(level=AuthLevel.ADMIN.value).count() == 0: api.logger.info("No admin users exist. Creating default admin user...") try: admin_username = os.environ["TAILFIN_ADMIN_USERNAME"] diff --git a/api/database/models.py b/api/database/models.py index 9cb971d..5140ca9 100644 --- a/api/database/models.py +++ b/api/database/models.py @@ -8,11 +8,29 @@ class AuthLevel(Enum): 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 User(Document): username = StringField(required=True, unique=True) password = StringField(required=True) - level = EnumField(AuthLevel, default=AuthLevel.USER) + + # EnumField validation is currently broken, replace workaround if MongoEngine is updated to fix it + level = IntField(choices=[l.value for l in AuthLevel], default=1) + # level = EnumField(AuthLevel, default=AuthLevel.USER) class Flight(Document): From 43e90f8b07c3d0c47c86ec11a63fc4b88c88f0b0 Mon Sep 17 00:00:00 2001 From: april Date: Tue, 19 Dec 2023 11:43:29 -0600 Subject: [PATCH 04/66] Minor restructuring and add documentation --- api/app.py | 191 ++++++++++++++++++++++++++++++++++++------ api/database/utils.py | 40 +++++++++ 2 files changed, 204 insertions(+), 27 deletions(-) create mode 100644 api/database/utils.py diff --git a/api/app.py b/api/app.py index 1cdd4c5..ade4692 100644 --- a/api/app.py +++ b/api/app.py @@ -10,22 +10,33 @@ from database.models import Flight, User, AuthLevel from mongoengine import connect, ValidationError, DoesNotExist from flask_jwt_extended import create_access_token, get_jwt , get_jwt_identity, unset_jwt_cookies, jwt_required, JWTManager +# Initialize Flask app api = Flask(__name__) +# Set JWT key from environment variable try: api.config["JWT_SECRET_KEY"] = os.environ["TAILFIN_DB_KEY"] except KeyError: api.logger.error("Please set 'TAILFIN_DB_KEY' environment variable") exit(1) +# Set JWT keys to expire after 1 hour api.config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(hours=1) + +# Initialize JWT manager jwt = JWTManager(api) - +# Connect to MongoDB connect('tailfin') -def auth_level_required(level): +def auth_level_required(level: AuthLevel): + """ + Limit access to given authorization level. + + :param level: required authorization level to access this endpoint + :return: 403 Unauthorized upon auth failure or response of decorated function on auth success + """ def auth_inner(func): def auth_wrapper(*args, **kwargs): user = User.objects.get(username=get_jwt_identity()) @@ -41,6 +52,12 @@ def auth_level_required(level): @api.after_request def refresh_expiring_jwts(response): + """ + Refresh/reissue JWTs that are near expiry following each request containing a JWT + + :param response: Response given by previous request + :return: Original response with refreshed JWT + """ try: exp_timestamp = get_jwt()["exp"] now = datetime.now(timezone.utc) @@ -63,9 +80,15 @@ def refresh_expiring_jwts(response): @jwt_required() @auth_level_required(AuthLevel.ADMIN) def add_user(): - username = request.json.get("username", None) - password = request.json.get("password", None) - auth_level = AuthLevel(request.json.get("auth_level", None)) + """ + Add user to database. + + :return: Failure message if user already exists, otherwise ID of newly created user + """ + body = request.get_json() + username = body.username + password = body.password + auth_level = AuthLevel(body.auth_level) try: existing_user = User.objects.get(username=username) @@ -85,6 +108,12 @@ def add_user(): @jwt_required() @auth_level_required(AuthLevel.ADMIN) def remove_user(user_id): + """ + Delete given user from database + + :param user_id: ID of user to delete + :return: 200 if success, 401 if user does not exist + """ try: User.objects.get(id=user_id).delete() except DoesNotExist: @@ -98,12 +127,22 @@ def remove_user(user_id): @jwt_required() @auth_level_required(AuthLevel.ADMIN) def get_users(): + """ + Get a list of all users + + :return: List of users in the database + """ users = User.objects.to_json() return users, 200 @api.route('/login', methods=["POST"]) def create_token(): + """ + Log in as given user and return JWT for API access + + :return: 401 if username or password invalid, else JWT + """ username = request.json.get("username", None) password = request.json.get("password", None) @@ -123,25 +162,77 @@ def create_token(): @api.route('/logout', methods=["POST"]) def logout(): + """ + Log out given user. Note that JWTs cannot be natively revoked so this must also be handled by the frontend + + :return: Message with JWT removed from headers + """ response = jsonify({"msg": "logout successful"}) unset_jwt_cookies(response) return response -@api.route('/profile', methods=["GET"]) +@api.route('/profile/', methods=["GET"]) @jwt_required() -def get_profile(): +@auth_level_required(AuthLevel.ADMIN) +def get_user_profile(user_id): + """ + Get profile of the given user + + :param user_id: ID of the requested user + :return: 401 is user does not exist, else username and auth level + """ try: - user = User.objects.get(username=get_jwt_identity()) + user = User.objects.get(id=user_id) except DoesNotExist: api.logger.warning("User %s not found", get_jwt_identity()) return {"msg": "User not found"}, 401 return jsonify({"username": user.username, "auth_level:": str(user.level)}), 200 +@api.route('/profile/', methods=["PUT"]) +@jwt_required() +@auth_level_required(AuthLevel.ADMIN) +def update_user_profile(user_id): + """ + Update the profile of the given user + :param user_id: ID of the user to update + :return: Error messages if request is invalid, else 200 + """ + try: + user = User.objects.get(id=user_id) + except DoesNotExist: + api.logger.warning("User %s not found", get_jwt_identity()) + return jsonify({"msg": "User not found"}), 401 + + body = request.get_json() + return update_profile(user.id, body.username, body.password, body.auth_level) + + +@api.route('/profile', methods=["GET"]) +@jwt_required() +def get_profile(): + """ + Return basic user information for the currently logged-in user + + :return: 401 if user not found, else username and auth level + """ + try: + user = User.objects.get(username=get_jwt_identity()) + except DoesNotExist: + api.logger.warning("User %s not found", get_jwt_identity()) + return jsonify({"msg": "User not found"}), 401 + return jsonify({"username": user.username, "auth_level:": str(user.level)}), 200 + + @api.route('/profile', methods=["PUT"]) @jwt_required() def update_profile(): + """ + Update the profile of the currently logged-in user + + :return: Error messages if request is invalid, else 200 + """ try: user = User.objects.get(username=get_jwt_identity()) except DoesNotExist: @@ -149,28 +240,17 @@ def update_profile(): return {"msg": "user not found"}, 401 body = request.get_json() - username = request.json.get("username", None) - password = request.json.get("password", None) - auth_level = request.json.get("level", None) - - if username: - existing_users = User.objects(username=username).count() - if existing_users != 0: - return jsonify({"msg": "Username not available"}) - if password: - hashed_password = bcrypt.hashpw(password.encode('UTF-8'), bcrypt.gensalt()) - if auth_level: - if AuthLevel(user.level) < AuthLevel.ADMIN: - api.logger.warning("Unauthorized attempt to change auth level of %s", user.username) - return jsonify({"msg": "Unauthorized attempt to change auth level"}), 403 - - user.update(**body) - return '', 200 + return update_profile(user.id, body.username, body.password, body.auth_level) @api.route('/flights', methods=['GET']) @jwt_required() def get_flights(): + """ + Get a list of the flights logged by the currently logged-in user + + :return: List of flights + """ user = User.objects.get(username=get_jwt_identity()).id flights = Flight.objects(user=user).to_json() return flights, 200 @@ -180,6 +260,11 @@ def get_flights(): @jwt_required() @auth_level_required(AuthLevel.ADMIN) def get_all_flights(): + """ + Get a list of all flights logged by any user + + :return: List of flights + """ flights = Flight.objects.to_json() return flights, 200 @@ -187,6 +272,12 @@ def get_all_flights(): @api.route('/flights/', methods=['GET']) @jwt_required() def get_flight(flight_id): + """ + Get all details of a given flight + + :param flight_id: ID of requested flight + :return: Flight details + """ user = User.objects.get(username=get_jwt_identity()).id flight = Flight.objects(id=flight_id).to_json() if flight.user != user and AuthLevel(user.level) != AuthLevel.ADMIN: @@ -198,6 +289,11 @@ def get_flight(flight_id): @api.route('/flights', methods=['POST']) @jwt_required() def add_flight(): + """ + Add a flight logbook entry + + :return: Error message if request invalid, else ID of newly created log + """ user = User.objects(username=get_jwt_identity()) body = request.get_json() @@ -210,19 +306,59 @@ def add_flight(): @api.route('/flights/', methods=['PUT']) +@jwt_required() def update_flight(flight_id): + """ + Update the given flight with new information + + :param flight_id: ID of flight to update + :return: Error messages if user not found or access unauthorized, else 200 + """ + try: + user = User.objects.get(username=get_jwt_identity()) + except DoesNotExist: + api.logger.warning("User %s not found", get_jwt_identity()) + return {"msg": "user not found"}, 401 + + flight = Flight.objects(id=flight_id) + + if flight.user != user and AuthLevel(user.level) != AuthLevel.ADMIN: + api.logger.warning("Attempted access to unauthorized flight by %s", user.username) + return {"msg": "Unauthorized access"}, 403 + body = request.get_json() - Flight.objects(id=flight_id).update(**body) + flight.update(**body) + return '', 200 @api.route('/flights/', methods=['DELETE']) def delete_flight(flight_id): - Flight.objects(id=flight_id).delete() + """ + Delete the given flight + + :param flight_id: ID of flight to delete + :return: Error messages if user not found or access unauthorized, else 200 + """ + try: + user = User.objects.get(username=get_jwt_identity()) + except DoesNotExist: + api.logger.warning("User %s not found", get_jwt_identity()) + return {"msg": "user not found"}, 401 + + flight = Flight.objects(id=flight_id) + + if flight.user != user and AuthLevel(user.level) != AuthLevel.ADMIN: + api.logger.warning("Attempted access to unauthorized flight by %s", user.username) + return {"msg": "Unauthorized access"}, 403 + + flight.delete() + return '', 200 if __name__ == '__main__': + # Create default admin user if no admin users found if User.objects(level=AuthLevel.ADMIN.value).count() == 0: api.logger.info("No admin users exist. Creating default admin user...") try: @@ -242,4 +378,5 @@ if __name__ == '__main__': User(username=admin_username, password=hashed_password, level=AuthLevel.ADMIN).save() api.logger.info("Default admin user created with username %s", User.objects.get(level=AuthLevel.ADMIN).username) + # Start the app api.run() diff --git a/api/database/utils.py b/api/database/utils.py new file mode 100644 index 0000000..6faead9 --- /dev/null +++ b/api/database/utils.py @@ -0,0 +1,40 @@ +import bcrypt +from flask import jsonify +from mongoengine import DoesNotExist + +from database.models import User, AuthLevel + + +def update_profile(user_id, username=None, password=None, auth_level=None): + """ + 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 + """ + try: + user = User.objects.get(id=user_id) + except DoesNotExist: + return {"msg": "user not found"}, 401 + + if username: + existing_users = User.objects(username=username).count() + if existing_users != 0: + return jsonify({"msg": "Username not available"}) + if password: + hashed_password = bcrypt.hashpw(password.encode('UTF-8'), bcrypt.gensalt()) + if auth_level: + if AuthLevel(user.level) < AuthLevel.ADMIN: + return jsonify({"msg": "Unauthorized attempt to change auth level"}), 403 + + if username: + user.update_one(username=username) + if password: + user.update_one(password=password) + if auth_level: + user.update_one(level=auth_level) + + return '', 200 From ce1d2c09188384cad36edb4694584cd1a94e80c4 Mon Sep 17 00:00:00 2001 From: april Date: Tue, 19 Dec 2023 13:07:55 -0600 Subject: [PATCH 05/66] Fix request JSON access issues --- api/app.py | 67 ++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 47 insertions(+), 20 deletions(-) diff --git a/api/app.py b/api/app.py index ade4692..3aee9d8 100644 --- a/api/app.py +++ b/api/app.py @@ -1,14 +1,15 @@ -import functools -import json, os, sys +import json +import os from datetime import timedelta, datetime, timezone import bcrypt -from flask import Flask, request, Response, jsonify, session -from pymongo import database +from flask import Flask, request, jsonify + +from mongoengine import connect, ValidationError, DoesNotExist +from flask_jwt_extended import create_access_token, get_jwt, get_jwt_identity, unset_jwt_cookies, jwt_required, \ + JWTManager from database.models import Flight, User, AuthLevel -from mongoengine import connect, ValidationError, DoesNotExist -from flask_jwt_extended import create_access_token, get_jwt , get_jwt_identity, unset_jwt_cookies, jwt_required, JWTManager # Initialize Flask app api = Flask(__name__) @@ -37,6 +38,7 @@ def auth_level_required(level: AuthLevel): :param level: required authorization level to access this endpoint :return: 403 Unauthorized upon auth failure or response of decorated function on auth success """ + def auth_inner(func): def auth_wrapper(*args, **kwargs): user = User.objects.get(username=get_jwt_identity()) @@ -45,8 +47,10 @@ def auth_level_required(level: AuthLevel): return '', 403 else: return func(*args, **kwargs) + auth_wrapper.__name__ = func.__name__ return auth_wrapper + return auth_inner @@ -86,9 +90,15 @@ def add_user(): :return: Failure message if user already exists, otherwise ID of newly created user """ body = request.get_json() - username = body.username - password = body.password - auth_level = AuthLevel(body.auth_level) + try: + username = body["username"] + password = body["password"] + except KeyError: + return jsonify({"msg": "Missing username or password"}) + try: + auth_level = AuthLevel(body["auth_level"]) + except KeyError: + auth_level = AuthLevel.USER try: existing_user = User.objects.get(username=username) @@ -101,7 +111,7 @@ def add_user(): user = User(username=username, password=hashed_password, level=auth_level.value) user.save() - return jsonify({"id": user.id}), 201 + return jsonify({"id": str(user.id)}), 201 @api.route('/users/', methods=['DELETE']) @@ -143,8 +153,12 @@ def create_token(): :return: 401 if username or password invalid, else JWT """ - username = request.json.get("username", None) - password = request.json.get("password", None) + body = request.get_json() + try: + username = body["username"] + password = body["password"] + except KeyError: + return jsonify({"msg": "Missing username or password"}) try: user = User.objects.get(username=username) @@ -206,7 +220,7 @@ def update_user_profile(user_id): return jsonify({"msg": "User not found"}), 401 body = request.get_json() - return update_profile(user.id, body.username, body.password, body.auth_level) + return update_profile(user.id, body["username"], body["password"], body["auth_level"]) @api.route('/profile', methods=["GET"]) @@ -240,7 +254,7 @@ def update_profile(): return {"msg": "user not found"}, 401 body = request.get_json() - return update_profile(user.id, body.username, body.password, body.auth_level) + return update_profile(user.id, body["username"], body["password"], body["auth_level"]) @api.route('/flights', methods=['GET']) @@ -251,8 +265,12 @@ def get_flights(): :return: List of flights """ - user = User.objects.get(username=get_jwt_identity()).id - flights = Flight.objects(user=user).to_json() + try: + user = User.objects.get(username=get_jwt_identity()) + except DoesNotExist: + api.logger.warning("User %s not found", get_jwt_identity()) + return {"msg": "user not found"}, 401 + flights = Flight.objects(user=user.id).to_json() return flights, 200 @@ -278,9 +296,14 @@ def get_flight(flight_id): :param flight_id: ID of requested flight :return: Flight details """ - user = User.objects.get(username=get_jwt_identity()).id + try: + user = User.objects.get(username=get_jwt_identity()) + except DoesNotExist: + api.logger.warning("User %s not found", get_jwt_identity()) + return {"msg": "user not found"}, 401 + flight = Flight.objects(id=flight_id).to_json() - if flight.user != user and AuthLevel(user.level) != AuthLevel.ADMIN: + if flight.user != user.id and AuthLevel(user.level) != AuthLevel.ADMIN: api.logger.warning("Attempted access to unauthorized flight by %s", user.username) return {"msg": "Unauthorized access"}, 403 return flight, 200 @@ -294,7 +317,11 @@ def add_flight(): :return: Error message if request invalid, else ID of newly created log """ - user = User.objects(username=get_jwt_identity()) + try: + user = User.objects.get(username=get_jwt_identity()) + except DoesNotExist: + api.logger.warning("User %s not found", get_jwt_identity()) + return {"msg": "user not found"}, 401 body = request.get_json() try: @@ -375,7 +402,7 @@ if __name__ == '__main__': api.logger.warning("'TAILFIN_ADMIN_PASSWORD' not set, using default password 'admin'\n" "Change this as soon as possible") hashed_password = bcrypt.hashpw(admin_password.encode('utf-8'), bcrypt.gensalt()) - User(username=admin_username, password=hashed_password, level=AuthLevel.ADMIN).save() + User(username=admin_username, password=hashed_password, level=AuthLevel.ADMIN.value).save() api.logger.info("Default admin user created with username %s", User.objects.get(level=AuthLevel.ADMIN).username) # Start the app From 1f275ec1954f33662b178ad5b4c7a644fed2f5a4 Mon Sep 17 00:00:00 2001 From: april Date: Tue, 19 Dec 2023 13:27:07 -0600 Subject: [PATCH 06/66] Refactor into multiple files --- api/app.py | 361 ++---------------------------------------- api/database/utils.py | 3 +- api/routes/flights.py | 136 ++++++++++++++++ api/routes/users.py | 191 ++++++++++++++++++++++ api/routes/utils.py | 57 +++++++ 5 files changed, 398 insertions(+), 350 deletions(-) create mode 100644 api/routes/flights.py create mode 100644 api/routes/users.py create mode 100644 api/routes/utils.py diff --git a/api/app.py b/api/app.py index 3aee9d8..26aa3ec 100644 --- a/api/app.py +++ b/api/app.py @@ -2,18 +2,22 @@ import json import os from datetime import timedelta, datetime, timezone -import bcrypt -from flask import Flask, request, jsonify +from flask import Flask -from mongoengine import connect, ValidationError, DoesNotExist -from flask_jwt_extended import create_access_token, get_jwt, get_jwt_identity, unset_jwt_cookies, jwt_required, \ - JWTManager +from mongoengine import connect +from flask_jwt_extended import create_access_token, get_jwt, get_jwt_identity, JWTManager -from database.models import Flight, User, AuthLevel +from routes.flights import flights_api +from routes.users import users_api +from routes.utils import create_admin_user # Initialize Flask app api = Flask(__name__) +# Register route blueprints +api.register_blueprint(users_api) +api.register_blueprint(flights_api) + # Set JWT key from environment variable try: api.config["JWT_SECRET_KEY"] = os.environ["TAILFIN_DB_KEY"] @@ -31,29 +35,6 @@ jwt = JWTManager(api) connect('tailfin') -def auth_level_required(level: AuthLevel): - """ - Limit access to given authorization level. - - :param level: required authorization level to access this endpoint - :return: 403 Unauthorized upon auth failure or response of decorated function on auth success - """ - - def auth_inner(func): - def auth_wrapper(*args, **kwargs): - user = User.objects.get(username=get_jwt_identity()) - if AuthLevel(user.level) < level: - api.logger.warning("Attempted access to unauthorized resource by %s", user.username) - return '', 403 - else: - return func(*args, **kwargs) - - auth_wrapper.__name__ = func.__name__ - return auth_wrapper - - return auth_inner - - @api.after_request def refresh_expiring_jwts(response): """ @@ -80,330 +61,12 @@ def refresh_expiring_jwts(response): return response -@api.route('/users', methods=["POST"]) -@jwt_required() -@auth_level_required(AuthLevel.ADMIN) -def add_user(): - """ - Add user to database. - :return: Failure message if user already exists, otherwise ID of newly created user - """ - body = request.get_json() - try: - username = body["username"] - password = body["password"] - except KeyError: - return jsonify({"msg": "Missing username or password"}) - try: - auth_level = AuthLevel(body["auth_level"]) - except KeyError: - auth_level = AuthLevel.USER - - try: - existing_user = User.objects.get(username=username) - api.logger.info("User %s already exists at auth level %s", existing_user.username, existing_user.level) - return jsonify({"msg": "Username already exists"}) - except DoesNotExist: - api.logger.info("Creating user %s with auth level %s", username, auth_level) - - hashed_password = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()) - user = User(username=username, password=hashed_password, level=auth_level.value) - user.save() - - return jsonify({"id": str(user.id)}), 201 - - -@api.route('/users/', methods=['DELETE']) -@jwt_required() -@auth_level_required(AuthLevel.ADMIN) -def remove_user(user_id): - """ - Delete given user from database - - :param user_id: ID of user to delete - :return: 200 if success, 401 if user does not exist - """ - try: - User.objects.get(id=user_id).delete() - except DoesNotExist: - api.logger.info("Attempt to delete nonexistent user %s by %s", user_id, get_jwt_identity()) - return {"msg": "User does not exist"}, 401 - Flight.objects(user=user_id).delete() - return '', 200 - - -@api.route('/users', methods=["GET"]) -@jwt_required() -@auth_level_required(AuthLevel.ADMIN) -def get_users(): - """ - Get a list of all users - - :return: List of users in the database - """ - users = User.objects.to_json() - return users, 200 - - -@api.route('/login', methods=["POST"]) -def create_token(): - """ - Log in as given user and return JWT for API access - - :return: 401 if username or password invalid, else JWT - """ - body = request.get_json() - try: - username = body["username"] - password = body["password"] - except KeyError: - return jsonify({"msg": "Missing username or password"}) - - try: - user = User.objects.get(username=username) - except DoesNotExist: - return jsonify({"msg": "Invalid username or password"}), 401 - else: - if bcrypt.checkpw(password.encode('utf-8'), user.password.encode('utf-8')): - access_token = create_access_token(identity=username) - api.logger.info("%s successfully logged in", username) - response = {"access_token": access_token} - return jsonify(response), 200 - api.logger.info("Failed login attempt from %s", request.remote_addr) - return jsonify({"msg": "Invalid username or password"}), 401 - - -@api.route('/logout', methods=["POST"]) -def logout(): - """ - Log out given user. Note that JWTs cannot be natively revoked so this must also be handled by the frontend - - :return: Message with JWT removed from headers - """ - response = jsonify({"msg": "logout successful"}) - unset_jwt_cookies(response) - return response - - -@api.route('/profile/', methods=["GET"]) -@jwt_required() -@auth_level_required(AuthLevel.ADMIN) -def get_user_profile(user_id): - """ - Get profile of the given user - - :param user_id: ID of the requested user - :return: 401 is user does not exist, else username and auth level - """ - try: - user = User.objects.get(id=user_id) - except DoesNotExist: - api.logger.warning("User %s not found", get_jwt_identity()) - return {"msg": "User not found"}, 401 - return jsonify({"username": user.username, "auth_level:": str(user.level)}), 200 - - -@api.route('/profile/', methods=["PUT"]) -@jwt_required() -@auth_level_required(AuthLevel.ADMIN) -def update_user_profile(user_id): - """ - Update the profile of the given user - :param user_id: ID of the user to update - :return: Error messages if request is invalid, else 200 - """ - try: - user = User.objects.get(id=user_id) - except DoesNotExist: - api.logger.warning("User %s not found", get_jwt_identity()) - return jsonify({"msg": "User not found"}), 401 - - body = request.get_json() - return update_profile(user.id, body["username"], body["password"], body["auth_level"]) - - -@api.route('/profile', methods=["GET"]) -@jwt_required() -def get_profile(): - """ - Return basic user information for the currently logged-in user - - :return: 401 if user not found, else username and auth level - """ - try: - user = User.objects.get(username=get_jwt_identity()) - except DoesNotExist: - api.logger.warning("User %s not found", get_jwt_identity()) - return jsonify({"msg": "User not found"}), 401 - return jsonify({"username": user.username, "auth_level:": str(user.level)}), 200 - - -@api.route('/profile', methods=["PUT"]) -@jwt_required() -def update_profile(): - """ - Update the profile of the currently logged-in user - - :return: Error messages if request is invalid, else 200 - """ - try: - user = User.objects.get(username=get_jwt_identity()) - except DoesNotExist: - api.logger.warning("User %s not found", get_jwt_identity()) - return {"msg": "user not found"}, 401 - body = request.get_json() - - return update_profile(user.id, body["username"], body["password"], body["auth_level"]) - - -@api.route('/flights', methods=['GET']) -@jwt_required() -def get_flights(): - """ - Get a list of the flights logged by the currently logged-in user - - :return: List of flights - """ - try: - user = User.objects.get(username=get_jwt_identity()) - except DoesNotExist: - api.logger.warning("User %s not found", get_jwt_identity()) - return {"msg": "user not found"}, 401 - flights = Flight.objects(user=user.id).to_json() - return flights, 200 - - -@api.route('/flights/all', methods=['GET']) -@jwt_required() -@auth_level_required(AuthLevel.ADMIN) -def get_all_flights(): - """ - Get a list of all flights logged by any user - - :return: List of flights - """ - flights = Flight.objects.to_json() - return flights, 200 - - -@api.route('/flights/', methods=['GET']) -@jwt_required() -def get_flight(flight_id): - """ - Get all details of a given flight - - :param flight_id: ID of requested flight - :return: Flight details - """ - try: - user = User.objects.get(username=get_jwt_identity()) - except DoesNotExist: - api.logger.warning("User %s not found", get_jwt_identity()) - return {"msg": "user not found"}, 401 - - flight = Flight.objects(id=flight_id).to_json() - if flight.user != user.id and AuthLevel(user.level) != AuthLevel.ADMIN: - api.logger.warning("Attempted access to unauthorized flight by %s", user.username) - return {"msg": "Unauthorized access"}, 403 - return flight, 200 - - -@api.route('/flights', methods=['POST']) -@jwt_required() -def add_flight(): - """ - Add a flight logbook entry - - :return: Error message if request invalid, else ID of newly created log - """ - try: - user = User.objects.get(username=get_jwt_identity()) - except DoesNotExist: - api.logger.warning("User %s not found", get_jwt_identity()) - return {"msg": "user not found"}, 401 - - body = request.get_json() - try: - flight = Flight(user=user, **body).save() - except ValidationError: - return jsonify({"msg": "Invalid request"}) - id = flight.id - return jsonify({'id': str(id)}), 201 - - -@api.route('/flights/', methods=['PUT']) -@jwt_required() -def update_flight(flight_id): - """ - Update the given flight with new information - - :param flight_id: ID of flight to update - :return: Error messages if user not found or access unauthorized, else 200 - """ - try: - user = User.objects.get(username=get_jwt_identity()) - except DoesNotExist: - api.logger.warning("User %s not found", get_jwt_identity()) - return {"msg": "user not found"}, 401 - - flight = Flight.objects(id=flight_id) - - if flight.user != user and AuthLevel(user.level) != AuthLevel.ADMIN: - api.logger.warning("Attempted access to unauthorized flight by %s", user.username) - return {"msg": "Unauthorized access"}, 403 - - body = request.get_json() - flight.update(**body) - - return '', 200 - - -@api.route('/flights/', methods=['DELETE']) -def delete_flight(flight_id): - """ - Delete the given flight - - :param flight_id: ID of flight to delete - :return: Error messages if user not found or access unauthorized, else 200 - """ - try: - user = User.objects.get(username=get_jwt_identity()) - except DoesNotExist: - api.logger.warning("User %s not found", get_jwt_identity()) - return {"msg": "user not found"}, 401 - - flight = Flight.objects(id=flight_id) - - if flight.user != user and AuthLevel(user.level) != AuthLevel.ADMIN: - api.logger.warning("Attempted access to unauthorized flight by %s", user.username) - return {"msg": "Unauthorized access"}, 403 - - flight.delete() - - return '', 200 if __name__ == '__main__': - # Create default admin user if no admin users found - if User.objects(level=AuthLevel.ADMIN.value).count() == 0: - api.logger.info("No admin users exist. Creating default admin user...") - try: - admin_username = os.environ["TAILFIN_ADMIN_USERNAME"] - api.logger.info("Setting admin username to 'TAILFIN_ADMIN_USERNAME': %s", admin_username) - except KeyError: - admin_username = "admin" - api.logger.info("'TAILFIN_ADMIN_USERNAME' not set, using default username 'admin'") - try: - admin_password = os.environ["TAILFIN_ADMIN_PASSWORD"] - api.logger.info("Setting admin password to 'TAILFIN_ADMIN_PASSWORD'") - except KeyError: - admin_password = "admin" - api.logger.warning("'TAILFIN_ADMIN_PASSWORD' not set, using default password 'admin'\n" - "Change this as soon as possible") - hashed_password = bcrypt.hashpw(admin_password.encode('utf-8'), bcrypt.gensalt()) - User(username=admin_username, password=hashed_password, level=AuthLevel.ADMIN.value).save() - api.logger.info("Default admin user created with username %s", User.objects.get(level=AuthLevel.ADMIN).username) + # Create default admin user if it doesn't exist + create_admin_user() # Start the app api.run() diff --git a/api/database/utils.py b/api/database/utils.py index 6faead9..bacd6cc 100644 --- a/api/database/utils.py +++ b/api/database/utils.py @@ -1,5 +1,5 @@ import bcrypt -from flask import jsonify +from flask import jsonify, current_app from mongoengine import DoesNotExist from database.models import User, AuthLevel @@ -28,6 +28,7 @@ def update_profile(user_id, username=None, password=None, auth_level=None): hashed_password = bcrypt.hashpw(password.encode('UTF-8'), bcrypt.gensalt()) if auth_level: if AuthLevel(user.level) < AuthLevel.ADMIN: + current_app.logger.warning("Unauthorized attempt by %s to change auth level", user.username) return jsonify({"msg": "Unauthorized attempt to change auth level"}), 403 if username: diff --git a/api/routes/flights.py b/api/routes/flights.py new file mode 100644 index 0000000..01752e5 --- /dev/null +++ b/api/routes/flights.py @@ -0,0 +1,136 @@ +from flask import Blueprint, current_app, request, jsonify +from mongoengine import DoesNotExist, ValidationError + +from flask_jwt_extended import get_jwt_identity, jwt_required + +from database.models import User, Flight, AuthLevel +from routes.utils import auth_level_required + +flights_api = Blueprint('flights_api', __name__) + + +@flights_api.route('/flights', methods=['GET']) +@jwt_required() +def get_flights(): + """ + Get a list of the flights logged by the currently logged-in user + + :return: List of flights + """ + try: + user = User.objects.get(username=get_jwt_identity()) + except DoesNotExist: + current_app.logger.warning("User %s not found", get_jwt_identity()) + return {"msg": "user not found"}, 401 + flights = Flight.objects(user=user.id).to_json() + return flights, 200 + + +@flights_api.route('/flights/all', methods=['GET']) +@jwt_required() +@auth_level_required(AuthLevel.ADMIN) +def get_all_flights(): + """ + Get a list of all flights logged by any user + + :return: List of flights + """ + flights = Flight.objects.to_json() + return flights, 200 + + +@flights_api.route('/flights/', methods=['GET']) +@jwt_required() +def get_flight(flight_id): + """ + Get all details of a given flight + + :param flight_id: ID of requested flight + :return: Flight details + """ + try: + user = User.objects.get(username=get_jwt_identity()) + except DoesNotExist: + current_app.logger.warning("User %s not found", get_jwt_identity()) + return {"msg": "user not found"}, 401 + + flight = Flight.objects(id=flight_id).to_json() + if flight.user != user.id and AuthLevel(user.level) != AuthLevel.ADMIN: + current_app.logger.warning("Attempted access to unauthorized flight by %s", user.username) + return {"msg": "Unauthorized access"}, 403 + return flight, 200 + + +@flights_api.route('/flights', methods=['POST']) +@jwt_required() +def add_flight(): + """ + Add a flight logbook entry + + :return: Error message if request invalid, else ID of newly created log + """ + try: + user = User.objects.get(username=get_jwt_identity()) + except DoesNotExist: + current_app.logger.warning("User %s not found", get_jwt_identity()) + return {"msg": "user not found"}, 401 + + body = request.get_json() + try: + flight = Flight(user=user, **body).save() + except ValidationError: + return jsonify({"msg": "Invalid request"}) + id = flight.id + return jsonify({'id': str(id)}), 201 + + +@flights_api.route('/flights/', methods=['PUT']) +@jwt_required() +def update_flight(flight_id): + """ + Update the given flight with new information + + :param flight_id: ID of flight to update + :return: Error messages if user not found or access unauthorized, else 200 + """ + try: + user = User.objects.get(username=get_jwt_identity()) + except DoesNotExist: + current_app.logger.warning("User %s not found", get_jwt_identity()) + return {"msg": "user not found"}, 401 + + flight = Flight.objects(id=flight_id) + + if flight.user != user and AuthLevel(user.level) != AuthLevel.ADMIN: + current_app.logger.warning("Attempted access to unauthorized flight by %s", user.username) + return {"msg": "Unauthorized access"}, 403 + + body = request.get_json() + flight.update(**body) + + return '', 200 + + +@flights_api.route('/flights/', methods=['DELETE']) +def delete_flight(flight_id): + """ + Delete the given flight + + :param flight_id: ID of flight to delete + :return: Error messages if user not found or access unauthorized, else 200 + """ + try: + user = User.objects.get(username=get_jwt_identity()) + except DoesNotExist: + current_app.logger.warning("User %s not found", get_jwt_identity()) + return {"msg": "user not found"}, 401 + + flight = Flight.objects(id=flight_id) + + if flight.user != user and AuthLevel(user.level) != AuthLevel.ADMIN: + current_app.logger.warning("Attempted access to unauthorized flight by %s", user.username) + return {"msg": "Unauthorized access"}, 403 + + flight.delete() + + return '', 200 diff --git a/api/routes/users.py b/api/routes/users.py new file mode 100644 index 0000000..f9d7cd2 --- /dev/null +++ b/api/routes/users.py @@ -0,0 +1,191 @@ +import bcrypt +from flask import Blueprint, request, jsonify, current_app + +from flask_jwt_extended import create_access_token, get_jwt, get_jwt_identity, unset_jwt_cookies, jwt_required, \ + JWTManager +from mongoengine import DoesNotExist, ValidationError + +from database.models import AuthLevel, User, Flight +from routes.utils import auth_level_required + +users_api = Blueprint('users_api', __name__) + + +@users_api.route('/users', methods=["POST"]) +@jwt_required() +@auth_level_required(AuthLevel.ADMIN) +def add_user(): + """ + Add user to database. + + :return: Failure message if user already exists, otherwise ID of newly created user + """ + body = request.get_json() + try: + username = body["username"] + password = body["password"] + except KeyError: + return jsonify({"msg": "Missing username or password"}) + try: + auth_level = AuthLevel(body["auth_level"]) + except KeyError: + auth_level = AuthLevel.USER + + try: + existing_user = User.objects.get(username=username) + current_app.logger.info("User %s already exists at auth level %s", existing_user.username, existing_user.level) + return jsonify({"msg": "Username already exists"}) + except DoesNotExist: + current_app.logger.info("Creating user %s with auth level %s", username, auth_level) + + hashed_password = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()) + user = User(username=username, password=hashed_password, level=auth_level.value) + try: + user.save() + except ValidationError: + return jsonify({"msg": "Invalid request"}) + + return jsonify({"id": str(user.id)}), 201 + + +@users_api.route('/users/', methods=['DELETE']) +@jwt_required() +@auth_level_required(AuthLevel.ADMIN) +def remove_user(user_id): + """ + Delete given user from database + + :param user_id: ID of user to delete + :return: 200 if success, 401 if user does not exist + """ + try: + User.objects.get(id=user_id).delete() + except DoesNotExist: + current_app.logger.info("Attempt to delete nonexistent user %s by %s", user_id, get_jwt_identity()) + return {"msg": "User does not exist"}, 401 + Flight.objects(user=user_id).delete() + return '', 200 + + +@users_api.route('/users', methods=["GET"]) +@jwt_required() +@auth_level_required(AuthLevel.ADMIN) +def get_users(): + """ + Get a list of all users + + :return: List of users in the database + """ + users = User.objects.to_json() + return users, 200 + + +@users_api.route('/login', methods=["POST"]) +def create_token(): + """ + Log in as given user and return JWT for API access + + :return: 401 if username or password invalid, else JWT + """ + body = request.get_json() + try: + username = body["username"] + password = body["password"] + except KeyError: + return jsonify({"msg": "Missing username or password"}) + + try: + user = User.objects.get(username=username) + except DoesNotExist: + return jsonify({"msg": "Invalid username or password"}), 401 + else: + if bcrypt.checkpw(password.encode('utf-8'), user.password.encode('utf-8')): + access_token = create_access_token(identity=username) + current_app.logger.info("%s successfully logged in", username) + response = {"access_token": access_token} + return jsonify(response), 200 + current_app.logger.info("Failed login attempt from %s", request.remote_addr) + return jsonify({"msg": "Invalid username or password"}), 401 + + +@users_api.route('/logout', methods=["POST"]) +def logout(): + """ + Log out given user. Note that JWTs cannot be natively revoked so this must also be handled by the frontend + + :return: Message with JWT removed from headers + """ + response = jsonify({"msg": "logout successful"}) + unset_jwt_cookies(response) + return response + + +@users_api.route('/profile/', methods=["GET"]) +@jwt_required() +@auth_level_required(AuthLevel.ADMIN) +def get_user_profile(user_id): + """ + Get profile of the given user + + :param user_id: ID of the requested user + :return: 401 is user does not exist, else username and auth level + """ + try: + user = User.objects.get(id=user_id) + except DoesNotExist: + current_app.logger.warning("User %s not found", get_jwt_identity()) + return {"msg": "User not found"}, 401 + return jsonify({"username": user.username, "auth_level:": str(user.level)}), 200 + + +@users_api.route('/profile/', methods=["PUT"]) +@jwt_required() +@auth_level_required(AuthLevel.ADMIN) +def update_user_profile(user_id): + """ + Update the profile of the given user + :param user_id: ID of the user to update + :return: Error messages if request is invalid, else 200 + """ + try: + user = User.objects.get(id=user_id) + except DoesNotExist: + current_app.logger.warning("User %s not found", get_jwt_identity()) + return jsonify({"msg": "User not found"}), 401 + + body = request.get_json() + return update_profile(user.id, body["username"], body["password"], body["auth_level"]) + + +@users_api.route('/profile', methods=["GET"]) +@jwt_required() +def get_profile(): + """ + Return basic user information for the currently logged-in user + + :return: 401 if user not found, else username and auth level + """ + try: + user = User.objects.get(username=get_jwt_identity()) + except DoesNotExist: + current_app.logger.warning("User %s not found", get_jwt_identity()) + return jsonify({"msg": "User not found"}), 401 + return jsonify({"username": user.username, "auth_level:": str(user.level)}), 200 + + +@users_api.route('/profile', methods=["PUT"]) +@jwt_required() +def update_profile(): + """ + Update the profile of the currently logged-in user + + :return: Error messages if request is invalid, else 200 + """ + try: + user = User.objects.get(username=get_jwt_identity()) + except DoesNotExist: + current_app.logger.warning("User %s not found", get_jwt_identity()) + return {"msg": "user not found"}, 401 + body = request.get_json() + + return update_profile(user.id, body["username"], body["password"], body["auth_level"]) diff --git a/api/routes/utils.py b/api/routes/utils.py new file mode 100644 index 0000000..fff9ed9 --- /dev/null +++ b/api/routes/utils.py @@ -0,0 +1,57 @@ +import os + +import bcrypt +from flask import current_app + +from flask_jwt_extended import get_jwt_identity + +from database.models import AuthLevel, User + + +def auth_level_required(level: AuthLevel): + """ + Limit access to given authorization level. + + :param level: Required authorization level to access this endpoint + :return: 403 Unauthorized upon auth failure or response of decorated function on auth success + """ + + def auth_inner(func): + def auth_wrapper(*args, **kwargs): + user = User.objects.get(username=get_jwt_identity()) + if AuthLevel(user.level) < level: + current_app.logger.warning("Attempted access to unauthorized resource by %s", user.username) + return '', 403 + else: + return func(*args, **kwargs) + + auth_wrapper.__name__ = func.__name__ + return auth_wrapper + + return auth_inner + + +def create_admin_user(): + """ + Create default admin user if no admin users are present in the database + + :return: None + """ + if User.objects(level=AuthLevel.ADMIN.value).count() == 0: + current_app.logger.info("No admin users exist. Creating default admin user...") + try: + admin_username = os.environ["TAILFIN_ADMIN_USERNAME"] + current_app.logger.info("Setting admin username to 'TAILFIN_ADMIN_USERNAME': %s", admin_username) + except KeyError: + admin_username = "admin" + current_app.logger.info("'TAILFIN_ADMIN_USERNAME' not set, using default username 'admin'") + try: + admin_password = os.environ["TAILFIN_ADMIN_PASSWORD"] + current_app.logger.info("Setting admin password to 'TAILFIN_ADMIN_PASSWORD'") + except KeyError: + admin_password = "admin" + current_app.logger.warning("'TAILFIN_ADMIN_PASSWORD' not set, using default password 'admin'\n" + "Change this as soon as possible") + hashed_password = bcrypt.hashpw(admin_password.encode('utf-8'), bcrypt.gensalt()) + User(username=admin_username, password=hashed_password, level=AuthLevel.ADMIN.value).save() + current_app.logger.info("Default admin user created with username %s", User.objects.get(level=AuthLevel.ADMIN).username) From f8ecc028c79b3f835dc3957d25be7c792cfb7c74 Mon Sep 17 00:00:00 2001 From: april Date: Wed, 20 Dec 2023 09:51:50 -0600 Subject: [PATCH 07/66] Start move to FastAPI --- api/.env | 2 + api/app.py | 82 +++++++--------- api/database/models.py | 30 +++--- api/database/utils.py | 216 ++++++++++++++++++++++++++++++++++++++--- api/models.py | 81 ++++++++++++++++ api/requirements.txt | 5 +- api/routes/flights.py | 93 ++++++++++-------- api/routes/users.py | 149 ++++++++++++++-------------- api/routes/utils.py | 30 ------ 9 files changed, 469 insertions(+), 219 deletions(-) create mode 100644 api/.env create mode 100644 api/models.py diff --git a/api/.env b/api/.env new file mode 100644 index 0000000..2ccf26b --- /dev/null +++ b/api/.env @@ -0,0 +1,2 @@ +DB_URI=localhost +DB_NAME=tailfin \ No newline at end of file diff --git a/api/app.py b/api/app.py index 26aa3ec..acd4548 100644 --- a/api/app.py +++ b/api/app.py @@ -2,66 +2,58 @@ import json import os from datetime import timedelta, datetime, timezone -from flask import Flask +import uvicorn + +from fastapi import FastAPI from mongoengine import connect from flask_jwt_extended import create_access_token, get_jwt, get_jwt_identity, JWTManager -from routes.flights import flights_api -from routes.users import users_api -from routes.utils import create_admin_user +from database.utils import create_admin_user # Initialize Flask app -api = Flask(__name__) - -# Register route blueprints -api.register_blueprint(users_api) -api.register_blueprint(flights_api) +app = FastAPI() # Set JWT key from environment variable -try: - api.config["JWT_SECRET_KEY"] = os.environ["TAILFIN_DB_KEY"] -except KeyError: - api.logger.error("Please set 'TAILFIN_DB_KEY' environment variable") - exit(1) +# try: +# app.config["JWT_SECRET_KEY"] = os.environ["TAILFIN_JWT_KEY"] +# except KeyError: +# app.logger.error("Please set 'TAILFIN_JWT_KEY' environment variable") +# exit(1) # Set JWT keys to expire after 1 hour -api.config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(hours=1) +# app.config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(hours=1) # Initialize JWT manager -jwt = JWTManager(api) +# jwt = JWTManager(app) # Connect to MongoDB connect('tailfin') - -@api.after_request -def refresh_expiring_jwts(response): - """ - Refresh/reissue JWTs that are near expiry following each request containing a JWT - - :param response: Response given by previous request - :return: Original response with refreshed JWT - """ - try: - exp_timestamp = get_jwt()["exp"] - now = datetime.now(timezone.utc) - target_timestamp = datetime.timestamp(now + timedelta(minutes=30)) - if target_timestamp > exp_timestamp: - api.logger.info("Refreshing expiring JWT") - access_token = create_access_token(identity=get_jwt_identity()) - data = response.get_json() - if type(data) is dict: - data["access_token"] = access_token - response.data = json.dumps(data) - return response - except (RuntimeError, KeyError): - # No valid JWT, return original response - api.logger.info("No valid JWT, cannot refresh expiry") - return response - - - +# @app.after_request +# def refresh_expiring_jwts(response): +# """ +# Refresh/reissue JWTs that are near expiry following each request containing a JWT +# +# :param response: Response given by previous request +# :return: Original response with refreshed JWT +# """ +# try: +# exp_timestamp = get_jwt()["exp"] +# now = datetime.now(timezone.utc) +# target_timestamp = datetime.timestamp(now + timedelta(minutes=30)) +# if target_timestamp > exp_timestamp: +# app.logger.info("Refreshing expiring JWT") +# access_token = create_access_token(identity=get_jwt_identity()) +# data = response.get_json() +# if type(data) is dict: +# data["access_token"] = access_token +# response.data = json.dumps(data) +# return response +# except (RuntimeError, KeyError): +# # No valid JWT, return original response +# app.logger.info("No valid JWT, cannot refresh expiry") +# return response if __name__ == '__main__': @@ -69,4 +61,4 @@ if __name__ == '__main__': create_admin_user() # Start the app - api.run() + uvicorn.run("fastapi_code:app", reload=True) diff --git a/api/database/models.py b/api/database/models.py index 5140ca9..ded9474 100644 --- a/api/database/models.py +++ b/api/database/models.py @@ -56,25 +56,25 @@ class Flight(Document): time_pic = DecimalField(default=0) time_sic = DecimalField(default=0) time_night = DecimalField(default=0) - time_solo = DecimalField() + time_solo = DecimalField(default=0) - time_xc = DecimalField() - dist_xc = DecimalField() + time_xc = DecimalField(default=0) + dist_xc = DecimalField(default=0) - takeoffs_day = IntField() - landings_day = IntField() - takeoffs_night = IntField() - landings_night = IntField() - landings_all = IntField() + takeoffs_day = IntField(default=0) + landings_day = IntField(default=0) + takeoffs_night = IntField(default=0) + landings_night = IntField(default=0) + landings_all = IntField(default=0) - time_instrument = DecimalField() - time_sim_instrument = DecimalField() - holds_instrument = DecimalField() + time_instrument = DecimalField(default=0) + time_sim_instrument = DecimalField(default=0) + holds_instrument = DecimalField(default=0) - dual_given = DecimalField() - dual_recvd = DecimalField() - time_sim = DecimalField() - time_ground = DecimalField() + dual_given = DecimalField(default=0) + dual_recvd = DecimalField(default=0) + time_sim = DecimalField(default=0) + time_ground = DecimalField(default=0) tags = ListField(StringField()) diff --git a/api/database/utils.py b/api/database/utils.py index bacd6cc..92f5e97 100644 --- a/api/database/utils.py +++ b/api/database/utils.py @@ -1,11 +1,18 @@ +import logging +import os +from datetime import datetime +from functools import reduce + import bcrypt -from flask import jsonify, current_app -from mongoengine import DoesNotExist +from fastapi import HTTPException +from mongoengine import DoesNotExist, Q -from database.models import User, AuthLevel +from database.models import User, AuthLevel, Flight + +logger = logging.getLogger("utils") -def update_profile(user_id, username=None, password=None, auth_level=None): +def update_profile(user_id: str, username: str = None, password: str = None, auth_level: AuthLevel = None): """ Update the profile of the given user @@ -23,19 +30,206 @@ def update_profile(user_id, username=None, password=None, auth_level=None): if username: existing_users = User.objects(username=username).count() if existing_users != 0: - return jsonify({"msg": "Username not available"}) - if password: - hashed_password = bcrypt.hashpw(password.encode('UTF-8'), bcrypt.gensalt()) + return {"msg": "Username not available"} if auth_level: if AuthLevel(user.level) < AuthLevel.ADMIN: - current_app.logger.warning("Unauthorized attempt by %s to change auth level", user.username) - return jsonify({"msg": "Unauthorized attempt to change auth level"}), 403 + logger.info("Unauthorized attempt by %s to change auth level", user.username) + raise HTTPException(403, "Unauthorized attempt to change auth level") if username: user.update_one(username=username) if password: - user.update_one(password=password) + hashed_password = bcrypt.hashpw(password.encode('UTF-8'), bcrypt.gensalt()) + user.update_one(password=hashed_password) if auth_level: user.update_one(level=auth_level) - return '', 200 + +def create_admin_user(): + """ + Create default admin user if no admin users are present in the database + + :return: None + """ + if User.objects(level=AuthLevel.ADMIN.value).count() == 0: + logger.info("No admin users exist. Creating default admin user...") + try: + admin_username = os.environ["TAILFIN_ADMIN_USERNAME"] + logger.info("Setting admin username to 'TAILFIN_ADMIN_USERNAME': %s", admin_username) + except KeyError: + admin_username = "admin" + logger.info("'TAILFIN_ADMIN_USERNAME' not set, using default username 'admin'") + try: + admin_password = os.environ["TAILFIN_ADMIN_PASSWORD"] + logger.info("Setting admin password to 'TAILFIN_ADMIN_PASSWORD'") + except KeyError: + admin_password = "admin" + logger.warning("'TAILFIN_ADMIN_PASSWORD' not set, using default password 'admin'\n" + "Change this as soon as possible") + hashed_password = bcrypt.hashpw(admin_password.encode('utf-8'), bcrypt.gensalt()) + User(username=admin_username, password=hashed_password, level=AuthLevel.ADMIN.value).save() + logger.info("Default admin user created with username %s", + User.objects.get(level=AuthLevel.ADMIN).username) + + +def get_flight_list(sort: str = None, filters: list[list[dict]] = None, limit: int = None, offset: int = None): + def prepare_condition(condition): + field = [condition['field'], condition['operator']] + field = (s for s in field if s) + field = '__'.join(field) + return {field: condition['value']} + + def prepare_conditions(row): + return (Q(**prepare_condition(condition)) for condition in row) + + def join_conditions(row): + return reduce(lambda a, b: a | b, prepare_conditions(row)) + + def join_rows(rows): + return reduce(lambda a, b: a & b, rows) + + if sort is None: + sort = "+date" + + query = join_rows(join_conditions(row) for row in filters) + + if query == Q(): + flights = Flight.objects.all() + else: + if limit is None: + flights = Flight.objects(query).order_by(sort) + else: + flights = Flight.objects(query).order_by(sort)[offset:limit] + + return flights + + +def get_flight_list(sort: str = "date", order: str = "desc", limit: int = None, offset: int = None, user: str = None, + date_eq: str = None, date_lt: str = None, date_gt: str = None, aircraft: str = None, + pic: bool = None, sic: bool = None, night: bool = None, solo: bool = None, xc: bool = None, + xc_dist_gt: float = None, xc_dist_lt: float = None, xc_dist_eq: float = None, + instrument: bool = None, + sim_instrument: bool = None, dual_given: bool = None, + dual_recvd: bool = None, sim: bool = None, ground: bool = None, pax: list[str] = None, + crew: list[str] = None, tags: list[str] = None): + """ + Get an optionally filtered and sorted list of logged flights + + :param sort: Parameter to sort flights by + :param order: Order of sorting; "asc" or "desc" + :param limit: Pagination limit + :param offset: Pagination offset + :param user: Filter by user + :param date_eq: Filter by date + :param date_lt: Get flights before this date + :param date_gt: Get flights after this date + :param aircraft: Filter by aircraft + :param pic: Only include PIC time + :param sic: Only include SIC time + :param night: Only include night time + :param solo: Only include solo time + :param xc: Only include XC time + :param xc_dist_gt: Only include flights with XC distance greater than this + :param xc_dist_lt: Only include flights with XC distance less than this + :param xc_dist_eq: Only include flights with XC distance equal to this + :param instrument: Only include instrument time + :param sim_instrument: Only include sim instrument time + :param dual_given: Only include dual given time + :param dual_recvd: Only include dual received time + :param sim: Only include sim time + :param ground: Only include ground time + :param pax: Filter by passengers + :param crew: Filter by crew + :param tags: Filter by tags + :return: Filtered and sorted list of flights + """ + sort_str = ("-" if order == "desc" else "+") + sort + + query = Q() + if user: + query &= Q(user=user) + if date_eq: + fmt_date_eq = datetime.strptime(date_eq, "%Y-%m-%d") + query &= Q(date=fmt_date_eq) + if date_lt: + fmt_date_lt = datetime.strptime(date_lt, "%Y-%m-%d") + query &= Q(date__lt=fmt_date_lt) + if date_gt: + fmt_date_gt = datetime.strptime(date_gt, "%Y-%m-%d") + query &= Q(date__gt=fmt_date_gt) + if aircraft: + query &= Q(aircraft=aircraft) + if pic is not None: + if pic: + query &= Q(time_pic__gt=0) + else: + query &= Q(time_pic__eq=0) + if sic is not None: + if sic: + query &= Q(time_sic__gt=0) + else: + query &= Q(time_sic__eq=0) + if night is not None: + if night: + query &= Q(time_night__gt=0) + else: + query &= Q(time_night__eq=0) + if solo is not None: + if solo: + query &= Q(time_solo__gt=0) + else: + query &= Q(time_solo__eq=0) + if xc is not None: + if xc: + query &= Q(time_xc__gt=0) + else: + query &= Q(time_xc__eq=0) + if xc_dist_gt: + query &= Q(dist_xc__gt=xc_dist_gt) + if xc_dist_lt: + query &= Q(dist_xc__lt=xc_dist_lt) + if xc_dist_eq: + query &= Q(dist_xc__eq=xc_dist_eq) + if instrument is not None: + if instrument: + query &= Q(time_instrument__gt=0) + else: + query &= Q(time_instrument__eq=0) + if sim_instrument is not None: + if sim_instrument: + query &= Q(time_sim_instrument__gt=0) + else: + query &= Q(time_sim_instrument__eq=0) + if dual_given is not None: + if dual_given: + query &= Q(dual_given__gt=0) + else: + query &= Q(dual_given__eq=0) + if dual_recvd is not None: + if dual_recvd: + query &= Q(dual_recvd__gt=0) + else: + query &= Q(dual_recvd__eq=0) + if sim is not None: + if sim: + query &= Q(time_sim__gt=0) + else: + query &= Q(time_sim__eq=0) + if ground is not None: + if ground: + query &= Q(time_ground__gt=0) + else: + query &= Q(time_ground__eq=0) + if pax: + query &= Q(pax=pax) + if crew: + query &= Q(crew=crew) + if tags: + query &= Q(tags=tags) + + if query == Q(): + flights = Flight.objects.all().order_by(sort_str)[offset:limit] + else: + flights = Flight.objects(query).order_by(sort_str)[offset:limit] + + return flights diff --git a/api/models.py b/api/models.py new file mode 100644 index 0000000..74f5593 --- /dev/null +++ b/api/models.py @@ -0,0 +1,81 @@ +import datetime +from enum import Enum + +from pydantic import BaseModel + + +class FlightModel(BaseModel): + user: str + + date: datetime.date + aircraft: str = "" + waypoint_from: str = "" + waypoint_to: str = "" + route: str = "" + + hobbs_start: float | None = None + hobbs_end: float | None = None + tach_start: float | None = None + tach_end: float | None = None + + time_start: datetime.datetime | None = None + time_end: datetime.datetime | None = None + time_down: datetime.datetime | None = None + time_stop: datetime.datetime | None = None + + time_total: float = 0. + time_pic: float = 0. + time_sic: float = 0. + time_night: float = 0. + time_solo: float = 0. + + time_xc: float = 0. + dist_xc: float = 0. + + takeoffs_day: int = 0 + landings_day: int = 0 + takeoffs_night: int = 0 + landings_all: int = 0 + + time_instrument: float = 0 + time_sim_instrument: float = 0 + holds_instrument: float = 0 + + dual_given: float = 0 + dual_recvd: float = 0 + time_sim: float = 0 + time_ground: float = 0 + + tags: list[str] = [] + + pax: list[str] = [] + crew: list[str] = [] + + comments: str = "" + + +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 UserModel(BaseModel): + username: str + password: str + level: AuthLevel | None = None diff --git a/api/requirements.txt b/api/requirements.txt index 72f413f..eed1fa9 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -1,3 +1,6 @@ bcrypt~=4.1.2 flask~=3.0.0 -mongoengine~=0.27.0 \ No newline at end of file +mongoengine~=0.27.0 +uvicorn~=0.24.0.post1 +fastapi~=0.105.0 +pydantic~=2.5.2 \ No newline at end of file diff --git a/api/routes/flights.py b/api/routes/flights.py index 01752e5..9abf712 100644 --- a/api/routes/flights.py +++ b/api/routes/flights.py @@ -1,15 +1,23 @@ -from flask import Blueprint, current_app, request, jsonify +import logging + +from fastapi import APIRouter, HTTPException + +from models import FlightModel + from mongoengine import DoesNotExist, ValidationError from flask_jwt_extended import get_jwt_identity, jwt_required from database.models import User, Flight, AuthLevel +from database.utils import get_flight_list from routes.utils import auth_level_required -flights_api = Blueprint('flights_api', __name__) +router = APIRouter() + +logger = logging.getLogger("flights") -@flights_api.route('/flights', methods=['GET']) +@router.get('/flights') @jwt_required() def get_flights(): """ @@ -20,13 +28,14 @@ def get_flights(): try: user = User.objects.get(username=get_jwt_identity()) except DoesNotExist: - current_app.logger.warning("User %s not found", get_jwt_identity()) + logger.warning("User %s not found", get_jwt_identity()) return {"msg": "user not found"}, 401 - flights = Flight.objects(user=user.id).to_json() + + flights = get_flight_list(filters=[[{"field": "user", "operator": "eq", "value": user.id}]]).to_json() return flights, 200 -@flights_api.route('/flights/all', methods=['GET']) +@router.get('/flights/all') @jwt_required() @auth_level_required(AuthLevel.ADMIN) def get_all_flights(): @@ -35,13 +44,14 @@ def get_all_flights(): :return: List of flights """ - flights = Flight.objects.to_json() + logger.debug("Get all flights - user: %s", get_jwt_identity()) + flights = get_flight_list().to_json() return flights, 200 -@flights_api.route('/flights/', methods=['GET']) +@router.get('/flights/{flight_id}', response_model=FlightModel) @jwt_required() -def get_flight(flight_id): +def get_flight(flight_id: str): """ Get all details of a given flight @@ -51,19 +61,20 @@ def get_flight(flight_id): try: user = User.objects.get(username=get_jwt_identity()) except DoesNotExist: - current_app.logger.warning("User %s not found", get_jwt_identity()) - return {"msg": "user not found"}, 401 + logger.warning("User %s not found", get_jwt_identity()) + raise HTTPException(401, "User not found") flight = Flight.objects(id=flight_id).to_json() if flight.user != user.id and AuthLevel(user.level) != AuthLevel.ADMIN: - current_app.logger.warning("Attempted access to unauthorized flight by %s", user.username) - return {"msg": "Unauthorized access"}, 403 - return flight, 200 + logger.info("Attempted access to unauthorized flight by %s", user.username) + raise HTTPException(403, "Unauthorized access") + + return flight -@flights_api.route('/flights', methods=['POST']) +@router.post('/flights') @jwt_required() -def add_flight(): +def add_flight(flight_body: FlightModel): """ Add a flight logbook entry @@ -72,64 +83,64 @@ def add_flight(): try: user = User.objects.get(username=get_jwt_identity()) except DoesNotExist: - current_app.logger.warning("User %s not found", get_jwt_identity()) - return {"msg": "user not found"}, 401 + logger.warning("User %s not found", get_jwt_identity()) + raise HTTPException(401, "User not found") - body = request.get_json() try: - flight = Flight(user=user, **body).save() - except ValidationError: - return jsonify({"msg": "Invalid request"}) - id = flight.id - return jsonify({'id': str(id)}), 201 + flight = Flight(user=user.id, **flight_body.model_dump()).save() + except ValidationError as e: + logger.info("Invalid flight body: %s", e) + raise HTTPException(400, "Invalid request") + + return {"id": flight.id} -@flights_api.route('/flights/', methods=['PUT']) +@router.put('/flights/{flight_id}', status_code=201, response_model=FlightModel) @jwt_required() -def update_flight(flight_id): +def update_flight(flight_id: str, flight_body: FlightModel): """ Update the given flight with new information :param flight_id: ID of flight to update - :return: Error messages if user not found or access unauthorized, else 200 + :param flight_body: New flight information to update with + :return: Updated flight """ try: user = User.objects.get(username=get_jwt_identity()) except DoesNotExist: - current_app.logger.warning("User %s not found", get_jwt_identity()) - return {"msg": "user not found"}, 401 + logger.warning("User %s not found", get_jwt_identity()) + raise HTTPException(status_code=401, detail="user not found") flight = Flight.objects(id=flight_id) if flight.user != user and AuthLevel(user.level) != AuthLevel.ADMIN: - current_app.logger.warning("Attempted access to unauthorized flight by %s", user.username) - return {"msg": "Unauthorized access"}, 403 + logger.info("Attempted access to unauthorized flight by %s", user.username) + raise HTTPException(403, "Unauthorized access") - body = request.get_json() - flight.update(**body) + flight.update(**flight_body.model_dump()) - return '', 200 + return flight_body -@flights_api.route('/flights/', methods=['DELETE']) -def delete_flight(flight_id): +@router.delete('/flights/{flight_id}', status_code=200) +def delete_flight(flight_id: str): """ Delete the given flight :param flight_id: ID of flight to delete - :return: Error messages if user not found or access unauthorized, else 200 + :return: 200 """ try: user = User.objects.get(username=get_jwt_identity()) except DoesNotExist: - current_app.logger.warning("User %s not found", get_jwt_identity()) - return {"msg": "user not found"}, 401 + logger.warning("User %s not found", get_jwt_identity()) + raise HTTPException(401, "user not found") flight = Flight.objects(id=flight_id) if flight.user != user and AuthLevel(user.level) != AuthLevel.ADMIN: - current_app.logger.warning("Attempted access to unauthorized flight by %s", user.username) - return {"msg": "Unauthorized access"}, 403 + logger.info("Attempted access to unauthorized flight by %s", user.username) + raise HTTPException(403, "Unauthorized access") flight.delete() diff --git a/api/routes/users.py b/api/routes/users.py index f9d7cd2..7a1543f 100644 --- a/api/routes/users.py +++ b/api/routes/users.py @@ -1,73 +1,74 @@ import bcrypt -from flask import Blueprint, request, jsonify, current_app + +import logging +from fastapi import APIRouter, HTTPException from flask_jwt_extended import create_access_token, get_jwt, get_jwt_identity, unset_jwt_cookies, jwt_required, \ JWTManager from mongoengine import DoesNotExist, ValidationError from database.models import AuthLevel, User, Flight +from models import UserModel from routes.utils import auth_level_required -users_api = Blueprint('users_api', __name__) +router = APIRouter() + +logger = logging.getLogger("users") -@users_api.route('/users', methods=["POST"]) +@router.post('/users', status_code=201) @jwt_required() @auth_level_required(AuthLevel.ADMIN) -def add_user(): +def add_user(body: UserModel): """ Add user to database. :return: Failure message if user already exists, otherwise ID of newly created user """ - body = request.get_json() - try: - username = body["username"] - password = body["password"] - except KeyError: - return jsonify({"msg": "Missing username or password"}) - try: - auth_level = AuthLevel(body["auth_level"]) - except KeyError: - auth_level = AuthLevel.USER + + auth_level = body.level if body.level is not None else AuthLevel.USER try: - existing_user = User.objects.get(username=username) - current_app.logger.info("User %s already exists at auth level %s", existing_user.username, existing_user.level) - return jsonify({"msg": "Username already exists"}) + existing_user = User.objects.get(username=body.username) + logger.debug("User %s already exists at auth level %s", existing_user.username, existing_user.level) + return {"msg": "Username already exists"} + except DoesNotExist: - current_app.logger.info("Creating user %s with auth level %s", username, auth_level) + logger.info("Creating user %s with auth level %s", body.username, auth_level) + + hashed_password = bcrypt.hashpw(body.password.encode('utf-8'), bcrypt.gensalt()) + user = User(username=body.username, password=hashed_password, level=auth_level) - hashed_password = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()) - user = User(username=username, password=hashed_password, level=auth_level.value) try: user.save() except ValidationError: - return jsonify({"msg": "Invalid request"}) + raise HTTPException(400, "Invalid request") - return jsonify({"id": str(user.id)}), 201 + return {"id": str(user.id)} -@users_api.route('/users/', methods=['DELETE']) +@router.delete('/users/{user_id}', status_code=200) @jwt_required() @auth_level_required(AuthLevel.ADMIN) -def remove_user(user_id): +def remove_user(user_id: str): """ - Delete given user from database + Delete given user from database along with all flights associated with said user :param user_id: ID of user to delete - :return: 200 if success, 401 if user does not exist + :return: None """ try: + # Delete user from database User.objects.get(id=user_id).delete() except DoesNotExist: - current_app.logger.info("Attempt to delete nonexistent user %s by %s", user_id, get_jwt_identity()) - return {"msg": "User does not exist"}, 401 + logger.info("Attempt to delete nonexistent user %s by %s", user_id, get_jwt_identity()) + raise HTTPException(401, "User does not exist") + + # Delete all flights associated with the user Flight.objects(user=user_id).delete() - return '', 200 -@users_api.route('/users', methods=["GET"]) +@router.get('/users', status_code=200, response_model=list[UserModel]) @jwt_required() @auth_level_required(AuthLevel.ADMIN) def get_users(): @@ -77,115 +78,111 @@ def get_users(): :return: List of users in the database """ users = User.objects.to_json() - return users, 200 + return users -@users_api.route('/login', methods=["POST"]) -def create_token(): +@router.post('/login', status_code=200) +def create_token(body: UserModel): """ - Log in as given user and return JWT for API access + Log in as given user - create associated JWT for API access - :return: 401 if username or password invalid, else JWT + :return: JWT for given user """ - body = request.get_json() - try: - username = body["username"] - password = body["password"] - except KeyError: - return jsonify({"msg": "Missing username or password"}) try: - user = User.objects.get(username=username) + user = User.objects.get(username=body.username) except DoesNotExist: - return jsonify({"msg": "Invalid username or password"}), 401 + raise HTTPException(401, "Invalid username or password") else: - if bcrypt.checkpw(password.encode('utf-8'), user.password.encode('utf-8')): - access_token = create_access_token(identity=username) - current_app.logger.info("%s successfully logged in", username) - response = {"access_token": access_token} - return jsonify(response), 200 - current_app.logger.info("Failed login attempt from %s", request.remote_addr) - return jsonify({"msg": "Invalid username or password"}), 401 + if bcrypt.checkpw(body.password.encode('utf-8'), user.password.encode('utf-8')): + access_token = create_access_token(identity=body.username) + logger.info("%s successfully logged in", body.username) + return {"access_token": access_token} + + logger.info("Failed login attempt for user %s", body.username) + raise HTTPException(401, "Invalid username or password") -@users_api.route('/logout', methods=["POST"]) +@router.post('/logout', status_code=200) def logout(): """ Log out given user. Note that JWTs cannot be natively revoked so this must also be handled by the frontend :return: Message with JWT removed from headers """ - response = jsonify({"msg": "logout successful"}) - unset_jwt_cookies(response) + response = {"msg": "logout successful"} + # unset_jwt_cookies(response) return response -@users_api.route('/profile/', methods=["GET"]) +@router.get('/profile/{user_id}', status_code=200) @jwt_required() @auth_level_required(AuthLevel.ADMIN) -def get_user_profile(user_id): +def get_user_profile(user_id: str): """ Get profile of the given user :param user_id: ID of the requested user - :return: 401 is user does not exist, else username and auth level + :return: Username and auth level of the requested user """ try: user = User.objects.get(id=user_id) except DoesNotExist: - current_app.logger.warning("User %s not found", get_jwt_identity()) - return {"msg": "User not found"}, 401 - return jsonify({"username": user.username, "auth_level:": str(user.level)}), 200 + logger.warning("User %s not found", get_jwt_identity()) + raise HTTPException(401, "User not found") + + return {"username": user.username, "auth_level:": str(user.level)} -@users_api.route('/profile/', methods=["PUT"]) +@router.put('/profile/{user_id}', status_code=200) @jwt_required() @auth_level_required(AuthLevel.ADMIN) -def update_user_profile(user_id): +def update_user_profile(user_id: str, body: UserModel): """ 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 """ try: user = User.objects.get(id=user_id) except DoesNotExist: - current_app.logger.warning("User %s not found", get_jwt_identity()) - return jsonify({"msg": "User not found"}), 401 + logger.warning("User %s not found", get_jwt_identity()) + raise HTTPException(401, "User not found") - body = request.get_json() - return update_profile(user.id, body["username"], body["password"], body["auth_level"]) + return update_profile(user.id, body.username, body.password, body.level) -@users_api.route('/profile', methods=["GET"]) +@router.get('/profile', status_code=200) @jwt_required() def get_profile(): """ Return basic user information for the currently logged-in user - :return: 401 if user not found, else username and auth level + :return: Username and auth level of current user """ try: user = User.objects.get(username=get_jwt_identity()) except DoesNotExist: - current_app.logger.warning("User %s not found", get_jwt_identity()) - return jsonify({"msg": "User not found"}), 401 - return jsonify({"username": user.username, "auth_level:": str(user.level)}), 200 + logger.warning("User %s not found", get_jwt_identity()) + raise HTTPException(401, "User not found") + + return {"username": user.username, "auth_level:": str(user.level)} -@users_api.route('/profile', methods=["PUT"]) +@router.put('/profile') @jwt_required() -def update_profile(): +def update_profile(body: UserModel): """ Update the profile of the currently logged-in user - :return: Error messages if request is invalid, else 200 + :param body: New information to insert + :return: None """ try: user = User.objects.get(username=get_jwt_identity()) except DoesNotExist: - current_app.logger.warning("User %s not found", get_jwt_identity()) - return {"msg": "user not found"}, 401 - body = request.get_json() + logger.warning("User %s not found", get_jwt_identity()) + raise HTTPException(401, "User not found") return update_profile(user.id, body["username"], body["password"], body["auth_level"]) diff --git a/api/routes/utils.py b/api/routes/utils.py index fff9ed9..3675332 100644 --- a/api/routes/utils.py +++ b/api/routes/utils.py @@ -1,8 +1,4 @@ -import os - -import bcrypt from flask import current_app - from flask_jwt_extended import get_jwt_identity from database.models import AuthLevel, User @@ -29,29 +25,3 @@ def auth_level_required(level: AuthLevel): return auth_wrapper return auth_inner - - -def create_admin_user(): - """ - Create default admin user if no admin users are present in the database - - :return: None - """ - if User.objects(level=AuthLevel.ADMIN.value).count() == 0: - current_app.logger.info("No admin users exist. Creating default admin user...") - try: - admin_username = os.environ["TAILFIN_ADMIN_USERNAME"] - current_app.logger.info("Setting admin username to 'TAILFIN_ADMIN_USERNAME': %s", admin_username) - except KeyError: - admin_username = "admin" - current_app.logger.info("'TAILFIN_ADMIN_USERNAME' not set, using default username 'admin'") - try: - admin_password = os.environ["TAILFIN_ADMIN_PASSWORD"] - current_app.logger.info("Setting admin password to 'TAILFIN_ADMIN_PASSWORD'") - except KeyError: - admin_password = "admin" - current_app.logger.warning("'TAILFIN_ADMIN_PASSWORD' not set, using default password 'admin'\n" - "Change this as soon as possible") - hashed_password = bcrypt.hashpw(admin_password.encode('utf-8'), bcrypt.gensalt()) - User(username=admin_username, password=hashed_password, level=AuthLevel.ADMIN.value).save() - current_app.logger.info("Default admin user created with username %s", User.objects.get(level=AuthLevel.ADMIN).username) From d791e6f062552d10c5a2cde07f8ef1019df10e25 Mon Sep 17 00:00:00 2001 From: april Date: Wed, 20 Dec 2023 16:11:02 -0600 Subject: [PATCH 08/66] Migrate to FastAPI JWT auth --- api/.env | 14 ++- api/app.py | 61 +---------- api/app/__init__.py | 0 api/app/api.py | 40 ++++++++ api/app/config.py | 25 +++++ api/app/deps.py | 73 +++++++++++++ api/database/models.py | 27 +---- api/database/utils.py | 18 ++-- api/requirements.txt | 6 +- api/routes/flights.py | 92 ++++++----------- api/routes/users.py | 186 ++++++++++++++++------------------ api/routes/utils.py | 27 ----- api/{models.py => schemas.py} | 40 +++++++- api/utils.py | 41 ++++++++ 14 files changed, 369 insertions(+), 281 deletions(-) create mode 100644 api/app/__init__.py create mode 100644 api/app/api.py create mode 100644 api/app/config.py create mode 100644 api/app/deps.py delete mode 100644 api/routes/utils.py rename api/{models.py => schemas.py} (70%) create mode 100644 api/utils.py diff --git a/api/.env b/api/.env index 2ccf26b..1f2d01d 100644 --- a/api/.env +++ b/api/.env @@ -1,2 +1,12 @@ -DB_URI=localhost -DB_NAME=tailfin \ No newline at end of file +#DB_URI=localhost +#DB_NAME=tailfin +DB_USER="tailfin-api" +DB_PWD="tailfin-api-password" + +# 60 * 24 * 7 -> 7 days +#JWT_REFRESH_TOKEN_EXPIRE_MINUTES=10080 +#JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30 + +#JWT_ALGORITHM="HS256" +JWT_SECRET_KEY="please-change-me" +JWT_REFRESH_SECRET_KEY="change-me-i-beg-of-you" diff --git a/api/app.py b/api/app.py index acd4548..8610eb1 100644 --- a/api/app.py +++ b/api/app.py @@ -1,64 +1,5 @@ -import json -import os -from datetime import timedelta, datetime, timezone - import uvicorn -from fastapi import FastAPI - -from mongoengine import connect -from flask_jwt_extended import create_access_token, get_jwt, get_jwt_identity, JWTManager - -from database.utils import create_admin_user - -# Initialize Flask app -app = FastAPI() - -# Set JWT key from environment variable -# try: -# app.config["JWT_SECRET_KEY"] = os.environ["TAILFIN_JWT_KEY"] -# except KeyError: -# app.logger.error("Please set 'TAILFIN_JWT_KEY' environment variable") -# exit(1) - -# Set JWT keys to expire after 1 hour -# app.config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(hours=1) - -# Initialize JWT manager -# jwt = JWTManager(app) - -# Connect to MongoDB -connect('tailfin') - -# @app.after_request -# def refresh_expiring_jwts(response): -# """ -# Refresh/reissue JWTs that are near expiry following each request containing a JWT -# -# :param response: Response given by previous request -# :return: Original response with refreshed JWT -# """ -# try: -# exp_timestamp = get_jwt()["exp"] -# now = datetime.now(timezone.utc) -# target_timestamp = datetime.timestamp(now + timedelta(minutes=30)) -# if target_timestamp > exp_timestamp: -# app.logger.info("Refreshing expiring JWT") -# access_token = create_access_token(identity=get_jwt_identity()) -# data = response.get_json() -# if type(data) is dict: -# data["access_token"] = access_token -# response.data = json.dumps(data) -# return response -# except (RuntimeError, KeyError): -# # No valid JWT, return original response -# app.logger.info("No valid JWT, cannot refresh expiry") -# return response - - if __name__ == '__main__': - # Create default admin user if it doesn't exist - create_admin_user() - # Start the app - uvicorn.run("fastapi_code:app", reload=True) + uvicorn.run("app.api:app", host="0.0.0.0", port=8081, reload=True) diff --git a/api/app/__init__.py b/api/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/app/api.py b/api/app/api.py new file mode 100644 index 0000000..a5f84ee --- /dev/null +++ b/api/app/api.py @@ -0,0 +1,40 @@ +import logging +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Request + +from mongoengine import connect + +from app.config import get_settings +from database.utils import create_admin_user +from routes import users, flights + +logger = logging.getLogger("api") + +logging.basicConfig(format='%(asctime)s - %(levelname)s: %(message)s', level=logging.DEBUG) + + +async def connect_to_db(): + # Connect to MongoDB + settings = get_settings() + try: + connected = connect(settings.db_name, host=settings.db_uri, username=settings.db_user, + password=settings.db_pwd, authentication_source=settings.db_name) + if connected: + logging.info("Connected to database %s", settings.db_name) + # Create default admin user if it doesn't exist + create_admin_user() + except ConnectionError: + logger.error("Failed to connect to MongoDB") + raise ConnectionError + + +# Initialize FastAPI +app = FastAPI() +app.include_router(users.router) +app.include_router(flights.router) + + +@app.on_event("startup") +async def startup(): + await connect_to_db() diff --git a/api/app/config.py b/api/app/config.py new file mode 100644 index 0000000..f4d322b --- /dev/null +++ b/api/app/config.py @@ -0,0 +1,25 @@ +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_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" + + +@lru_cache +def get_settings(): + return Settings() diff --git a/api/app/deps.py b/api/app/deps.py new file mode 100644 index 0000000..02e1568 --- /dev/null +++ b/api/app/deps.py @@ -0,0 +1,73 @@ +from datetime import datetime +from typing import Annotated + +from fastapi import Depends, HTTPException +from fastapi.security import OAuth2PasswordBearer +from jose import jwt +from mongoengine import DoesNotExist +from pydantic import ValidationError + +from app.config import get_settings, Settings +from database.models import User, TokenBlacklist +from schemas import GetSystemUserSchema, TokenPayload, AuthLevel + +reusable_oath = OAuth2PasswordBearer( + tokenUrl="/login", + scheme_name="JWT" +) + + +async def get_current_user(settings: Annotated[Settings, Depends(get_settings)], + token: str = Depends(reusable_oath)) -> GetSystemUserSchema: + 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(403, "Could not validate credentials", {"WWW-Authenticate": "Bearer"}) + + try: + TokenBlacklist.objects.get(token=token) + raise HTTPException(403, "Token expired", {"WWW-Authenticate": "Bearer"}) + except DoesNotExist: + try: + user = User.objects.get(id=token_data.sub) + except DoesNotExist: + raise HTTPException(404, "Could not find user") + + return GetSystemUserSchema(id=str(user.id), username=user.username, level=user.level, password=user.password) + + +async def get_current_user_token(settings: Annotated[Settings, Depends(get_settings)], + token: str = Depends(reusable_oath)) -> (GetSystemUserSchema, str): + 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(403, "Could not validate credentials", {"WWW-Authenticate": "Bearer"}) + + try: + TokenBlacklist.objects.get(token=token) + raise HTTPException(403, "Token expired", {"WWW-Authenticate": "Bearer"}) + except DoesNotExist: + try: + user = User.objects.get(id=token_data.sub) + except DoesNotExist: + raise HTTPException(404, "Could not find user") + + return GetSystemUserSchema(id=str(user.id), username=user.username, level=user.level, + password=user.password), token + + +async def admin_required(user: Annotated[GetSystemUserSchema, Depends(get_current_user)]): + if user.level < AuthLevel.ADMIN: + raise HTTPException(403, "Access unauthorized") diff --git a/api/database/models.py b/api/database/models.py index ded9474..6cd6fa3 100644 --- a/api/database/models.py +++ b/api/database/models.py @@ -1,27 +1,6 @@ -from enum import Enum - from mongoengine import * - -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 +from schemas import AuthLevel class User(Document): @@ -33,6 +12,10 @@ class User(Document): # level = EnumField(AuthLevel, default=AuthLevel.USER) +class TokenBlacklist(Document): + token = StringField(required=True) + + class Flight(Document): user = ObjectIdField(required=True) diff --git a/api/database/utils.py b/api/database/utils.py index 92f5e97..4ca85f4 100644 --- a/api/database/utils.py +++ b/api/database/utils.py @@ -8,11 +8,13 @@ from fastapi import HTTPException from mongoengine import DoesNotExist, Q from database.models import User, AuthLevel, Flight +from schemas import GetUserSchema logger = logging.getLogger("utils") -def update_profile(user_id: str, username: str = None, password: str = None, auth_level: AuthLevel = None): +async def edit_profile(user_id: str, username: str = None, password: str = None, + auth_level: AuthLevel = None) -> GetUserSchema: """ Update the profile of the given user @@ -25,24 +27,26 @@ def update_profile(user_id: str, username: str = None, password: str = None, aut try: user = User.objects.get(id=user_id) except DoesNotExist: - return {"msg": "user not found"}, 401 + raise HTTPException(404, "User not found") if username: existing_users = User.objects(username=username).count() if existing_users != 0: - return {"msg": "Username not available"} + raise HTTPException(400, "Username not available") if auth_level: - if AuthLevel(user.level) < AuthLevel.ADMIN: + 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.update_one(username=username) + user.update(username=username) if password: hashed_password = bcrypt.hashpw(password.encode('UTF-8'), bcrypt.gensalt()) - user.update_one(password=hashed_password) + user.update(password=hashed_password) if auth_level: - user.update_one(level=auth_level) + user.update(level=auth_level) + + return GetUserSchema(id=str(user.id), username=user.username, level=user.level) def create_admin_user(): diff --git a/api/requirements.txt b/api/requirements.txt index eed1fa9..ac444a8 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -1,6 +1,6 @@ -bcrypt~=4.1.2 -flask~=3.0.0 +bcrypt==4.0.1 mongoengine~=0.27.0 uvicorn~=0.24.0.post1 fastapi~=0.105.0 -pydantic~=2.5.2 \ No newline at end of file +pydantic~=2.5.2 +passlib~=1.7.4 \ No newline at end of file diff --git a/api/routes/flights.py b/api/routes/flights.py index 9abf712..f749425 100644 --- a/api/routes/flights.py +++ b/api/routes/flights.py @@ -1,69 +1,57 @@ import logging -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Depends -from models import FlightModel +from app.deps import get_current_user, admin_required +from schemas import FlightModel, GetSystemUserSchema -from mongoengine import DoesNotExist, ValidationError +from mongoengine import ValidationError -from flask_jwt_extended import get_jwt_identity, jwt_required - -from database.models import User, Flight, AuthLevel +from database.models import Flight, AuthLevel from database.utils import get_flight_list -from routes.utils import auth_level_required router = APIRouter() logger = logging.getLogger("flights") -@router.get('/flights') -@jwt_required() -def get_flights(): +@router.get('/flights', summary="Get flights logged by the currently logged-in user", status_code=200) +async def get_flights(user: GetSystemUserSchema = Depends(get_current_user)) -> list[FlightModel]: """ Get a list of the flights logged by the currently logged-in user :return: List of flights """ - try: - user = User.objects.get(username=get_jwt_identity()) - except DoesNotExist: - logger.warning("User %s not found", get_jwt_identity()) - return {"msg": "user not found"}, 401 - - flights = get_flight_list(filters=[[{"field": "user", "operator": "eq", "value": user.id}]]).to_json() - return flights, 200 + # l = get_flight_list(filters=[[{"field": "user", "operator": "eq", "value": user.id}]]) + l = get_flight_list(user=str(user.id)) + flights = [] + for f in l: + flights.append(FlightModel(**f.to_mongo())) + return [f.to_mongo() for f in flights] -@router.get('/flights/all') -@jwt_required() -@auth_level_required(AuthLevel.ADMIN) -def get_all_flights(): +@router.get('/flights/all', summary="Get all flights logged by all users", status_code=200, + dependencies=[Depends(admin_required)]) +def get_all_flights() -> list[FlightModel]: """ Get a list of all flights logged by any user :return: List of flights """ - logger.debug("Get all flights - user: %s", get_jwt_identity()) - flights = get_flight_list().to_json() - return flights, 200 + flights = [FlightModel(**f.to_mongo()) for f in get_flight_list()] + return flights -@router.get('/flights/{flight_id}', response_model=FlightModel) -@jwt_required() -def get_flight(flight_id: str): +@router.get('/flights/{flight_id}', summary="Get details of a given flight", response_model=FlightModel, + status_code=200) +def get_flight(flight_id: str, user: GetSystemUserSchema = Depends(get_current_user)): """ Get all details of a given flight :param flight_id: ID of requested flight + :param user: Currently logged-in user :return: Flight details """ - try: - user = User.objects.get(username=get_jwt_identity()) - except DoesNotExist: - logger.warning("User %s not found", get_jwt_identity()) - raise HTTPException(401, "User not found") - flight = Flight.objects(id=flight_id).to_json() if flight.user != user.id and AuthLevel(user.level) != AuthLevel.ADMIN: logger.info("Attempted access to unauthorized flight by %s", user.username) @@ -72,20 +60,14 @@ def get_flight(flight_id: str): return flight -@router.post('/flights') -@jwt_required() -def add_flight(flight_body: FlightModel): +@router.post('/flights', summary="Add a flight logbook entry", status_code=200) +def add_flight(flight_body: FlightModel, user: GetSystemUserSchema = Depends(get_current_user)): """ Add a flight logbook entry + :param user: Currently logged-in user :return: Error message if request invalid, else ID of newly created log """ - try: - user = User.objects.get(username=get_jwt_identity()) - except DoesNotExist: - logger.warning("User %s not found", get_jwt_identity()) - raise HTTPException(401, "User not found") - try: flight = Flight(user=user.id, **flight_body.model_dump()).save() except ValidationError as e: @@ -95,22 +77,17 @@ def add_flight(flight_body: FlightModel): return {"id": flight.id} -@router.put('/flights/{flight_id}', status_code=201, response_model=FlightModel) -@jwt_required() -def update_flight(flight_id: str, flight_body: FlightModel): +@router.put('/flights/{flight_id}', summary="Update the given flight with new information", status_code=201, + response_model=FlightModel) +def update_flight(flight_id: str, flight_body: FlightModel, user: GetSystemUserSchema = Depends(get_current_user)): """ 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: Updated flight """ - try: - user = User.objects.get(username=get_jwt_identity()) - except DoesNotExist: - logger.warning("User %s not found", get_jwt_identity()) - raise HTTPException(status_code=401, detail="user not found") - flight = Flight.objects(id=flight_id) if flight.user != user and AuthLevel(user.level) != AuthLevel.ADMIN: @@ -122,20 +99,15 @@ def update_flight(flight_id: str, flight_body: FlightModel): return flight_body -@router.delete('/flights/{flight_id}', status_code=200) -def delete_flight(flight_id: str): +@router.delete('/flights/{flight_id}', summary="Delete the given flight", status_code=200) +def delete_flight(flight_id: str, user: GetSystemUserSchema = Depends(get_current_user)): """ Delete the given flight :param flight_id: ID of flight to delete + :param user: Currently logged-in user :return: 200 """ - try: - user = User.objects.get(username=get_jwt_identity()) - except DoesNotExist: - logger.warning("User %s not found", get_jwt_identity()) - raise HTTPException(401, "user not found") - flight = Flight.objects(id=flight_id) if flight.user != user and AuthLevel(user.level) != AuthLevel.ADMIN: diff --git a/api/routes/users.py b/api/routes/users.py index 7a1543f..19e3be7 100644 --- a/api/routes/users.py +++ b/api/routes/users.py @@ -1,43 +1,43 @@ -import bcrypt +from typing import Annotated import logging -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Depends +from fastapi.security import OAuth2PasswordRequestForm -from flask_jwt_extended import create_access_token, get_jwt, get_jwt_identity, unset_jwt_cookies, jwt_required, \ - JWTManager from mongoengine import DoesNotExist, ValidationError -from database.models import AuthLevel, User, Flight -from models import UserModel -from routes.utils import auth_level_required +from app.deps import get_current_user, admin_required, reusable_oath, get_current_user_token +from app.config import Settings, get_settings +from database.models import AuthLevel, User, Flight, TokenBlacklist +from schemas import CreateUserSchema, TokenSchema, GetSystemUserSchema, GetUserSchema, UpdateUserSchema +from utils import get_hashed_password, verify_password, create_access_token, create_refresh_token +from database.utils import edit_profile router = APIRouter() logger = logging.getLogger("users") -@router.post('/users', status_code=201) -@jwt_required() -@auth_level_required(AuthLevel.ADMIN) -def add_user(body: UserModel): +@router.post('/users', summary="Add user to database", status_code=201, dependencies=[Depends(admin_required)]) +async def add_user(body: CreateUserSchema) -> dict: """ Add user to database. - :return: Failure message if user already exists, otherwise ID of newly created user + :return: ID of newly created user """ auth_level = body.level if body.level is not None else AuthLevel.USER try: existing_user = User.objects.get(username=body.username) - logger.debug("User %s already exists at auth level %s", existing_user.username, existing_user.level) - return {"msg": "Username already exists"} + logger.info("User %s already exists at auth level %s", existing_user.username, existing_user.level) + raise HTTPException(400, "Username already exists") except DoesNotExist: logger.info("Creating user %s with auth level %s", body.username, auth_level) - hashed_password = bcrypt.hashpw(body.password.encode('utf-8'), bcrypt.gensalt()) - user = User(username=body.username, password=hashed_password, level=auth_level) + hashed_password = get_hashed_password(body.password) + user = User(username=body.username, password=hashed_password, level=auth_level.value) try: user.save() @@ -47,10 +47,9 @@ def add_user(body: UserModel): return {"id": str(user.id)} -@router.delete('/users/{user_id}', status_code=200) -@jwt_required() -@auth_level_required(AuthLevel.ADMIN) -def remove_user(user_id: str): +@router.delete('/users/{user_id}', summary="Delete given user and all associated flights", status_code=200, + dependencies=[Depends(admin_required)]) +async def remove_user(user_id: str) -> None: """ Delete given user from database along with all flights associated with said user @@ -61,28 +60,31 @@ def remove_user(user_id: str): # Delete user from database User.objects.get(id=user_id).delete() except DoesNotExist: - logger.info("Attempt to delete nonexistent user %s by %s", user_id, get_jwt_identity()) + logger.info("Attempt to delete nonexistent user %s", user_id) raise HTTPException(401, "User does not exist") + except ValidationError: + logger.debug("Invalid user delete request") + raise HTTPException(400, "Invalid user") # Delete all flights associated with the user Flight.objects(user=user_id).delete() -@router.get('/users', status_code=200, response_model=list[UserModel]) -@jwt_required() -@auth_level_required(AuthLevel.ADMIN) -def get_users(): +@router.get('/users', summary="Get a list of all users", status_code=200, response_model=list[GetUserSchema], + dependencies=[Depends(admin_required)]) +async def get_users() -> list[GetUserSchema]: """ Get a list of all users :return: List of users in the database """ - users = User.objects.to_json() - return users + users = User.objects.all() + return [GetUserSchema(id=str(u.id), username=u.username, level=u.level) for u in users] -@router.post('/login', status_code=200) -def create_token(body: UserModel): +@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 @@ -90,35 +92,53 @@ def create_token(body: UserModel): """ try: - user = User.objects.get(username=body.username) + user = User.objects.get(username=form_data.username) + hashed_pass = user.password + if not verify_password(form_data.password, hashed_pass): + raise HTTPException(401, "Invalid username or password") + return TokenSchema( + access_token=create_access_token(settings, str(user.id)), + refresh_token=create_refresh_token(settings, str(user.id)) + ) except DoesNotExist: raise HTTPException(401, "Invalid username or password") - else: - if bcrypt.checkpw(body.password.encode('utf-8'), user.password.encode('utf-8')): - access_token = create_access_token(identity=body.username) - logger.info("%s successfully logged in", body.username) - return {"access_token": access_token} - - logger.info("Failed login attempt for user %s", body.username) - raise HTTPException(401, "Invalid username or password") -@router.post('/logout', status_code=200) -def logout(): +@router.post('/logout', summary="Invalidate current user's token", status_code=200) +async def logout(user_token: (GetSystemUserSchema, TokenSchema) = Depends(get_current_user_token)) -> dict: """ - Log out given user. Note that JWTs cannot be natively revoked so this must also be handled by the frontend + Log out given user by adding JWT to a blacklist database - :return: Message with JWT removed from headers + :return: Logout message """ - response = {"msg": "logout successful"} - # unset_jwt_cookies(response) - return response + user, token = user_token + print(token) + try: + TokenBlacklist(token=str(token)).save() + except ValidationError: + logger.debug("Failed to add token to blacklist") + + return {"msg": "Logout successful"} -@router.get('/profile/{user_id}', status_code=200) -@jwt_required() -@auth_level_required(AuthLevel.ADMIN) -def get_user_profile(user_id: str): +# @router.post('/refresh', summary="Refresh JWT token", status_code=200) +# async def refresh(form: OAuth2RefreshRequestForm = Depends()): +# if request.method == 'POST': +# form = await request.json() + + +@router.get('/profile', status_code=200, response_model=GetUserSchema) +async def get_profile(user: GetSystemUserSchema = Depends(get_current_user)) -> GetUserSchema: + """ + Return basic user information for the currently logged-in user + + :return: Username and auth level of current user + """ + return user + + +@router.get('/profile/{user_id}', status_code=200, dependencies=[Depends(admin_required)], response_model=GetUserSchema) +async def get_user_profile(user_id: str) -> GetUserSchema: """ Get profile of the given user @@ -128,61 +148,33 @@ def get_user_profile(user_id: str): try: user = User.objects.get(id=user_id) except DoesNotExist: - logger.warning("User %s not found", get_jwt_identity()) - raise HTTPException(401, "User not found") + logger.warning("User %s not found", user_id) + raise HTTPException(404, "User not found") - return {"username": user.username, "auth_level:": str(user.level)} + return GetUserSchema(id=str(user.id), username=user.username, level=user.level) -@router.put('/profile/{user_id}', status_code=200) -@jwt_required() -@auth_level_required(AuthLevel.ADMIN) -def update_user_profile(user_id: str, body: UserModel): +@router.put('/profile', summary="Update the profile of the currently logged-in user", response_model=GetUserSchema) +async def update_profile(body: UpdateUserSchema, + user: GetSystemUserSchema = Depends(get_current_user)) -> GetUserSchema: + """ + Update the profile of the currently logged-in user + + :param body: New information to insert + :param user: Currently logged-in user + :return: None + """ + return await edit_profile(user.id, body.username, body.password, body.level) + + +@router.put('/profile/{user_id}', summary="Update profile of the given user", status_code=200, + dependencies=[Depends(admin_required)], response_model=GetUserSchema) +async def update_user_profile(user_id: str, body: UpdateUserSchema) -> GetUserSchema: """ 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 """ - try: - user = User.objects.get(id=user_id) - except DoesNotExist: - logger.warning("User %s not found", get_jwt_identity()) - raise HTTPException(401, "User not found") - return update_profile(user.id, body.username, body.password, body.level) - - -@router.get('/profile', status_code=200) -@jwt_required() -def get_profile(): - """ - Return basic user information for the currently logged-in user - - :return: Username and auth level of current user - """ - try: - user = User.objects.get(username=get_jwt_identity()) - except DoesNotExist: - logger.warning("User %s not found", get_jwt_identity()) - raise HTTPException(401, "User not found") - - return {"username": user.username, "auth_level:": str(user.level)} - - -@router.put('/profile') -@jwt_required() -def update_profile(body: UserModel): - """ - Update the profile of the currently logged-in user - - :param body: New information to insert - :return: None - """ - try: - user = User.objects.get(username=get_jwt_identity()) - except DoesNotExist: - logger.warning("User %s not found", get_jwt_identity()) - raise HTTPException(401, "User not found") - - return update_profile(user.id, body["username"], body["password"], body["auth_level"]) + return await edit_profile(user_id, body.username, body.password, body.level) diff --git a/api/routes/utils.py b/api/routes/utils.py deleted file mode 100644 index 3675332..0000000 --- a/api/routes/utils.py +++ /dev/null @@ -1,27 +0,0 @@ -from flask import current_app -from flask_jwt_extended import get_jwt_identity - -from database.models import AuthLevel, User - - -def auth_level_required(level: AuthLevel): - """ - Limit access to given authorization level. - - :param level: Required authorization level to access this endpoint - :return: 403 Unauthorized upon auth failure or response of decorated function on auth success - """ - - def auth_inner(func): - def auth_wrapper(*args, **kwargs): - user = User.objects.get(username=get_jwt_identity()) - if AuthLevel(user.level) < level: - current_app.logger.warning("Attempted access to unauthorized resource by %s", user.username) - return '', 403 - else: - return func(*args, **kwargs) - - auth_wrapper.__name__ = func.__name__ - return auth_wrapper - - return auth_inner diff --git a/api/models.py b/api/schemas.py similarity index 70% rename from api/models.py rename to api/schemas.py index 74f5593..2ae2566 100644 --- a/api/models.py +++ b/api/schemas.py @@ -1,11 +1,14 @@ import datetime from enum import Enum +from typing import Annotated -from pydantic import BaseModel +from pydantic import BaseModel, BeforeValidator + +ObjectId = Annotated[str, BeforeValidator(str)] class FlightModel(BaseModel): - user: str + user: ObjectId date: datetime.date aircraft: str = "" @@ -75,7 +78,38 @@ class AuthLevel(Enum): return NotImplemented -class UserModel(BaseModel): +class LoginUserSchema(BaseModel): username: str password: str + + +class CreateUserSchema(BaseModel): + username: str + password: str + level: AuthLevel = AuthLevel.USER + + +class UpdateUserSchema(BaseModel): + username: str | None = None + password: str | None = None level: AuthLevel | None = None + + +class GetUserSchema(BaseModel): + id: str + username: str + level: AuthLevel = AuthLevel.USER + + +class GetSystemUserSchema(GetUserSchema): + password: str + + +class TokenSchema(BaseModel): + access_token: str + refresh_token: str + + +class TokenPayload(BaseModel): + sub: str = None + exp: int = None diff --git a/api/utils.py b/api/utils.py new file mode 100644 index 0000000..f52f8e2 --- /dev/null +++ b/api/utils.py @@ -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 From 7520cb3a2791375e205cf9cd7c3e62ad153ca649 Mon Sep 17 00:00:00 2001 From: april Date: Thu, 28 Dec 2023 16:31:52 -0600 Subject: [PATCH 09/66] Migrate to motor for DB interaction --- api/app/api.py | 39 ++--- api/app/config.py | 4 + api/app/deps.py | 45 +++--- api/database/__init__.py | 0 api/database/db.py | 27 ++++ api/database/flights.py | 88 +++++++++++ api/database/models.py | 69 --------- api/database/tokens.py | 25 ++++ api/database/users.py | 134 +++++++++++++++++ api/database/utils.py | 297 ++++++++++---------------------------- api/requirements.txt | 6 +- api/routes/__init__.py | 0 api/routes/auth.py | 64 ++++++++ api/routes/flights.py | 69 ++++----- api/routes/users.py | 147 ++++++------------- api/{ => routes}/utils.py | 0 api/schemas.py | 115 --------------- api/schemas/__init__.py | 0 api/schemas/flight.py | 103 +++++++++++++ api/schemas/user.py | 99 +++++++++++++ 20 files changed, 739 insertions(+), 592 deletions(-) create mode 100644 api/database/__init__.py create mode 100644 api/database/db.py create mode 100644 api/database/flights.py delete mode 100644 api/database/models.py create mode 100644 api/database/tokens.py create mode 100644 api/database/users.py create mode 100644 api/routes/__init__.py create mode 100644 api/routes/auth.py rename api/{ => routes}/utils.py (100%) delete mode 100644 api/schemas.py create mode 100644 api/schemas/__init__.py create mode 100644 api/schemas/flight.py create mode 100644 api/schemas/user.py diff --git a/api/app/api.py b/api/app/api.py index a5f84ee..61ee57f 100644 --- a/api/app/api.py +++ b/api/app/api.py @@ -1,40 +1,29 @@ import logging +import sys from contextlib import asynccontextmanager -from fastapi import FastAPI, Request +from fastapi import FastAPI -from mongoengine import connect - -from app.config import get_settings from database.utils import create_admin_user -from routes import users, flights +from routes import users, flights, auth logger = logging.getLogger("api") logging.basicConfig(format='%(asctime)s - %(levelname)s: %(message)s', level=logging.DEBUG) +handler = logging.StreamHandler(sys.stdout) +logger.addHandler(handler) -async def connect_to_db(): - # Connect to MongoDB - settings = get_settings() - try: - connected = connect(settings.db_name, host=settings.db_uri, username=settings.db_user, - password=settings.db_pwd, authentication_source=settings.db_name) - if connected: - logging.info("Connected to database %s", settings.db_name) - # Create default admin user if it doesn't exist - create_admin_user() - except ConnectionError: - logger.error("Failed to connect to MongoDB") - raise ConnectionError +@asynccontextmanager +async def lifespan(app: FastAPI): + await create_admin_user() + yield # Initialize FastAPI -app = FastAPI() -app.include_router(users.router) -app.include_router(flights.router) +app = FastAPI(lifespan=lifespan) - -@app.on_event("startup") -async def startup(): - await connect_to_db() +# Add subroutes +app.include_router(users.router, tags=["Users"], prefix="/users") +app.include_router(flights.router, tags=["Flights"], prefix="/flights") +app.include_router(auth.router, tags=["Auth"], prefix="/auth") diff --git a/api/app/config.py b/api/app/config.py index f4d322b..5b64c60 100644 --- a/api/app/config.py +++ b/api/app/config.py @@ -7,6 +7,7 @@ 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 @@ -19,6 +20,9 @@ class Settings(BaseSettings): 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" + @lru_cache def get_settings(): diff --git a/api/app/deps.py b/api/app/deps.py index 02e1568..6ed732f 100644 --- a/api/app/deps.py +++ b/api/app/deps.py @@ -4,21 +4,21 @@ from typing import Annotated from fastapi import Depends, HTTPException from fastapi.security import OAuth2PasswordBearer from jose import jwt -from mongoengine import DoesNotExist from pydantic import ValidationError from app.config import get_settings, Settings -from database.models import User, TokenBlacklist -from schemas import GetSystemUserSchema, TokenPayload, AuthLevel +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 reusable_oath = OAuth2PasswordBearer( - tokenUrl="/login", + tokenUrl="/auth/login", scheme_name="JWT" ) async def get_current_user(settings: Annotated[Settings, Depends(get_settings)], - token: str = Depends(reusable_oath)) -> GetSystemUserSchema: + token: str = Depends(reusable_oath)) -> UserDisplaySchema: try: payload = jwt.decode( token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm] @@ -30,20 +30,19 @@ async def get_current_user(settings: Annotated[Settings, Depends(get_settings)], except (jwt.JWTError, ValidationError): raise HTTPException(403, "Could not validate credentials", {"WWW-Authenticate": "Bearer"}) - try: - TokenBlacklist.objects.get(token=token) + blacklisted = await is_blacklisted(token) + if blacklisted: raise HTTPException(403, "Token expired", {"WWW-Authenticate": "Bearer"}) - except DoesNotExist: - try: - user = User.objects.get(id=token_data.sub) - except DoesNotExist: - raise HTTPException(404, "Could not find user") - return GetSystemUserSchema(id=str(user.id), username=user.username, level=user.level, password=user.password) + 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)) -> (GetSystemUserSchema, str): + token: str = Depends(reusable_oath)) -> (UserDisplaySchema, str): try: payload = jwt.decode( token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm] @@ -55,19 +54,17 @@ async def get_current_user_token(settings: Annotated[Settings, Depends(get_setti except (jwt.JWTError, ValidationError): raise HTTPException(403, "Could not validate credentials", {"WWW-Authenticate": "Bearer"}) - try: - TokenBlacklist.objects.get(token=token) + blacklisted = await is_blacklisted(token) + if blacklisted: raise HTTPException(403, "Token expired", {"WWW-Authenticate": "Bearer"}) - except DoesNotExist: - try: - user = User.objects.get(id=token_data.sub) - except DoesNotExist: - raise HTTPException(404, "Could not find user") - return GetSystemUserSchema(id=str(user.id), username=user.username, level=user.level, - password=user.password), token + user = await get_user_system_info(id=token_data.sub) + if user is None: + raise HTTPException(404, "Could not find user") + + return user -async def admin_required(user: Annotated[GetSystemUserSchema, Depends(get_current_user)]): +async def admin_required(user: Annotated[UserDisplaySchema, Depends(get_current_user)]): if user.level < AuthLevel.ADMIN: raise HTTPException(403, "Access unauthorized") diff --git a/api/database/__init__.py b/api/database/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/database/db.py b/api/database/db.py new file mode 100644 index 0000000..bed56f5 --- /dev/null +++ b/api/database/db.py @@ -0,0 +1,27 @@ +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"] +token_collection = db_client["token_blacklist"] diff --git a/api/database/flights.py b/api/database/flights.py new file mode 100644 index 0000000..24e9e00 --- /dev/null +++ b/api/database/flights.py @@ -0,0 +1,88 @@ +import logging + +from bson import ObjectId +from fastapi import HTTPException + +from database.utils import flight_display_helper, flight_add_helper +from .db import flight_collection +from schemas.flight import FlightConciseSchema, FlightDisplaySchema, FlightCreateSchema + +logger = logging.getLogger("api") + + +async def retrieve_flights(user: str = "") -> list[FlightConciseSchema]: + """ + Retrieve a list of flights, optionally filtered by user + + :param user: User to filter flights by + :return: List of flights + """ + flights = [] + if user == "": + async for flight in flight_collection.find(): + flights.append(FlightConciseSchema(**flight_display_helper(flight))) + else: + async for flight in flight_collection.find({"user": ObjectId(user)}): + flights.append(FlightConciseSchema(**flight_display_helper(flight))) + return flights + + +async def retrieve_flight(id: str) -> FlightDisplaySchema: + """ + Get detailed information about the given flight + + :param id: ID of flight to retrieve + :return: Flight information + """ + oid = ObjectId(id) + flight = await flight_collection.find_one({"_id": oid}) + + 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 + """ + 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) -> FlightDisplaySchema: + """ + 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": ObjectId(id)}) + + if flight is None: + raise HTTPException(404, "Flight not found") + + updated_flight = await flight_collection.update_one({"_id": ObjectId(id)}, {"$set": body}) + return updated_flight.upserted_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": ObjectId(id)}) + + if flight is None: + raise HTTPException(404, "Flight not found") + + await flight_collection.delete_one({"_id": ObjectId(id)}) + return FlightDisplaySchema(**flight_display_helper(flight)) diff --git a/api/database/models.py b/api/database/models.py deleted file mode 100644 index 6cd6fa3..0000000 --- a/api/database/models.py +++ /dev/null @@ -1,69 +0,0 @@ -from mongoengine import * - -from schemas import AuthLevel - - -class User(Document): - username = StringField(required=True, unique=True) - password = StringField(required=True) - - # EnumField validation is currently broken, replace workaround if MongoEngine is updated to fix it - level = IntField(choices=[l.value for l in AuthLevel], default=1) - # level = EnumField(AuthLevel, default=AuthLevel.USER) - - -class TokenBlacklist(Document): - token = StringField(required=True) - - -class Flight(Document): - user = ObjectIdField(required=True) - - date = DateField(required=True, unique=False) - aircraft = StringField(default="") - waypoint_from = StringField(default="") - waypoint_to = StringField(default="") - route = StringField(default="") - - hobbs_start = DecimalField() - hobbs_end = DecimalField() - tach_start = DecimalField() - tach_end = DecimalField() - - time_start = DateTimeField() - time_off = DateTimeField() - time_down = DateTimeField() - time_stop = DateTimeField() - - time_total = DecimalField(default=0) - time_pic = DecimalField(default=0) - time_sic = DecimalField(default=0) - time_night = DecimalField(default=0) - time_solo = DecimalField(default=0) - - time_xc = DecimalField(default=0) - dist_xc = DecimalField(default=0) - - takeoffs_day = IntField(default=0) - landings_day = IntField(default=0) - takeoffs_night = IntField(default=0) - landings_night = IntField(default=0) - landings_all = IntField(default=0) - - time_instrument = DecimalField(default=0) - time_sim_instrument = DecimalField(default=0) - holds_instrument = DecimalField(default=0) - - dual_given = DecimalField(default=0) - dual_recvd = DecimalField(default=0) - time_sim = DecimalField(default=0) - time_ground = DecimalField(default=0) - - tags = ListField(StringField()) - - pax = ListField(StringField()) - crew = ListField(StringField()) - - comments = StringField() - - photos = ListField(ImageField()) diff --git a/api/database/tokens.py b/api/database/tokens.py new file mode 100644 index 0000000..8d72140 --- /dev/null +++ b/api/database/tokens.py @@ -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) diff --git a/api/database/users.py b/api/database/users.py new file mode 100644 index 0000000..046d61a --- /dev/null +++ b/api/database/users.py @@ -0,0 +1,134 @@ +import logging + +from bson import ObjectId +from fastapi import HTTPException + +from database.utils import user_helper, create_user_helper, system_user_helper +from .db import user_collection +from routes.utils import get_hashed_password +from schemas.user import UserDisplaySchema, UserCreateSchema, UserSystemSchema, AuthLevel + +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": 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": 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 from the database + + :param id: ID of user to delete + :return: Information of deleted user + """ + user = await user_collection.find_one({"_id": ObjectId(id)}) + + if user is None: + raise HTTPException(404, "User not found") + + await user_collection.delete_one({"_id": 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": ObjectId(user_id)}, {"$set": {"username": username}}) + if password: + hashed_password = get_hashed_password(password) + user_collection.update_one({"_id": ObjectId(user_id)}, {"$set": {"password": hashed_password}}) + if auth_level: + user_collection.update_one({"_id": ObjectId(user_id)}, {"$set": {"level": auth_level}}) + + updated_user = await get_user_info_id(user_id) + return updated_user diff --git a/api/database/utils.py b/api/database/utils.py index 4ca85f4..c27130e 100644 --- a/api/database/utils.py +++ b/api/database/utils.py @@ -1,239 +1,98 @@ import logging -import os -from datetime import datetime -from functools import reduce -import bcrypt -from fastapi import HTTPException -from mongoengine import DoesNotExist, Q +from bson import ObjectId -from database.models import User, AuthLevel, Flight -from schemas import GetUserSchema +from app.config import get_settings +from .db import user_collection +from routes.utils import get_hashed_password +from schemas.user import AuthLevel, UserCreateSchema -logger = logging.getLogger("utils") +logger = logging.getLogger("api") -async def edit_profile(user_id: str, username: str = None, password: str = None, - auth_level: AuthLevel = None) -> GetUserSchema: +def user_helper(user) -> dict: """ - 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 + Convert given db response into a format usable by UserDisplaySchema + :param user: Database response + :return: Usable dict """ - try: - user = User.objects.get(id=user_id) - except DoesNotExist: - raise HTTPException(404, "User not found") - - if username: - existing_users = User.objects(username=username).count() - 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.update(username=username) - if password: - hashed_password = bcrypt.hashpw(password.encode('UTF-8'), bcrypt.gensalt()) - user.update(password=hashed_password) - if auth_level: - user.update(level=auth_level) - - return GetUserSchema(id=str(user.id), username=user.username, level=user.level) + return { + "id": str(user["_id"]), + "username": user["username"], + "level": user["level"], + } -def create_admin_user(): +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, + } + + +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 + + +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"] = ObjectId(user) + return flight + + +# UTILS # + +async def create_admin_user(): """ Create default admin user if no admin users are present in the database :return: None """ - if User.objects(level=AuthLevel.ADMIN.value).count() == 0: + if await user_collection.count_documents({"level": AuthLevel.ADMIN.value}) == 0: logger.info("No admin users exist. Creating default admin user...") - try: - admin_username = os.environ["TAILFIN_ADMIN_USERNAME"] - logger.info("Setting admin username to 'TAILFIN_ADMIN_USERNAME': %s", admin_username) - except KeyError: - admin_username = "admin" - logger.info("'TAILFIN_ADMIN_USERNAME' not set, using default username 'admin'") - try: - admin_password = os.environ["TAILFIN_ADMIN_PASSWORD"] - logger.info("Setting admin password to 'TAILFIN_ADMIN_PASSWORD'") - except KeyError: - admin_password = "admin" - logger.warning("'TAILFIN_ADMIN_PASSWORD' not set, using default password 'admin'\n" - "Change this as soon as possible") - hashed_password = bcrypt.hashpw(admin_password.encode('utf-8'), bcrypt.gensalt()) - User(username=admin_username, password=hashed_password, level=AuthLevel.ADMIN.value).save() - logger.info("Default admin user created with username %s", - User.objects.get(level=AuthLevel.ADMIN).username) + settings = get_settings() -def get_flight_list(sort: str = None, filters: list[list[dict]] = None, limit: int = None, offset: int = None): - def prepare_condition(condition): - field = [condition['field'], condition['operator']] - field = (s for s in field if s) - field = '__'.join(field) - return {field: condition['value']} + admin_username = settings.tailfin_admin_username + logger.info("Setting admin username to 'TAILFIN_ADMIN_USERNAME': %s", admin_username) - def prepare_conditions(row): - return (Q(**prepare_condition(condition)) for condition in row) + admin_password = settings.tailfin_admin_password + logger.info("Setting admin password to 'TAILFIN_ADMIN_PASSWORD'") - def join_conditions(row): - return reduce(lambda a, b: a | b, prepare_conditions(row)) - - def join_rows(rows): - return reduce(lambda a, b: a & b, rows) - - if sort is None: - sort = "+date" - - query = join_rows(join_conditions(row) for row in filters) - - if query == Q(): - flights = Flight.objects.all() - else: - if limit is None: - flights = Flight.objects(query).order_by(sort) - else: - flights = Flight.objects(query).order_by(sort)[offset:limit] - - return flights - - -def get_flight_list(sort: str = "date", order: str = "desc", limit: int = None, offset: int = None, user: str = None, - date_eq: str = None, date_lt: str = None, date_gt: str = None, aircraft: str = None, - pic: bool = None, sic: bool = None, night: bool = None, solo: bool = None, xc: bool = None, - xc_dist_gt: float = None, xc_dist_lt: float = None, xc_dist_eq: float = None, - instrument: bool = None, - sim_instrument: bool = None, dual_given: bool = None, - dual_recvd: bool = None, sim: bool = None, ground: bool = None, pax: list[str] = None, - crew: list[str] = None, tags: list[str] = None): - """ - Get an optionally filtered and sorted list of logged flights - - :param sort: Parameter to sort flights by - :param order: Order of sorting; "asc" or "desc" - :param limit: Pagination limit - :param offset: Pagination offset - :param user: Filter by user - :param date_eq: Filter by date - :param date_lt: Get flights before this date - :param date_gt: Get flights after this date - :param aircraft: Filter by aircraft - :param pic: Only include PIC time - :param sic: Only include SIC time - :param night: Only include night time - :param solo: Only include solo time - :param xc: Only include XC time - :param xc_dist_gt: Only include flights with XC distance greater than this - :param xc_dist_lt: Only include flights with XC distance less than this - :param xc_dist_eq: Only include flights with XC distance equal to this - :param instrument: Only include instrument time - :param sim_instrument: Only include sim instrument time - :param dual_given: Only include dual given time - :param dual_recvd: Only include dual received time - :param sim: Only include sim time - :param ground: Only include ground time - :param pax: Filter by passengers - :param crew: Filter by crew - :param tags: Filter by tags - :return: Filtered and sorted list of flights - """ - sort_str = ("-" if order == "desc" else "+") + sort - - query = Q() - if user: - query &= Q(user=user) - if date_eq: - fmt_date_eq = datetime.strptime(date_eq, "%Y-%m-%d") - query &= Q(date=fmt_date_eq) - if date_lt: - fmt_date_lt = datetime.strptime(date_lt, "%Y-%m-%d") - query &= Q(date__lt=fmt_date_lt) - if date_gt: - fmt_date_gt = datetime.strptime(date_gt, "%Y-%m-%d") - query &= Q(date__gt=fmt_date_gt) - if aircraft: - query &= Q(aircraft=aircraft) - if pic is not None: - if pic: - query &= Q(time_pic__gt=0) - else: - query &= Q(time_pic__eq=0) - if sic is not None: - if sic: - query &= Q(time_sic__gt=0) - else: - query &= Q(time_sic__eq=0) - if night is not None: - if night: - query &= Q(time_night__gt=0) - else: - query &= Q(time_night__eq=0) - if solo is not None: - if solo: - query &= Q(time_solo__gt=0) - else: - query &= Q(time_solo__eq=0) - if xc is not None: - if xc: - query &= Q(time_xc__gt=0) - else: - query &= Q(time_xc__eq=0) - if xc_dist_gt: - query &= Q(dist_xc__gt=xc_dist_gt) - if xc_dist_lt: - query &= Q(dist_xc__lt=xc_dist_lt) - if xc_dist_eq: - query &= Q(dist_xc__eq=xc_dist_eq) - if instrument is not None: - if instrument: - query &= Q(time_instrument__gt=0) - else: - query &= Q(time_instrument__eq=0) - if sim_instrument is not None: - if sim_instrument: - query &= Q(time_sim_instrument__gt=0) - else: - query &= Q(time_sim_instrument__eq=0) - if dual_given is not None: - if dual_given: - query &= Q(dual_given__gt=0) - else: - query &= Q(dual_given__eq=0) - if dual_recvd is not None: - if dual_recvd: - query &= Q(dual_recvd__gt=0) - else: - query &= Q(dual_recvd__eq=0) - if sim is not None: - if sim: - query &= Q(time_sim__gt=0) - else: - query &= Q(time_sim__eq=0) - if ground is not None: - if ground: - query &= Q(time_ground__gt=0) - else: - query &= Q(time_ground__eq=0) - if pax: - query &= Q(pax=pax) - if crew: - query &= Q(crew=crew) - if tags: - query &= Q(tags=tags) - - if query == Q(): - flights = Flight.objects.all().order_by(sort_str)[offset:limit] - else: - flights = Flight.objects(query).order_by(sort_str)[offset:limit] - - return flights + hashed_password = get_hashed_password(admin_password) + user = await add_user( + UserCreateSchema(username=admin_username, password=hashed_password, level=AuthLevel.ADMIN.value)) + logger.info("Default admin user created with username %s", user.username) diff --git a/api/requirements.txt b/api/requirements.txt index ac444a8..c5e6396 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -1,6 +1,6 @@ -bcrypt==4.0.1 -mongoengine~=0.27.0 uvicorn~=0.24.0.post1 fastapi~=0.105.0 pydantic~=2.5.2 -passlib~=1.7.4 \ No newline at end of file +passlib[bcrypt]~=1.7.4 +motor~=3.3.2 +python-jose[cryptography]~=3.3.0 \ No newline at end of file diff --git a/api/routes/__init__.py b/api/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/routes/auth.py b/api/routes/auth.py new file mode 100644 index 0000000..523ea18 --- /dev/null +++ b/api/routes/auth.py @@ -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 = 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() diff --git a/api/routes/flights.py b/api/routes/flights.py index f749425..5c60c2c 100644 --- a/api/routes/flights.py +++ b/api/routes/flights.py @@ -3,48 +3,43 @@ import logging from fastapi import APIRouter, HTTPException, Depends from app.deps import get_current_user, admin_required -from schemas import FlightModel, GetSystemUserSchema +from database import flights as db +from schemas.flight import FlightConciseSchema, FlightDisplaySchema, FlightCreateSchema -from mongoengine import ValidationError - -from database.models import Flight, AuthLevel -from database.utils import get_flight_list +from schemas.user import UserDisplaySchema, AuthLevel router = APIRouter() logger = logging.getLogger("flights") -@router.get('/flights', summary="Get flights logged by the currently logged-in user", status_code=200) -async def get_flights(user: GetSystemUserSchema = Depends(get_current_user)) -> list[FlightModel]: +@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)) -> list[FlightConciseSchema]: """ Get a list of the flights logged by the currently logged-in user :return: List of flights """ # l = get_flight_list(filters=[[{"field": "user", "operator": "eq", "value": user.id}]]) - l = get_flight_list(user=str(user.id)) - flights = [] - for f in l: - flights.append(FlightModel(**f.to_mongo())) - return [f.to_mongo() for f in flights] + flights = await db.retrieve_flights(user.id) + return flights -@router.get('/flights/all', summary="Get all flights logged by all users", status_code=200, +@router.get('/all', summary="Get all flights logged by all users", status_code=200, dependencies=[Depends(admin_required)]) -def get_all_flights() -> list[FlightModel]: +async def get_all_flights() -> list[FlightConciseSchema]: """ Get a list of all flights logged by any user :return: List of flights """ - flights = [FlightModel(**f.to_mongo()) for f in get_flight_list()] + flights = await db.retrieve_flights() return flights -@router.get('/flights/{flight_id}', summary="Get details of a given flight", response_model=FlightModel, +@router.get('/{flight_id}', summary="Get details of a given flight", response_model=FlightDisplaySchema, status_code=200) -def get_flight(flight_id: str, user: GetSystemUserSchema = Depends(get_current_user)): +async def get_flight(flight_id: str, user: UserDisplaySchema = Depends(get_current_user)): """ Get all details of a given flight @@ -52,7 +47,7 @@ def get_flight(flight_id: str, user: GetSystemUserSchema = Depends(get_current_u :param user: Currently logged-in user :return: Flight details """ - flight = Flight.objects(id=flight_id).to_json() + flight = await db.retrieve_flight(flight_id) if 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") @@ -60,26 +55,24 @@ def get_flight(flight_id: str, user: GetSystemUserSchema = Depends(get_current_u return flight -@router.post('/flights', summary="Add a flight logbook entry", status_code=200) -def add_flight(flight_body: FlightModel, user: GetSystemUserSchema = Depends(get_current_user)): +@router.post('/', summary="Add a flight logbook entry", status_code=200) +async def add_flight(flight_body: FlightCreateSchema, user: UserDisplaySchema = Depends(get_current_user)): """ Add a flight logbook entry + :param flight_body: Information associated with new flight :param user: Currently logged-in user :return: Error message if request invalid, else ID of newly created log """ - try: - flight = Flight(user=user.id, **flight_body.model_dump()).save() - except ValidationError as e: - logger.info("Invalid flight body: %s", e) - raise HTTPException(400, "Invalid request") - return {"id": flight.id} + flight = await db.insert_flight(flight_body, user.id) + + return {"id": str(flight)} -@router.put('/flights/{flight_id}', summary="Update the given flight with new information", status_code=201, - response_model=FlightModel) -def update_flight(flight_id: str, flight_body: FlightModel, user: GetSystemUserSchema = Depends(get_current_user)): +@router.put('/{flight_id}', summary="Update the given flight with new information", status_code=201) +async def update_flight(flight_id: str, flight_body: FlightCreateSchema, + user: UserDisplaySchema = Depends(get_current_user)) -> str: """ Update the given flight with new information @@ -88,19 +81,21 @@ def update_flight(flight_id: str, flight_body: FlightModel, user: GetSystemUserS :param user: Currently logged-in user :return: Updated flight """ - flight = Flight.objects(id=flight_id) + flight = await get_flight(flight_id) + if flight is None: + raise HTTPException(404, "Flight not found") if flight.user != user and AuthLevel(user.level) != AuthLevel.ADMIN: logger.info("Attempted access to unauthorized flight by %s", user.username) raise HTTPException(403, "Unauthorized access") - flight.update(**flight_body.model_dump()) + updated_flight = await db.update_flight(flight_body, flight_id) - return flight_body + return str(updated_flight) -@router.delete('/flights/{flight_id}', summary="Delete the given flight", status_code=200) -def delete_flight(flight_id: str, user: GetSystemUserSchema = Depends(get_current_user)): +@router.delete('/{flight_id}', summary="Delete the given flight", status_code=200) +async def delete_flight(flight_id: str, user: UserDisplaySchema = Depends(get_current_user)): """ Delete the given flight @@ -108,12 +103,12 @@ def delete_flight(flight_id: str, user: GetSystemUserSchema = Depends(get_curren :param user: Currently logged-in user :return: 200 """ - flight = Flight.objects(id=flight_id) + flight = await get_flight(flight_id) if flight.user != user and AuthLevel(user.level) != AuthLevel.ADMIN: logger.info("Attempted access to unauthorized flight by %s", user.username) raise HTTPException(403, "Unauthorized access") - flight.delete() + deleted = await db.delete_flight(flight_id) - return '', 200 + return deleted diff --git a/api/routes/users.py b/api/routes/users.py index 19e3be7..1d944d5 100644 --- a/api/routes/users.py +++ b/api/routes/users.py @@ -1,25 +1,19 @@ -from typing import Annotated - import logging from fastapi import APIRouter, HTTPException, Depends -from fastapi.security import OAuth2PasswordRequestForm +from pydantic import ValidationError -from mongoengine import DoesNotExist, ValidationError - -from app.deps import get_current_user, admin_required, reusable_oath, get_current_user_token -from app.config import Settings, get_settings -from database.models import AuthLevel, User, Flight, TokenBlacklist -from schemas import CreateUserSchema, TokenSchema, GetSystemUserSchema, GetUserSchema, UpdateUserSchema -from utils import get_hashed_password, verify_password, create_access_token, create_refresh_token -from database.utils import edit_profile +from app.deps import get_current_user, admin_required +from database import users as db +from schemas.user import AuthLevel, UserCreateSchema, UserDisplaySchema, UserUpdateSchema +from routes.utils import get_hashed_password router = APIRouter() -logger = logging.getLogger("users") +logger = logging.getLogger("api") -@router.post('/users', summary="Add user to database", status_code=201, dependencies=[Depends(admin_required)]) -async def add_user(body: CreateUserSchema) -> dict: +@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. @@ -28,26 +22,24 @@ async def add_user(body: CreateUserSchema) -> dict: auth_level = body.level if body.level is not None else AuthLevel.USER - try: - existing_user = User.objects.get(username=body.username) + 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") - except DoesNotExist: - logger.info("Creating user %s with auth level %s", body.username, auth_level) + logger.info("Creating user %s with auth level %s", body.username, auth_level) - hashed_password = get_hashed_password(body.password) - user = User(username=body.username, password=hashed_password, level=auth_level.value) + hashed_password = get_hashed_password(body.password) + user = UserCreateSchema(username=body.username, password=hashed_password, level=auth_level.value) - try: - user.save() - except ValidationError: - raise HTTPException(400, "Invalid request") + added_user = await db.add_user(user) + if added_user is None: + raise HTTPException(500, "Failed to add user") - return {"id": str(user.id)} + return {"id": str(added_user)} -@router.delete('/users/{user_id}', summary="Delete given user and all associated flights", status_code=200, +@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) -> None: """ @@ -56,79 +48,34 @@ async def remove_user(user_id: str) -> None: :param user_id: ID of user to delete :return: None """ - try: - # Delete user from database - User.objects.get(id=user_id).delete() - except DoesNotExist: + # 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") - except ValidationError: - logger.debug("Invalid user delete request") - raise HTTPException(400, "Invalid user") + # except ValidationError: + # logger.debug("Invalid user delete request") + # raise HTTPException(400, "Invalid user") - # Delete all flights associated with the user - Flight.objects(user=user_id).delete() + # Delete all flights associated with the user TODO + # Flight.objects(user=user_id).delete() -@router.get('/users', summary="Get a list of all users", status_code=200, response_model=list[GetUserSchema], +@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[GetUserSchema]: +async def get_users() -> list[UserDisplaySchema]: """ Get a list of all users :return: List of users in the database """ - users = User.objects.all() - return [GetUserSchema(id=str(u.id), username=u.username, level=u.level) for u in users] + users = await db.retrieve_users() + return users -@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 - """ - - try: - user = User.objects.get(username=form_data.username) - hashed_pass = user.password - if not verify_password(form_data.password, hashed_pass): - raise HTTPException(401, "Invalid username or password") - return TokenSchema( - access_token=create_access_token(settings, str(user.id)), - refresh_token=create_refresh_token(settings, str(user.id)) - ) - except DoesNotExist: - raise HTTPException(401, "Invalid username or password") - - -@router.post('/logout', summary="Invalidate current user's token", status_code=200) -async def logout(user_token: (GetSystemUserSchema, 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 - print(token) - try: - TokenBlacklist(token=str(token)).save() - except ValidationError: - logger.debug("Failed to add token to blacklist") - - 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() - - -@router.get('/profile', status_code=200, response_model=GetUserSchema) -async def get_profile(user: GetSystemUserSchema = Depends(get_current_user)) -> GetUserSchema: +@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 @@ -137,26 +84,26 @@ async def get_profile(user: GetSystemUserSchema = Depends(get_current_user)) -> return user -@router.get('/profile/{user_id}', status_code=200, dependencies=[Depends(admin_required)], response_model=GetUserSchema) -async def get_user_profile(user_id: str) -> GetUserSchema: +@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 """ - try: - user = User.objects.get(id=user_id) - except DoesNotExist: + 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 GetUserSchema(id=str(user.id), username=user.username, level=user.level) + return user -@router.put('/profile', summary="Update the profile of the currently logged-in user", response_model=GetUserSchema) -async def update_profile(body: UpdateUserSchema, - user: GetSystemUserSchema = Depends(get_current_user)) -> GetUserSchema: +@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 @@ -164,12 +111,12 @@ async def update_profile(body: UpdateUserSchema, :param user: Currently logged-in user :return: None """ - return await edit_profile(user.id, body.username, body.password, body.level) + return await db.edit_profile(user.id, body.username, body.password, body.level) -@router.put('/profile/{user_id}', summary="Update profile of the given user", status_code=200, - dependencies=[Depends(admin_required)], response_model=GetUserSchema) -async def update_user_profile(user_id: str, body: UpdateUserSchema) -> GetUserSchema: +@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 @@ -177,4 +124,4 @@ async def update_user_profile(user_id: str, body: UpdateUserSchema) -> GetUserSc :return: Error messages if request is invalid, else 200 """ - return await edit_profile(user_id, body.username, body.password, body.level) + return await db.edit_profile(user_id, body.username, body.password, body.level) diff --git a/api/utils.py b/api/routes/utils.py similarity index 100% rename from api/utils.py rename to api/routes/utils.py diff --git a/api/schemas.py b/api/schemas.py deleted file mode 100644 index 2ae2566..0000000 --- a/api/schemas.py +++ /dev/null @@ -1,115 +0,0 @@ -import datetime -from enum import Enum -from typing import Annotated - -from pydantic import BaseModel, BeforeValidator - -ObjectId = Annotated[str, BeforeValidator(str)] - - -class FlightModel(BaseModel): - user: ObjectId - - date: datetime.date - aircraft: str = "" - waypoint_from: str = "" - waypoint_to: str = "" - route: str = "" - - hobbs_start: float | None = None - hobbs_end: float | None = None - tach_start: float | None = None - tach_end: float | None = None - - time_start: datetime.datetime | None = None - time_end: datetime.datetime | None = None - time_down: datetime.datetime | None = None - time_stop: datetime.datetime | None = None - - time_total: float = 0. - time_pic: float = 0. - time_sic: float = 0. - time_night: float = 0. - time_solo: float = 0. - - time_xc: float = 0. - dist_xc: float = 0. - - takeoffs_day: int = 0 - landings_day: int = 0 - takeoffs_night: int = 0 - landings_all: int = 0 - - time_instrument: float = 0 - time_sim_instrument: float = 0 - holds_instrument: float = 0 - - dual_given: float = 0 - dual_recvd: float = 0 - time_sim: float = 0 - time_ground: float = 0 - - tags: list[str] = [] - - pax: list[str] = [] - crew: list[str] = [] - - comments: str = "" - - -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 LoginUserSchema(BaseModel): - username: str - password: str - - -class CreateUserSchema(BaseModel): - username: str - password: str - level: AuthLevel = AuthLevel.USER - - -class UpdateUserSchema(BaseModel): - username: str | None = None - password: str | None = None - level: AuthLevel | None = None - - -class GetUserSchema(BaseModel): - id: str - username: str - level: AuthLevel = AuthLevel.USER - - -class GetSystemUserSchema(GetUserSchema): - password: str - - -class TokenSchema(BaseModel): - access_token: str - refresh_token: str - - -class TokenPayload(BaseModel): - sub: str = None - exp: int = None diff --git a/api/schemas/__init__.py b/api/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/schemas/flight.py b/api/schemas/flight.py new file mode 100644 index 0000000..a37ef78 --- /dev/null +++ b/api/schemas/flight.py @@ -0,0 +1,103 @@ +import datetime +from typing import Optional, Annotated, Any + +from bson import ObjectId +from pydantic import BaseModel, Field +from pydantic_core import core_schema + +PositiveInt = Annotated[int, Field(default=0, ge=0)] +PositiveFloat = Annotated[float, Field(default=0., ge=0)] +PositiveFloatNullable = Annotated[float, Field(ge=0)] + + +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) + + +class FlightCreateSchema(BaseModel): + date: datetime.date + 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 + tach_start: Optional[PositiveFloatNullable] = None + tach_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 + + takeoffs_day: PositiveInt + landings_day: PositiveInt + takeoffs_night: PositiveInt + landings_all: PositiveInt + + time_instrument: PositiveFloat + time_sim_instrument: PositiveFloat + holds_instrument: PositiveFloat + + 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 FlightDisplaySchema(FlightCreateSchema): + id: PyObjectId + + +class FlightConciseSchema(BaseModel): + user: PyObjectId + id: PyObjectId + + date: datetime.date + aircraft: str + waypoint_from: Optional[str] = None + waypoint_to: Optional[str] = None + + time_total: PositiveFloat + + comments: Optional[str] = None diff --git a/api/schemas/user.py b/api/schemas/user.py new file mode 100644 index 0000000..dd197cf --- /dev/null +++ b/api/schemas/user.py @@ -0,0 +1,99 @@ +from enum import Enum +from typing import Optional + +from pydantic import BaseModel, Field, validator, 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): + validate_username(value) + + @field_validator("password") + @classmethod + def _valid_password(cls, value): + validate_password(value) + + +class UserUpdateSchema(BaseModel): + username: Optional[str] = None + password: Optional[str] = None + level: Optional[AuthLevel] = AuthLevel.USER + + @field_validator("username") + @classmethod + def _valid_username(cls, value): + validate_username(value) + + @field_validator("password") + @classmethod + def _valid_password(cls, value): + validate_password(value) + + +class UserDisplaySchema(UserBaseSchema): + id: str + level: AuthLevel + + +class UserSystemSchema(UserDisplaySchema): + password: str + + +class TokenSchema(BaseModel): + access_token: str + refresh_token: str + + +class TokenPayload(BaseModel): + sub: Optional[str] + exp: Optional[int] From e4f6298c27a1b2f099b56754074266d0df72440d Mon Sep 17 00:00:00 2001 From: april Date: Thu, 28 Dec 2023 17:08:25 -0600 Subject: [PATCH 10/66] Add all config options to .env --- api/.env | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/api/.env b/api/.env index 1f2d01d..7d2cc00 100644 --- a/api/.env +++ b/api/.env @@ -1,12 +1,17 @@ -#DB_URI=localhost -#DB_NAME=tailfin +DB_URI=localhost +DB_PORT=27017 +DB_NAME=tailfin + DB_USER="tailfin-api" DB_PWD="tailfin-api-password" # 60 * 24 * 7 -> 7 days -#JWT_REFRESH_TOKEN_EXPIRE_MINUTES=10080 -#JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30 +REFRESH_TOKEN_EXPIRE_MINUTES=10080 +ACCESS_TOKEN_EXPIRE_MINUTES=30 -#JWT_ALGORITHM="HS256" +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" From 9d31a1af5562f2f2c99fab44458eaf7191415f29 Mon Sep 17 00:00:00 2001 From: april Date: Thu, 28 Dec 2023 17:20:29 -0600 Subject: [PATCH 11/66] Add port configuration option --- api/.env | 2 ++ api/app.py | 5 ++++- api/app/config.py | 2 ++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/api/.env b/api/.env index 7d2cc00..0399464 100644 --- a/api/.env +++ b/api/.env @@ -15,3 +15,5 @@ JWT_REFRESH_SECRET_KEY="change-me-i-beg-of-you" TAILFIN_ADMIN_USERNAME="admin" TAILFIN_ADMIN_PASSWORD="change-me-now" + +TAILFIN_PORT=8081 diff --git a/api/app.py b/api/app.py index 8610eb1..ea88919 100644 --- a/api/app.py +++ b/api/app.py @@ -1,5 +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="0.0.0.0", port=8081, reload=True) + uvicorn.run("app.api:app", host="0.0.0.0", port=settings.tailfin_port, reload=True) diff --git a/api/app/config.py b/api/app/config.py index 5b64c60..de3d808 100644 --- a/api/app/config.py +++ b/api/app/config.py @@ -23,6 +23,8 @@ class Settings(BaseSettings): tailfin_admin_username: str = "admin" tailfin_admin_password: str = "change-me-now" + tailfin_port: int = 8081 + @lru_cache def get_settings(): From c15b0e6b3868b9b91495b3c6374e1fb15da176f1 Mon Sep 17 00:00:00 2001 From: april Date: Thu, 28 Dec 2023 17:20:42 -0600 Subject: [PATCH 12/66] Add README --- api/README.md | 99 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 api/README.md diff --git a/api/README.md b/api/README.md new file mode 100644 index 0000000..7cd6a1b --- /dev/null +++ b/api/README.md @@ -0,0 +1,99 @@ +

+ + Tailfin Logo +

+ +

Tailfin

+ +--- + +

A self-hosted digital flight logbook

+ +## Table of Contents + ++ [About](#about) ++ [Getting Started](#getting_started) ++ [Configuration](#configuration) ++ [Usage](#usage) + +## About + +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 + +### 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 + +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 +DB_PORT: Port of MongoDB instance. Default: 27017 +DB_NAME: Name of the database to be used by Tailfin. Default: tailfin + +DB_USER: Username for MongoDB authentication. Default: tailfin-api +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) +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 +JWT_SECRET_KEY: Secret key used to encrypt and decrypt access tokens. Default: please-change-me +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 +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 + +Once the server is running, full API documentation is available at `localhost:8081/docs` \ No newline at end of file From 3aa3c9369cedc7d8a47837c1d0d4286da843e110 Mon Sep 17 00:00:00 2001 From: april Date: Thu, 28 Dec 2023 17:21:09 -0600 Subject: [PATCH 13/66] Add logo --- api/logo.png | Bin 0 -> 248662 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 api/logo.png diff --git a/api/logo.png b/api/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..18e04e3614030aa8d3cbb28c1eb96ff52b7bf4af GIT binary patch literal 248662 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 z%0A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

X4YHzRF_6 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 SI%Eh0_(+KXCY1;q1pPltYEOIs literal 0 HcmV?d00001 From 3e4155a9ba3fa5bd1837452669dd930d37024ab1 Mon Sep 17 00:00:00 2001 From: april Date: Thu, 28 Dec 2023 17:22:54 -0600 Subject: [PATCH 14/66] Update configuration docs --- api/README.md | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/api/README.md b/api/README.md index 7cd6a1b..8194f7e 100644 --- a/api/README.md +++ b/api/README.md @@ -73,26 +73,27 @@ $ python app.py 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 -DB_PORT: Port of MongoDB instance. Default: 27017 -DB_NAME: Name of the database to be used by Tailfin. Default: tailfin +`DB_URI`: Address of MongoDB instance. Default: localhost +`DB_PORT`: Port of MongoDB instance. Default: 27017 +`DB_NAME`: Name of the database to be used by Tailfin. Default: tailfin -DB_USER: Username for MongoDB authentication. Default: tailfin-api -DB_PWD: Password for MongoDB authentication. Default: tailfin-api-password +`DB_USER`: Username for MongoDB authentication. Default: tailfin-api +`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) -ACCESS_TOKEN_EXPIRE_MINUTES: Duration in minutes to keep access token active before invalidating it. Default: 30 +`REFRESH_TOKEN_EXPIRE_MINUTES`: Duration in minutes to keep refresh token active before invalidating it. Default: +10080 (7 days) +`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 -JWT_SECRET_KEY: Secret key used to encrypt and decrypt access tokens. Default: please-change-me -JWT_REFRESH_SECRET_KEY: Secret key used to encrypt and decrypt refresh tokens. Default: change-me-i-beg-of-you +`JWT_ALGORITHM`: Encryption algorithm to use for access and refresh tokens. Default: HS256 +`JWT_SECRET_KEY`: Secret key used to encrypt and decrypt access tokens. Default: please-change-me +`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 -TAILFIN_ADMIN_PASSWORD: Password of the default admin user that is created on startup if no admin users exist. Default: change-me-now +`TAILFIN_ADMIN_USERNAME`: Username of the default admin user that is created on startup if no admin users exist. +Default: admin +`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 -``` +`TAILFIN_PORT`: Port to run the local Tailfin API server on. Default: 8081 ## Usage From 941dbe527c9e0976fb3e5f092ba25d99f5aa00e5 Mon Sep 17 00:00:00 2001 From: april Date: Thu, 28 Dec 2023 17:25:19 -0600 Subject: [PATCH 15/66] Fix readme spacing --- api/README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/api/README.md b/api/README.md index 8194f7e..c178f54 100644 --- a/api/README.md +++ b/api/README.md @@ -74,22 +74,29 @@ To configure Tailfin, modify the `.env` file. Some of these options should be ch available options are detailed below: `DB_URI`: Address of MongoDB instance. Default: localhost +
`DB_PORT`: Port of MongoDB instance. Default: 27017 +
`DB_NAME`: Name of the database to be used by Tailfin. Default: tailfin `DB_USER`: Username for MongoDB authentication. Default: tailfin-api +
`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) +
`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 +
`JWT_SECRET_KEY`: Secret key used to encrypt and decrypt access tokens. Default: please-change-me +
`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 +
`TAILFIN_ADMIN_PASSWORD`: Password of the default admin user that is created on startup if no admin users exist. Default: change-me-now From 99e47765de8f9f5e7ad6419b2d18006c9a0a9356 Mon Sep 17 00:00:00 2001 From: april Date: Thu, 28 Dec 2023 17:26:50 -0600 Subject: [PATCH 16/66] Fix readme formatting --- api/README.md | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/api/README.md b/api/README.md index c178f54..1bc8829 100644 --- a/api/README.md +++ b/api/README.md @@ -73,34 +73,34 @@ $ python app.py 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 +`DB_URI`: Address of MongoDB instance. Default: `localhost`
-`DB_PORT`: Port of MongoDB instance. Default: 27017 +`DB_PORT`: Port of MongoDB instance. Default: `27017`
-`DB_NAME`: Name of the database to be used by Tailfin. Default: tailfin +`DB_NAME`: Name of the database to be used by Tailfin. Default: `tailfin` -`DB_USER`: Username for MongoDB authentication. Default: tailfin-api +`DB_USER`: Username for MongoDB authentication. Default: `tailfin-api`
-`DB_PWD`: Password for MongoDB authentication. Default: tailfin-api-password +`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) +`10080` (7 days)
-`ACCESS_TOKEN_EXPIRE_MINUTES`: Duration in minutes to keep access token active before invalidating it. Default: 30 +`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 +`JWT_ALGORITHM`: Encryption algorithm to use for access and refresh tokens. Default: `HS256`
-`JWT_SECRET_KEY`: Secret key used to encrypt and decrypt access tokens. Default: please-change-me +`JWT_SECRET_KEY`: Secret key used to encrypt and decrypt access tokens. Default: `please-change-me`
-`JWT_REFRESH_SECRET_KEY`: Secret key used to encrypt and decrypt refresh tokens. Default: change-me-i-beg-of-you +`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 +Default: `admin`
`TAILFIN_ADMIN_PASSWORD`: Password of the default admin user that is created on startup if no admin users exist. -Default: change-me-now +Default: `change-me-now` -`TAILFIN_PORT`: Port to run the local Tailfin API server on. Default: 8081 +`TAILFIN_PORT`: Port to run the local Tailfin API server on. Default: `8081` ## Usage From e7885369562f82d33b8b906cc5b346edd0d498a6 Mon Sep 17 00:00:00 2001 From: april Date: Fri, 29 Dec 2023 08:38:28 -0600 Subject: [PATCH 17/66] Add roadmap --- api/README.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/api/README.md b/api/README.md index 1bc8829..1d75dfd 100644 --- a/api/README.md +++ b/api/README.md @@ -15,6 +15,7 @@ + [Getting Started](#getting_started) + [Configuration](#configuration) + [Usage](#usage) ++ [Roadmap](#roadmap) ## About @@ -104,4 +105,13 @@ Default: `change-me-now` ## Usage -Once the server is running, full API documentation is available at `localhost:8081/docs` \ No newline at end of file +Once the server is running, full API documentation is available at `localhost:8081/docs` + +## Roadmap + +- [x] Multi-user authentication +- [x] Basic flight logging CRUD endpoints +- [ ] Implement JWT refresh tokens +- [ ] Attach photos to log entries +- [ ] Integrate database of airports and waypoints that can be queried to find nearest +- [ ] GPS track recording \ No newline at end of file From c2a649852d5baa40341b5e179d1b2aca2220ea66 Mon Sep 17 00:00:00 2001 From: april Date: Fri, 29 Dec 2023 08:59:47 -0600 Subject: [PATCH 18/66] Delete flights associated with user on user delete --- api/database/users.py | 8 ++++++-- api/routes/flights.py | 17 +++++++++-------- api/routes/users.py | 8 ++------ 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/api/database/users.py b/api/database/users.py index 046d61a..b439961 100644 --- a/api/database/users.py +++ b/api/database/users.py @@ -4,7 +4,7 @@ from bson import ObjectId from fastapi import HTTPException from database.utils import user_helper, create_user_helper, system_user_helper -from .db import user_collection +from .db import user_collection, flight_collection from routes.utils import get_hashed_password from schemas.user import UserDisplaySchema, UserCreateSchema, UserSystemSchema, AuthLevel @@ -84,7 +84,7 @@ async def get_user_system_info(username: str) -> UserSystemSchema: async def delete_user(id: str) -> UserDisplaySchema: """ - Delete given user from the database + Delete given user and all associated flights from the database :param id: ID of user to delete :return: Information of deleted user @@ -95,6 +95,10 @@ async def delete_user(id: str) -> UserDisplaySchema: raise HTTPException(404, "User not found") await user_collection.delete_one({"_id": ObjectId(id)}) + + # Delete all flights associated with user + await flight_collection.delete_many({"user": ObjectId(id)}) + return UserDisplaySchema(**user_helper(user)) diff --git a/api/routes/flights.py b/api/routes/flights.py index 5c60c2c..8ac71f2 100644 --- a/api/routes/flights.py +++ b/api/routes/flights.py @@ -13,7 +13,8 @@ router = APIRouter() logger = logging.getLogger("flights") -@router.get('/', summary="Get flights logged by the currently logged-in user", status_code=200) +@router.get('/', summary="Get flights logged by the currently logged-in user", status_code=200, + response_model=list[FlightConciseSchema]) async def get_flights(user: UserDisplaySchema = Depends(get_current_user)) -> list[FlightConciseSchema]: """ Get a list of the flights logged by the currently logged-in user @@ -26,7 +27,7 @@ async def get_flights(user: UserDisplaySchema = Depends(get_current_user)) -> li @router.get('/all', summary="Get all flights logged by all users", status_code=200, - dependencies=[Depends(admin_required)]) + dependencies=[Depends(admin_required)], response_model=list[FlightConciseSchema]) async def get_all_flights() -> list[FlightConciseSchema]: """ Get a list of all flights logged by any user @@ -39,7 +40,7 @@ async def get_all_flights() -> list[FlightConciseSchema]: @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)): +async def get_flight(flight_id: str, user: UserDisplaySchema = Depends(get_current_user)) -> FlightDisplaySchema: """ Get all details of a given flight @@ -56,7 +57,7 @@ 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)): +async def add_flight(flight_body: FlightCreateSchema, user: UserDisplaySchema = Depends(get_current_user)) -> dict: """ Add a flight logbook entry @@ -89,13 +90,13 @@ async def update_flight(flight_id: str, flight_body: FlightCreateSchema, logger.info("Attempted access to unauthorized flight by %s", user.username) raise HTTPException(403, "Unauthorized access") - updated_flight = await db.update_flight(flight_body, flight_id) + updated_flight_id = await db.update_flight(flight_body, flight_id) - return str(updated_flight) + return str(updated_flight_id) -@router.delete('/{flight_id}', summary="Delete the given flight", status_code=200) -async def delete_flight(flight_id: str, user: UserDisplaySchema = Depends(get_current_user)): +@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 diff --git a/api/routes/users.py b/api/routes/users.py index 1d944d5..51e722f 100644 --- a/api/routes/users.py +++ b/api/routes/users.py @@ -41,7 +41,7 @@ async def add_user(body: UserCreateSchema) -> dict: @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) -> None: +async def remove_user(user_id: str) -> UserDisplaySchema: """ Delete given user from database along with all flights associated with said user @@ -54,12 +54,8 @@ async def remove_user(user_id: str) -> None: if not deleted: logger.info("Attempt to delete nonexistent user %s", user_id) raise HTTPException(401, "User does not exist") - # except ValidationError: - # logger.debug("Invalid user delete request") - # raise HTTPException(400, "Invalid user") - # Delete all flights associated with the user TODO - # Flight.objects(user=user_id).delete() + return deleted @router.get('/', summary="Get a list of all users", status_code=200, response_model=list[UserDisplaySchema], From c4f01cd3f2073a085180ece3698f3c49e3f033c2 Mon Sep 17 00:00:00 2001 From: april Date: Tue, 2 Jan 2024 11:08:34 -0600 Subject: [PATCH 19/66] Add flight sorting --- api/database/flights.py | 8 +++++--- api/routes/flights.py | 9 +++++---- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/api/database/flights.py b/api/database/flights.py index 24e9e00..5a6f5fc 100644 --- a/api/database/flights.py +++ b/api/database/flights.py @@ -10,19 +10,21 @@ from schemas.flight import FlightConciseSchema, FlightDisplaySchema, FlightCreat logger = logging.getLogger("api") -async def retrieve_flights(user: str = "") -> list[FlightConciseSchema]: +async def retrieve_flights(user: str = "", sort: str = "date", order: int = -1) -> 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 :return: List of flights """ flights = [] if user == "": - async for flight in flight_collection.find(): + async for flight in flight_collection.find().sort({sort: order}): flights.append(FlightConciseSchema(**flight_display_helper(flight))) else: - async for flight in flight_collection.find({"user": ObjectId(user)}): + async for flight in flight_collection.find({"user": ObjectId(user)}).sort({sort: order}): flights.append(FlightConciseSchema(**flight_display_helper(flight))) return flights diff --git a/api/routes/flights.py b/api/routes/flights.py index 8ac71f2..4070f1d 100644 --- a/api/routes/flights.py +++ b/api/routes/flights.py @@ -15,26 +15,27 @@ logger = logging.getLogger("flights") @router.get('/', summary="Get flights logged by the currently logged-in user", status_code=200, response_model=list[FlightConciseSchema]) -async def get_flights(user: UserDisplaySchema = Depends(get_current_user)) -> list[FlightConciseSchema]: +async def get_flights(user: UserDisplaySchema = Depends(get_current_user), sort: str = "date", order: int = -1) -> list[ + FlightConciseSchema]: """ Get a list of the flights logged by the currently logged-in user :return: List of flights """ # l = get_flight_list(filters=[[{"field": "user", "operator": "eq", "value": user.id}]]) - flights = await db.retrieve_flights(user.id) + flights = await db.retrieve_flights(user.id, sort, order) return flights @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() -> 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 :return: List of flights """ - flights = await db.retrieve_flights() + flights = await db.retrieve_flights(sort, order) return flights From 5b6ed389c449ef109d2c65b713a4df95bb3707a0 Mon Sep 17 00:00:00 2001 From: april Date: Tue, 2 Jan 2024 11:10:56 -0600 Subject: [PATCH 20/66] Allow setting base URL from env --- api/app.py | 2 +- api/app/config.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/api/app.py b/api/app.py index ea88919..fc8a25e 100644 --- a/api/app.py +++ b/api/app.py @@ -5,4 +5,4 @@ from app.config import get_settings if __name__ == '__main__': settings = get_settings() # Start the app - uvicorn.run("app.api:app", host="0.0.0.0", port=settings.tailfin_port, reload=True) + uvicorn.run("app.api:app", host=settings.tailfin_url, port=settings.tailfin_port, reload=True) diff --git a/api/app/config.py b/api/app/config.py index de3d808..7ce519d 100644 --- a/api/app/config.py +++ b/api/app/config.py @@ -23,6 +23,7 @@ class Settings(BaseSettings): tailfin_admin_username: str = "admin" tailfin_admin_password: str = "change-me-now" + tailfin_url: str = "0.0.0.0" tailfin_port: int = 8081 From ca59125a85e9587fe32c2816c6b6421b589b83bf Mon Sep 17 00:00:00 2001 From: april Date: Tue, 2 Jan 2024 17:39:37 -0600 Subject: [PATCH 21/66] Fix logout type mismatch --- api/app/api.py | 5 +++++ api/app/deps.py | 9 +++++---- api/routes/flights.py | 2 +- api/schemas/flight.py | 1 + 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/api/app/api.py b/api/app/api.py index 61ee57f..d343252 100644 --- a/api/app/api.py +++ b/api/app/api.py @@ -3,6 +3,7 @@ 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 @@ -23,6 +24,10 @@ async def lifespan(app: FastAPI): # 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") diff --git a/api/app/deps.py b/api/app/deps.py index 6ed732f..3c18247 100644 --- a/api/app/deps.py +++ b/api/app/deps.py @@ -9,7 +9,8 @@ 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 + +from schemas.user import TokenPayload, AuthLevel, UserDisplaySchema, TokenSchema reusable_oath = OAuth2PasswordBearer( tokenUrl="/auth/login", @@ -42,7 +43,7 @@ async def get_current_user(settings: Annotated[Settings, Depends(get_settings)], async def get_current_user_token(settings: Annotated[Settings, Depends(get_settings)], - token: str = Depends(reusable_oath)) -> (UserDisplaySchema, str): + token: str = Depends(reusable_oath)) -> (UserDisplaySchema, TokenSchema): try: payload = jwt.decode( token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm] @@ -58,11 +59,11 @@ async def get_current_user_token(settings: Annotated[Settings, Depends(get_setti if blacklisted: raise HTTPException(403, "Token expired", {"WWW-Authenticate": "Bearer"}) - user = await get_user_system_info(id=token_data.sub) + user = await get_user_system_info_id(id=token_data.sub) if user is None: raise HTTPException(404, "Could not find user") - return user + return user, token async def admin_required(user: Annotated[UserDisplaySchema, Depends(get_current_user)]): diff --git a/api/routes/flights.py b/api/routes/flights.py index 4070f1d..fc005b2 100644 --- a/api/routes/flights.py +++ b/api/routes/flights.py @@ -50,7 +50,7 @@ async def get_flight(flight_id: str, user: UserDisplaySchema = Depends(get_curre :return: Flight details """ flight = await db.retrieve_flight(flight_id) - if flight.user != user.id and AuthLevel(user.level) != AuthLevel.ADMIN: + 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") diff --git a/api/schemas/flight.py b/api/schemas/flight.py index a37ef78..c370a53 100644 --- a/api/schemas/flight.py +++ b/api/schemas/flight.py @@ -86,6 +86,7 @@ class FlightCreateSchema(BaseModel): class FlightDisplaySchema(FlightCreateSchema): + user: PyObjectId id: PyObjectId From fc6a95995983de6e47cd3fdef33cb5dd3f86a4e4 Mon Sep 17 00:00:00 2001 From: april Date: Wed, 3 Jan 2024 08:38:40 -0600 Subject: [PATCH 22/66] Change login error codes --- api/app/deps.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/api/app/deps.py b/api/app/deps.py index 3c18247..193fd25 100644 --- a/api/app/deps.py +++ b/api/app/deps.py @@ -29,11 +29,11 @@ async def get_current_user(settings: Annotated[Settings, Depends(get_settings)], if datetime.fromtimestamp(token_data.exp) < datetime.now(): raise HTTPException(401, "Token expired", {"WWW-Authenticate": "Bearer"}) except (jwt.JWTError, ValidationError): - raise HTTPException(403, "Could not validate credentials", {"WWW-Authenticate": "Bearer"}) + raise HTTPException(401, "Could not validate credentials", {"WWW-Authenticate": "Bearer"}) blacklisted = await is_blacklisted(token) if blacklisted: - raise HTTPException(403, "Token expired", {"WWW-Authenticate": "Bearer"}) + raise HTTPException(401, "Token expired", {"WWW-Authenticate": "Bearer"}) user = await get_user_system_info_id(id=token_data.sub) if user is None: @@ -53,11 +53,11 @@ async def get_current_user_token(settings: Annotated[Settings, Depends(get_setti if datetime.fromtimestamp(token_data.exp) < datetime.now(): raise HTTPException(401, "Token expired", {"WWW-Authenticate": "Bearer"}) except (jwt.JWTError, ValidationError): - raise HTTPException(403, "Could not validate credentials", {"WWW-Authenticate": "Bearer"}) + raise HTTPException(401, "Could not validate credentials", {"WWW-Authenticate": "Bearer"}) blacklisted = await is_blacklisted(token) if blacklisted: - raise HTTPException(403, "Token expired", {"WWW-Authenticate": "Bearer"}) + raise HTTPException(401, "Token expired", {"WWW-Authenticate": "Bearer"}) user = await get_user_system_info_id(id=token_data.sub) if user is None: From ec6d565cdd7911746466d16033a56c642a6d6369 Mon Sep 17 00:00:00 2001 From: april Date: Wed, 3 Jan 2024 10:52:50 -0600 Subject: [PATCH 23/66] Fix instrument holds data type --- api/schemas/flight.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/schemas/flight.py b/api/schemas/flight.py index c370a53..5332b15 100644 --- a/api/schemas/flight.py +++ b/api/schemas/flight.py @@ -70,7 +70,7 @@ class FlightCreateSchema(BaseModel): time_instrument: PositiveFloat time_sim_instrument: PositiveFloat - holds_instrument: PositiveFloat + holds_instrument: PositiveInt dual_given: PositiveFloat dual_recvd: PositiveFloat From 4d9d2a8dba0a21544bfd1688828eb80d2cfb00b5 Mon Sep 17 00:00:00 2001 From: april Date: Wed, 3 Jan 2024 15:28:39 -0600 Subject: [PATCH 24/66] Fix date encoding issue --- api/schemas/flight.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/schemas/flight.py b/api/schemas/flight.py index 5332b15..78b86a0 100644 --- a/api/schemas/flight.py +++ b/api/schemas/flight.py @@ -38,7 +38,7 @@ class PyObjectId(str): class FlightCreateSchema(BaseModel): - date: datetime.date + date: datetime.datetime aircraft: Optional[str] = None waypoint_from: Optional[str] = None waypoint_to: Optional[str] = None From 5b6b5d819bd9b5c7e2563ae29299b38b11aa81f3 Mon Sep 17 00:00:00 2001 From: april Date: Thu, 4 Jan 2024 16:22:24 -0600 Subject: [PATCH 25/66] Fix landings label --- api/schemas/flight.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/schemas/flight.py b/api/schemas/flight.py index 78b86a0..560b21e 100644 --- a/api/schemas/flight.py +++ b/api/schemas/flight.py @@ -66,7 +66,7 @@ class FlightCreateSchema(BaseModel): takeoffs_day: PositiveInt landings_day: PositiveInt takeoffs_night: PositiveInt - landings_all: PositiveInt + landings_night: PositiveInt time_instrument: PositiveFloat time_sim_instrument: PositiveFloat From 78a4ca2984269dfa590ce4f150e1060921a50147 Mon Sep 17 00:00:00 2001 From: april Date: Fri, 5 Jan 2024 10:11:13 -0600 Subject: [PATCH 26/66] Add endpoint for getting flights structured by date --- api/routes/flights.py | 33 ++++++++++++++++++++++++++++++--- api/schemas/flight.py | 5 ++++- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/api/routes/flights.py b/api/routes/flights.py index fc005b2..32292f7 100644 --- a/api/routes/flights.py +++ b/api/routes/flights.py @@ -1,10 +1,11 @@ import logging +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 +from schemas.flight import FlightConciseSchema, FlightDisplaySchema, FlightCreateSchema, FlightByDateSchema from schemas.user import UserDisplaySchema, AuthLevel @@ -13,13 +14,15 @@ router = APIRouter() logger = logging.getLogger("flights") -@router.get('/', summary="Get flights logged by the currently logged-in user", status_code=200, - response_model=list[FlightConciseSchema]) +@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) -> 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) :return: List of flights """ # l = get_flight_list(filters=[[{"field": "user", "operator": "eq", "value": user.id}]]) @@ -27,12 +30,36 @@ async def get_flights(user: UserDisplaySchema = Depends(get_current_user), sort: 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) -> 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) + :return: + """ + flights = await db.retrieve_flights(user.id, sort, order) + 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('/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, order) diff --git a/api/schemas/flight.py b/api/schemas/flight.py index 560b21e..4a75d25 100644 --- a/api/schemas/flight.py +++ b/api/schemas/flight.py @@ -1,5 +1,5 @@ import datetime -from typing import Optional, Annotated, Any +from typing import Optional, Annotated, Any, Dict, Union, List, Literal from bson import ObjectId from pydantic import BaseModel, Field @@ -102,3 +102,6 @@ class FlightConciseSchema(BaseModel): time_total: PositiveFloat comments: Optional[str] = None + + +FlightByDateSchema = Dict[int, Union[List['FlightByDateSchema'], FlightConciseSchema]] From 17b187b40a81646d393f75cf9655aace29d6e393 Mon Sep 17 00:00:00 2001 From: April Petersen <58403923+azpsen@users.noreply.github.com> Date: Fri, 5 Jan 2024 12:19:53 -0600 Subject: [PATCH 27/66] Update roadmap --- api/README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/api/README.md b/api/README.md index 1d75dfd..0ad8246 100644 --- a/api/README.md +++ b/api/README.md @@ -113,5 +113,7 @@ Once the server is running, full API documentation is available at `localhost:80 - [x] Basic flight logging CRUD endpoints - [ ] Implement JWT refresh tokens - [ ] Attach photos to log entries +- [ ] PDF Export +- [ ] Import from other log applications - [ ] Integrate database of airports and waypoints that can be queried to find nearest -- [ ] GPS track recording \ No newline at end of file +- [ ] GPS track recording From 6b9a8b6a1a6f4cf64af4e11c9e0730911c0ebdfe Mon Sep 17 00:00:00 2001 From: april Date: Fri, 5 Jan 2024 14:16:19 -0600 Subject: [PATCH 28/66] Fix user comparison issue that prevented deletion and editing --- api/routes/flights.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/api/routes/flights.py b/api/routes/flights.py index 32292f7..c1f1fba 100644 --- a/api/routes/flights.py +++ b/api/routes/flights.py @@ -1,3 +1,4 @@ +import json import logging from typing import Dict, Union, List @@ -62,7 +63,7 @@ async def get_all_flights(sort: str = "date", order: int = -1) -> list[FlightCon :param order: Order of sorting (asc/desc) :return: List of flights """ - flights = await db.retrieve_flights(sort, order) + flights = await db.retrieve_flights(sort=sort, order=order) return flights @@ -110,11 +111,11 @@ async def update_flight(flight_id: str, flight_body: FlightCreateSchema, :param user: Currently logged-in user :return: Updated flight """ - flight = await get_flight(flight_id) + flight = await get_flight(flight_id, user) if flight is None: raise HTTPException(404, "Flight not found") - if flight.user != user and AuthLevel(user.level) != AuthLevel.ADMIN: + 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") @@ -132,9 +133,9 @@ async def delete_flight(flight_id: str, user: UserDisplaySchema = Depends(get_cu :param user: Currently logged-in user :return: 200 """ - flight = await get_flight(flight_id) + flight = await get_flight(flight_id, user) - if flight.user != user and AuthLevel(user.level) != AuthLevel.ADMIN: + 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") From d61b4738695f83380b05b93a8d8d356d452d0274 Mon Sep 17 00:00:00 2001 From: april Date: Fri, 5 Jan 2024 15:35:03 -0600 Subject: [PATCH 29/66] Fix flight update db issue --- api/database/flights.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/database/flights.py b/api/database/flights.py index 5a6f5fc..09b09c2 100644 --- a/api/database/flights.py +++ b/api/database/flights.py @@ -70,7 +70,7 @@ async def update_flight(body: FlightCreateSchema, id: str) -> FlightDisplaySchem if flight is None: raise HTTPException(404, "Flight not found") - updated_flight = await flight_collection.update_one({"_id": ObjectId(id)}, {"$set": body}) + updated_flight = await flight_collection.update_one({"_id": ObjectId(id)}, {"$set": body.model_dump()}) return updated_flight.upserted_id From 403ce0d9bc87783357301344bd0f3764dd1ed3b9 Mon Sep 17 00:00:00 2001 From: april Date: Fri, 5 Jan 2024 15:41:33 -0600 Subject: [PATCH 30/66] Change update_flight return value to be consistent with others --- api/database/flights.py | 5 ++++- api/routes/flights.py | 6 +++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/api/database/flights.py b/api/database/flights.py index 09b09c2..a3252a6 100644 --- a/api/database/flights.py +++ b/api/database/flights.py @@ -71,7 +71,10 @@ async def update_flight(body: FlightCreateSchema, id: str) -> FlightDisplaySchem raise HTTPException(404, "Flight not found") updated_flight = await flight_collection.update_one({"_id": ObjectId(id)}, {"$set": body.model_dump()}) - return updated_flight.upserted_id + if updated_flight is None: + raise HTTPException(500, "Failed to update flight") + + return id async def delete_flight(id: str) -> FlightDisplaySchema: diff --git a/api/routes/flights.py b/api/routes/flights.py index c1f1fba..be7c692 100644 --- a/api/routes/flights.py +++ b/api/routes/flights.py @@ -100,9 +100,9 @@ async def add_flight(flight_body: FlightCreateSchema, user: UserDisplaySchema = return {"id": str(flight)} -@router.put('/{flight_id}', summary="Update the given flight with new information", status_code=201) +@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)) -> str: + user: UserDisplaySchema = Depends(get_current_user)) -> dict: """ Update the given flight with new information @@ -121,7 +121,7 @@ async def update_flight(flight_id: str, flight_body: FlightCreateSchema, updated_flight_id = await db.update_flight(flight_body, flight_id) - return str(updated_flight_id) + return {"id": str(updated_flight_id)} @router.delete('/{flight_id}', summary="Delete the given flight", status_code=200, response_model=FlightDisplaySchema) From cabae556772201b37e47a4f3de0c65e2e3a3c9ca Mon Sep 17 00:00:00 2001 From: april Date: Fri, 5 Jan 2024 17:03:56 -0600 Subject: [PATCH 31/66] Implement password updating --- api/routes/auth.py | 2 +- api/routes/users.py | 30 +++++++++++++++++++++++++----- api/schemas/user.py | 16 ++++++++++------ 3 files changed, 36 insertions(+), 12 deletions(-) diff --git a/api/routes/auth.py b/api/routes/auth.py index 523ea18..4128448 100644 --- a/api/routes/auth.py +++ b/api/routes/auth.py @@ -50,7 +50,7 @@ async def logout(user_token: (UserDisplaySchema, TokenSchema) = Depends(get_curr user, token = user_token # Blacklist token - blacklisted = tokens.blacklist_token(token) + blacklisted = await tokens.blacklist_token(token) if not blacklisted: logger.debug("Failed to add token to blacklist") diff --git a/api/routes/users.py b/api/routes/users.py index 51e722f..03077a8 100644 --- a/api/routes/users.py +++ b/api/routes/users.py @@ -3,9 +3,9 @@ from fastapi import APIRouter, HTTPException, Depends from pydantic import ValidationError from app.deps import get_current_user, admin_required -from database import users as db -from schemas.user import AuthLevel, UserCreateSchema, UserDisplaySchema, UserUpdateSchema -from routes.utils import get_hashed_password +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() @@ -101,13 +101,33 @@ async def get_user_profile(user_id: str) -> UserDisplaySchema: async def update_profile(body: UserUpdateSchema, user: UserDisplaySchema = Depends(get_current_user)) -> UserDisplaySchema: """ - Update the profile of the currently logged-in user + 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 """ - return await db.edit_profile(user.id, body.username, body.password, body.level) + # 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, diff --git a/api/schemas/user.py b/api/schemas/user.py index dd197cf..8d38f8e 100644 --- a/api/schemas/user.py +++ b/api/schemas/user.py @@ -66,7 +66,6 @@ class UserCreateSchema(UserBaseSchema): class UserUpdateSchema(BaseModel): username: Optional[str] = None - password: Optional[str] = None level: Optional[AuthLevel] = AuthLevel.USER @field_validator("username") @@ -74,11 +73,6 @@ class UserUpdateSchema(BaseModel): def _valid_username(cls, value): validate_username(value) - @field_validator("password") - @classmethod - def _valid_password(cls, value): - validate_password(value) - class UserDisplaySchema(UserBaseSchema): id: str @@ -89,6 +83,16 @@ class UserSystemSchema(UserDisplaySchema): password: str +class PasswordUpdateSchema(BaseModel): + current_password: str + new_password: str + + @field_validator("new_password") + @classmethod + def _valid_password(cls, value): + validate_password(value) + + class TokenSchema(BaseModel): access_token: str refresh_token: str From 9c2eb7ea0ed0f4074ffa26bf168cc0b1016517ed Mon Sep 17 00:00:00 2001 From: april Date: Mon, 8 Jan 2024 13:58:10 -0600 Subject: [PATCH 32/66] Add simple statistics endpoint --- api/database/flights.py | 36 ++++++++++++++++++++++++++++++++++++ api/routes/flights.py | 20 ++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/api/database/flights.py b/api/database/flights.py index a3252a6..8faf352 100644 --- a/api/database/flights.py +++ b/api/database/flights.py @@ -1,4 +1,5 @@ import logging +from datetime import datetime from bson import ObjectId from fastapi import HTTPException @@ -29,6 +30,41 @@ async def retrieve_flights(user: str = "", sort: str = "date", order: int = -1) 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 = {"user": 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) + + cursor = flight_collection.aggregate([ + {"$match": match}, + {"$group": { + "_id": None, + "total_time": {"$sum": "$time_total"}, + "total_solo": {"$sum": "$time_solo"}, + "total_night": {"$sum": "$time_night"}, + "total_pic": {"$sum": "$time_pic"}, + "total_sic": {"$sum": "$time_sic"}, + } + }, + {"$project": {"_id": 0}}, + ]) + + result = await cursor.to_list(length=None) + + if not result: + raise HTTPException(404, "No flights found") + + return result[0] + + async def retrieve_flight(id: str) -> FlightDisplaySchema: """ Get detailed information about the given flight diff --git a/api/routes/flights.py b/api/routes/flights.py index be7c692..6f5b837 100644 --- a/api/routes/flights.py +++ b/api/routes/flights.py @@ -1,5 +1,6 @@ import json import logging +from datetime import datetime from typing import Dict, Union, List from fastapi import APIRouter, HTTPException, Depends @@ -53,6 +54,25 @@ async def get_flights_by_date(user: UserDisplaySchema = Depends(get_current_user 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]: From 29334dad95d3d6be41c68806b6ade3b05f6029a2 Mon Sep 17 00:00:00 2001 From: april Date: Mon, 8 Jan 2024 14:54:23 -0600 Subject: [PATCH 33/66] Remove takeoffs field --- api/schemas/flight.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/api/schemas/flight.py b/api/schemas/flight.py index 4a75d25..883d6c6 100644 --- a/api/schemas/flight.py +++ b/api/schemas/flight.py @@ -63,9 +63,7 @@ class FlightCreateSchema(BaseModel): time_xc: PositiveFloat dist_xc: PositiveFloat - takeoffs_day: PositiveInt landings_day: PositiveInt - takeoffs_night: PositiveInt landings_night: PositiveInt time_instrument: PositiveFloat From cbd6e2beb5430519061a16611b16c07bc07992ee Mon Sep 17 00:00:00 2001 From: april Date: Mon, 8 Jan 2024 14:54:39 -0600 Subject: [PATCH 34/66] Add totals endpoint --- api/database/flights.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/api/database/flights.py b/api/database/flights.py index 8faf352..73ac556 100644 --- a/api/database/flights.py +++ b/api/database/flights.py @@ -52,6 +52,12 @@ async def retrieve_totals(user: str, start_date: datetime = None, end_date: date "total_night": {"$sum": "$time_night"}, "total_pic": {"$sum": "$time_pic"}, "total_sic": {"$sum": "$time_sic"}, + "total_instrument": {"$sum": "$time_instrument"}, + "total_sim": {"$sum": "$time_sim"}, + "time_xc": {"$sum": "$time_xc"}, + "landings_day": {"$sum": "$takoffs_day"}, + "landings_night": {"$sum": "$takeoffs_nights"}, + } }, {"$project": {"_id": 0}}, @@ -62,7 +68,17 @@ async def retrieve_totals(user: str, start_date: datetime = None, end_date: date if not result: raise HTTPException(404, "No flights found") - return result[0] + totals = result[0] + async for log in flight_collection.find({"user": ObjectId(user)}): + flight = FlightDisplaySchema(**flight_display_helper(log)) + totals["total_xc_instr"] = totals.get("total_xc_instr", 0) + min(flight.time_xc, flight.dual_recvd) + totals["total_xc_solo"] = totals.get("total_xc_solo", 0) + min(flight.time_xc, flight.time_solo) + totals["total_xc_pic"] = totals.get("total_xc_pic", 0) + min(flight.time_xc, flight.time_pic) + totals["total_night_dual_recvd"] = totals.get("total_night_dual_recvd", 0) + min(flight.time_night, + flight.dual_recvd) + totals["total_night_pic"] = totals.get("total_night_pic", 0) + min(flight.time_night, flight.time_pic) + + return totals async def retrieve_flight(id: str) -> FlightDisplaySchema: From b7610e9b6fe4f47070af3b513ac4ffa10b4c05f1 Mon Sep 17 00:00:00 2001 From: april Date: Mon, 8 Jan 2024 15:12:25 -0600 Subject: [PATCH 35/66] Remove redundant "total" from totals --- api/database/flights.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/api/database/flights.py b/api/database/flights.py index 73ac556..1c2e0c3 100644 --- a/api/database/flights.py +++ b/api/database/flights.py @@ -47,16 +47,16 @@ async def retrieve_totals(user: str, start_date: datetime = None, end_date: date {"$match": match}, {"$group": { "_id": None, - "total_time": {"$sum": "$time_total"}, - "total_solo": {"$sum": "$time_solo"}, - "total_night": {"$sum": "$time_night"}, - "total_pic": {"$sum": "$time_pic"}, - "total_sic": {"$sum": "$time_sic"}, - "total_instrument": {"$sum": "$time_instrument"}, - "total_sim": {"$sum": "$time_sim"}, + "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": "$takoffs_day"}, - "landings_night": {"$sum": "$takeoffs_nights"}, + "landings_day": {"$sum": "$landings_day"}, + "landings_night": {"$sum": "$landings_night"}, } }, @@ -71,12 +71,12 @@ async def retrieve_totals(user: str, start_date: datetime = None, end_date: date totals = result[0] async for log in flight_collection.find({"user": ObjectId(user)}): flight = FlightDisplaySchema(**flight_display_helper(log)) - totals["total_xc_instr"] = totals.get("total_xc_instr", 0) + min(flight.time_xc, flight.dual_recvd) - totals["total_xc_solo"] = totals.get("total_xc_solo", 0) + min(flight.time_xc, flight.time_solo) - totals["total_xc_pic"] = totals.get("total_xc_pic", 0) + min(flight.time_xc, flight.time_pic) - totals["total_night_dual_recvd"] = totals.get("total_night_dual_recvd", 0) + min(flight.time_night, - flight.dual_recvd) - totals["total_night_pic"] = totals.get("total_night_pic", 0) + min(flight.time_night, flight.time_pic) + totals["xc_dual_recvd"] = totals.get("xc_dual_recvd", 0) + min(flight.time_xc, flight.dual_recvd) + totals["xc_solo"] = totals.get("xc_solo", 0) + min(flight.time_xc, flight.time_solo) + totals["xc_pic"] = totals.get("xc_pic", 0) + min(flight.time_xc, flight.time_pic) + totals["night_dual_recvd"] = totals.get("night_dual_recvd", 0) + min(flight.time_night, + flight.dual_recvd) + totals["night_pic"] = totals.get("night_pic", 0) + min(flight.time_night, flight.time_pic) return totals From 04e8c8ca8cb4ae57abd6e0a4032c6a77b3255ff5 Mon Sep 17 00:00:00 2001 From: april Date: Tue, 9 Jan 2024 12:31:04 -0600 Subject: [PATCH 36/66] Implement basic aircraft management --- api/app/api.py | 3 +- api/database/aircraft.py | 87 +++++++++++++++++++++++++++++ api/database/db.py | 1 + api/database/flights.py | 3 +- api/database/utils.py | 36 ++++++++++++ api/routes/aircraft.py | 117 +++++++++++++++++++++++++++++++++++++++ api/routes/flights.py | 5 +- api/schemas/aircraft.py | 101 +++++++++++++++++++++++++++++++++ 8 files changed, 346 insertions(+), 7 deletions(-) create mode 100644 api/database/aircraft.py create mode 100644 api/routes/aircraft.py create mode 100644 api/schemas/aircraft.py 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 From c45f47ed44213a361af3544bf8337bfd5a100c24 Mon Sep 17 00:00:00 2001 From: april Date: Tue, 9 Jan 2024 13:02:04 -0600 Subject: [PATCH 37/66] Update flights to reference aircraft collection --- api/database/aircraft.py | 3 +- api/database/flights.py | 18 ++++++- api/database/users.py | 4 +- api/database/utils.py | 108 +++------------------------------------ api/schemas/aircraft.py | 46 ++++++++++++++--- api/schemas/flight.py | 93 ++++++++++++++++++++------------- api/schemas/user.py | 46 +++++++++++++++++ api/schemas/utils.py | 49 ++++++++++++++++++ 8 files changed, 217 insertions(+), 150 deletions(-) create mode 100644 api/schemas/utils.py diff --git a/api/database/aircraft.py b/api/database/aircraft.py index 98c676a..4303955 100644 --- a/api/database/aircraft.py +++ b/api/database/aircraft.py @@ -2,8 +2,7 @@ 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 +from schemas.aircraft import AircraftDisplaySchema, AircraftCreateSchema, aircraft_display_helper, aircraft_add_helper async def retrieve_aircraft(user: str = "") -> list[AircraftDisplaySchema]: diff --git a/api/database/flights.py b/api/database/flights.py index c597cd4..589a30f 100644 --- a/api/database/flights.py +++ b/api/database/flights.py @@ -2,11 +2,12 @@ import logging from datetime import datetime from bson import ObjectId +from bson.errors import InvalidId from fastapi import HTTPException -from database.utils import flight_display_helper, flight_add_helper from .db import flight_collection -from schemas.flight import FlightConciseSchema, FlightDisplaySchema, FlightCreateSchema +from schemas.flight import FlightConciseSchema, FlightDisplaySchema, FlightCreateSchema, flight_display_helper, \ + flight_add_helper logger = logging.getLogger("api") @@ -104,6 +105,14 @@ async def insert_flight(body: FlightCreateSchema, id: str) -> ObjectId: :param id: ID of creating user :return: ID of inserted flight """ + try: + aircraft = await flight_collection.find_one({"_id": ObjectId(body.aircraft)}) + except InvalidId: + raise HTTPException(400, "Invalid aircraft ID") + + if aircraft is None: + raise HTTPException(404, "Aircraft not found") + flight = await flight_collection.insert_one(flight_add_helper(body.model_dump(), id)) return flight.inserted_id @@ -121,6 +130,11 @@ async def update_flight(body: FlightCreateSchema, id: str) -> FlightDisplaySchem if flight is None: raise HTTPException(404, "Flight not found") + aircraft = await flight_collection.find_ond({"_id": ObjectId(body.aircraft)}) + + if aircraft is None: + raise HTTPException(404, "Aircraft not found") + updated_flight = await flight_collection.update_one({"_id": ObjectId(id)}, {"$set": body.model_dump()}) if updated_flight is None: raise HTTPException(500, "Failed to update flight") diff --git a/api/database/users.py b/api/database/users.py index b439961..7d32614 100644 --- a/api/database/users.py +++ b/api/database/users.py @@ -3,10 +3,10 @@ import logging from bson import ObjectId from fastapi import HTTPException -from database.utils import user_helper, create_user_helper, system_user_helper from .db import user_collection, flight_collection from routes.utils import get_hashed_password -from schemas.user import UserDisplaySchema, UserCreateSchema, UserSystemSchema, AuthLevel +from schemas.user import UserDisplaySchema, UserCreateSchema, UserSystemSchema, AuthLevel, user_helper, \ + create_user_helper, system_user_helper logger = logging.getLogger("api") diff --git a/api/database/utils.py b/api/database/utils.py index d0e7259..51839d4 100644 --- a/api/database/utils.py +++ b/api/database/utils.py @@ -1,114 +1,14 @@ 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 +from .users import add_user 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 - """ - 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, - } - - -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 - - -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"] = ObjectId(user) - 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(): @@ -131,4 +31,8 @@ async def create_admin_user(): hashed_password = get_hashed_password(admin_password) user = await add_user( UserCreateSchema(username=admin_username, password=hashed_password, level=AuthLevel.ADMIN.value)) - logger.info("Default admin user created with username %s", user.username) + + if user is None: + raise Exception("Failed to create default admin user") + + logger.info("Default admin user created with username %s", admin_username) diff --git a/api/schemas/aircraft.py b/api/schemas/aircraft.py index 0ce002a..07e60f0 100644 --- a/api/schemas/aircraft.py +++ b/api/schemas/aircraft.py @@ -1,10 +1,10 @@ from enum import Enum -from typing import Annotated -from pydantic import BaseModel, field_validator, Field +from bson import ObjectId +from pydantic import BaseModel, field_validator from pydantic_core.core_schema import ValidationInfo -from schemas.flight import PyObjectId +from schemas.utils import PyObjectId, PositiveFloat class AircraftCategory(Enum): @@ -47,9 +47,6 @@ class AircraftClass(Enum): wss = "Weight-Shift Control Sea" -PositiveFloat = Annotated[float, Field(default=0., ge=0)] - - class AircraftCreateSchema(BaseModel): tail_no: str make: str @@ -99,3 +96,40 @@ class AircraftCreateSchema(BaseModel): 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"] = 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 diff --git a/api/schemas/flight.py b/api/schemas/flight.py index 883d6c6..f8688a9 100644 --- a/api/schemas/flight.py +++ b/api/schemas/flight.py @@ -1,45 +1,15 @@ import datetime -from typing import Optional, Annotated, Any, Dict, Union, List, Literal +from typing import Optional, Dict, Union, List from bson import ObjectId -from pydantic import BaseModel, Field -from pydantic_core import core_schema +from pydantic import BaseModel -PositiveInt = Annotated[int, Field(default=0, ge=0)] -PositiveFloat = Annotated[float, Field(default=0., ge=0)] -PositiveFloatNullable = Annotated[float, Field(ge=0)] +from database.aircraft import retrieve_aircraft_by_id +from schemas.utils import PositiveFloatNullable, PositiveFloat, PositiveInt, PyObjectId -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) - - -class FlightCreateSchema(BaseModel): +class FlightSchema(BaseModel): date: datetime.datetime - aircraft: Optional[str] = None waypoint_from: Optional[str] = None waypoint_to: Optional[str] = None route: Optional[str] = None @@ -83,14 +53,20 @@ class FlightCreateSchema(BaseModel): comments: Optional[str] = None -class FlightDisplaySchema(FlightCreateSchema): +class FlightCreateSchema(FlightSchema): + aircraft: str + + +class FlightDisplaySchema(FlightSchema): user: PyObjectId id: PyObjectId + aircraft: PyObjectId class FlightConciseSchema(BaseModel): user: PyObjectId id: PyObjectId + aircraft: str date: datetime.date aircraft: str @@ -103,3 +79,48 @@ class FlightConciseSchema(BaseModel): FlightByDateSchema = Dict[int, Union[List['FlightByDateSchema'], FlightConciseSchema]] + + +# 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"]) + flight["aircraft"] = str(flight["aircraft"]) + + 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"]) + flight["aircraft"] = (await retrieve_aircraft_by_id(str(flight["aircraft"]))).tail_no + + 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"] = ObjectId(user) + flight["aircraft"] = ObjectId(flight["aircraft"]) + + return flight diff --git a/api/schemas/user.py b/api/schemas/user.py index 8d38f8e..eec24bb 100644 --- a/api/schemas/user.py +++ b/api/schemas/user.py @@ -101,3 +101,49 @@ class TokenSchema(BaseModel): 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, + } diff --git a/api/schemas/utils.py b/api/schemas/utils.py new file mode 100644 index 0000000..6f30ddc --- /dev/null +++ b/api/schemas/utils.py @@ -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) From 955a3e38f30113699ce3c6d1aec85ab5d683bcba Mon Sep 17 00:00:00 2001 From: april Date: Tue, 9 Jan 2024 13:17:34 -0600 Subject: [PATCH 38/66] Handle totals with no flights --- api/database/flights.py | 19 ++++++++++++++++++- api/routes/flights.py | 1 + 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/api/database/flights.py b/api/database/flights.py index 589a30f..ae59ae5 100644 --- a/api/database/flights.py +++ b/api/database/flights.py @@ -67,7 +67,24 @@ async def retrieve_totals(user: str, start_date: datetime = None, end_date: date result = await cursor.to_list(length=None) if not result: - raise HTTPException(404, "No flights found") + return { + "time_total": 0.0, + "time_solo": 0.0, + "time_night": 0.0, + "time_pic": 0.0, + "time_sic": 0.0, + "time_instrument": 0.0, + "time_sim": 0.0, + "time_xc": 0.0, + "landings_day": 0, + "landings_night": 0, + "xc_dual_recvd": 0.0, + "xc_solo": 0.0, + "xc_pic": 0.0, + "night_dual_recvd": 0.0, + "night_pic": 0.0 + + } totals = result[0] async for log in flight_collection.find({"user": ObjectId(user)}): diff --git a/api/routes/flights.py b/api/routes/flights.py index d274a5b..ee968d5 100644 --- a/api/routes/flights.py +++ b/api/routes/flights.py @@ -67,6 +67,7 @@ async def get_flight_totals(user: UserDisplaySchema = Depends(get_current_user), 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) From f78be2cf86f01c6cb2a1e787ad2080830f3952d7 Mon Sep 17 00:00:00 2001 From: april Date: Tue, 9 Jan 2024 14:41:21 -0600 Subject: [PATCH 39/66] Fix password updating --- api/routes/users.py | 5 +++-- api/schemas/user.py | 14 +++++++------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/api/routes/users.py b/api/routes/users.py index 03077a8..3e62e78 100644 --- a/api/routes/users.py +++ b/api/routes/users.py @@ -1,5 +1,5 @@ import logging -from fastapi import APIRouter, HTTPException, Depends +from fastapi import APIRouter, HTTPException, Depends, Request from pydantic import ValidationError from app.deps import get_current_user, admin_required @@ -111,7 +111,8 @@ async def update_profile(body: UserUpdateSchema, @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)): +async def update_password(body: PasswordUpdateSchema, + user: UserDisplaySchema = Depends(get_current_user)): """ Update the password of the currently logged-in user. Requires password confirmation diff --git a/api/schemas/user.py b/api/schemas/user.py index eec24bb..4bc7888 100644 --- a/api/schemas/user.py +++ b/api/schemas/user.py @@ -1,7 +1,7 @@ from enum import Enum from typing import Optional -from pydantic import BaseModel, Field, validator, field_validator +from pydantic import BaseModel, Field, field_validator def validate_username(value: str): @@ -56,12 +56,12 @@ class UserCreateSchema(UserBaseSchema): @field_validator("username") @classmethod def _valid_username(cls, value): - validate_username(value) + return validate_username(value) @field_validator("password") @classmethod def _valid_password(cls, value): - validate_password(value) + return validate_password(value) class UserUpdateSchema(BaseModel): @@ -71,7 +71,7 @@ class UserUpdateSchema(BaseModel): @field_validator("username") @classmethod def _valid_username(cls, value): - validate_username(value) + return validate_username(value) class UserDisplaySchema(UserBaseSchema): @@ -84,13 +84,13 @@ class UserSystemSchema(UserDisplaySchema): class PasswordUpdateSchema(BaseModel): - current_password: str - new_password: str + current_password: str = ... + new_password: str = ... @field_validator("new_password") @classmethod def _valid_password(cls, value): - validate_password(value) + return validate_password(value) class TokenSchema(BaseModel): From dafcacf28abedd6a3d24a050cc8ec0a96e5227c4 Mon Sep 17 00:00:00 2001 From: april Date: Tue, 9 Jan 2024 16:03:57 -0600 Subject: [PATCH 40/66] Add endpoints to get categories and classese --- api/routes/aircraft.py | 25 ++++++++++++++++++++++++- api/schemas/aircraft.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/api/routes/aircraft.py b/api/routes/aircraft.py index a63326f..86fa69c 100644 --- a/api/routes/aircraft.py +++ b/api/routes/aircraft.py @@ -4,7 +4,7 @@ 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.aircraft import AircraftDisplaySchema, AircraftCreateSchema, category_class from schemas.user import UserDisplaySchema, AuthLevel router = APIRouter() @@ -36,6 +36,29 @@ async def get_all_aircraft() -> list[AircraftDisplaySchema]: 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('/{aircraft_id}', summary="Get details of a given aircraft", response_model=AircraftDisplaySchema, status_code=200) async def get_aircraft_by_id(aircraft_id: str, diff --git a/api/schemas/aircraft.py b/api/schemas/aircraft.py index 07e60f0..9dd8e3a 100644 --- a/api/schemas/aircraft.py +++ b/api/schemas/aircraft.py @@ -6,6 +6,37 @@ 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" From c1050d82349d967ee18b1c5a123b670835707948 Mon Sep 17 00:00:00 2001 From: april Date: Wed, 10 Jan 2024 13:55:43 -0600 Subject: [PATCH 41/66] Remove tach field from aircraft --- api/database/aircraft.py | 17 ++++++++++++++++- api/routes/aircraft.py | 32 +++++++++++++++++++++++++++++--- api/schemas/aircraft.py | 1 - api/schemas/flight.py | 16 +++------------- 4 files changed, 48 insertions(+), 18 deletions(-) diff --git a/api/database/aircraft.py b/api/database/aircraft.py index 4303955..77dc2ea 100644 --- a/api/database/aircraft.py +++ b/api/database/aircraft.py @@ -23,11 +23,26 @@ async def retrieve_aircraft(user: str = "") -> list[AircraftDisplaySchema]: 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 id: ID of desired aircraft + :param tail_no: Tail number of desired aircraft :return: Aircraft details """ aircraft = await aircraft_collection.find_one({"_id": ObjectId(id)}) diff --git a/api/routes/aircraft.py b/api/routes/aircraft.py index 86fa69c..ac09d53 100644 --- a/api/routes/aircraft.py +++ b/api/routes/aircraft.py @@ -59,7 +59,7 @@ async def get_categories(category: str = "Airplane") -> dict: return {"classes": category_class[category]} -@router.get('/{aircraft_id}', summary="Get details of a given aircraft", response_model=AircraftDisplaySchema, +@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: @@ -71,6 +71,27 @@ async def get_aircraft_by_id(aircraft_id: str, :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") @@ -89,9 +110,14 @@ async def add_aircraft(aircraft_body: AircraftCreateSchema, :return: Error message if request invalid, else ID of newly created aircraft """ - aircraft = await db.insert_aircraft(aircraft_body, user.id) + 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)} + 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) diff --git a/api/schemas/aircraft.py b/api/schemas/aircraft.py index 9dd8e3a..0fff7cc 100644 --- a/api/schemas/aircraft.py +++ b/api/schemas/aircraft.py @@ -86,7 +86,6 @@ class AircraftCreateSchema(BaseModel): aircraft_class: AircraftClass hobbs: PositiveFloat - tach: PositiveFloat @field_validator('aircraft_class') def validate_class(cls, v: str, info: ValidationInfo, **kwargs): diff --git a/api/schemas/flight.py b/api/schemas/flight.py index f8688a9..bcfac4e 100644 --- a/api/schemas/flight.py +++ b/api/schemas/flight.py @@ -4,20 +4,18 @@ from typing import Optional, Dict, Union, List from bson import ObjectId from pydantic import BaseModel -from database.aircraft import retrieve_aircraft_by_id from schemas.utils import PositiveFloatNullable, PositiveFloat, PositiveInt, PyObjectId -class FlightSchema(BaseModel): +class FlightCreateSchema(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 - tach_start: Optional[PositiveFloatNullable] = None - tach_end: Optional[PositiveFloatNullable] = None time_start: Optional[datetime.datetime] = None time_off: Optional[datetime.datetime] = None @@ -53,14 +51,9 @@ class FlightSchema(BaseModel): comments: Optional[str] = None -class FlightCreateSchema(FlightSchema): - aircraft: str - - -class FlightDisplaySchema(FlightSchema): +class FlightDisplaySchema(FlightCreateSchema): user: PyObjectId id: PyObjectId - aircraft: PyObjectId class FlightConciseSchema(BaseModel): @@ -93,7 +86,6 @@ def flight_display_helper(flight: dict) -> dict: """ flight["id"] = str(flight["_id"]) flight["user"] = str(flight["user"]) - flight["aircraft"] = str(flight["aircraft"]) return flight @@ -107,7 +99,6 @@ async def flight_concise_helper(flight: dict) -> dict: """ flight["id"] = str(flight["_id"]) flight["user"] = str(flight["user"]) - flight["aircraft"] = (await retrieve_aircraft_by_id(str(flight["aircraft"]))).tail_no return flight @@ -121,6 +112,5 @@ def flight_add_helper(flight: dict, user: str) -> dict: :return: Combined dict that can be inserted into db """ flight["user"] = ObjectId(user) - flight["aircraft"] = ObjectId(flight["aircraft"]) return flight From ab126ceb06b251abb39ee9181efa675704580cc5 Mon Sep 17 00:00:00 2001 From: april Date: Thu, 11 Jan 2024 11:49:30 -0600 Subject: [PATCH 42/66] Implement flight filtering --- api/database/aircraft.py | 32 +++++++++++++++++++++++++-- api/database/flights.py | 48 ++++++++++++++++++++++++++++------------ api/routes/flights.py | 13 +++++++---- api/schemas/flight.py | 2 +- 4 files changed, 74 insertions(+), 21 deletions(-) diff --git a/api/database/aircraft.py b/api/database/aircraft.py index 77dc2ea..82f91c5 100644 --- a/api/database/aircraft.py +++ b/api/database/aircraft.py @@ -1,5 +1,8 @@ +from typing import Any + from bson import ObjectId from fastapi import HTTPException +from pymongo.errors import WriteError from database.db import aircraft_collection from schemas.aircraft import AircraftDisplaySchema, AircraftCreateSchema, aircraft_display_helper, aircraft_add_helper @@ -71,7 +74,7 @@ async def update_aircraft(body: AircraftCreateSchema, id: str) -> AircraftDispla :param body: Updated aircraft data :param id: ID of aircraft to update - :return: ID of updated aircraft + :return: Updated aircraft """ aircraft = await aircraft_collection.find_one({"_id": ObjectId(id)}) @@ -82,7 +85,32 @@ async def update_aircraft(body: AircraftCreateSchema, id: str) -> AircraftDispla if updated_aircraft is None: raise HTTPException(500, "Failed to update flight") - return id + return AircraftDisplaySchema(**body.model_dump()) + + +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": ObjectId(id)}) + + if aircraft is None: + raise HTTPException(404, "Aircraft not found") + + try: + updated_aircraft = await aircraft_collection.update_one({"_id": 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.model_dump()) async def delete_aircraft(id: str) -> AircraftDisplaySchema: diff --git a/api/database/flights.py b/api/database/flights.py index ae59ae5..7a104f5 100644 --- a/api/database/flights.py +++ b/api/database/flights.py @@ -1,10 +1,13 @@ import logging from datetime import datetime +from typing import Dict, Union from bson import ObjectId from bson.errors import InvalidId from fastapi import HTTPException +from schemas.aircraft import AircraftCreateSchema, aircraft_add_helper +from .aircraft import retrieve_aircraft_by_tail, update_aircraft, update_aircraft_field from .db import flight_collection from schemas.flight import FlightConciseSchema, FlightDisplaySchema, FlightCreateSchema, flight_display_helper, \ flight_add_helper @@ -12,22 +15,30 @@ from schemas.flight import FlightConciseSchema, FlightDisplaySchema, FlightCreat logger = logging.getLogger("api") -async def retrieve_flights(user: str = "", sort: str = "date", order: int = -1) -> list[FlightConciseSchema]: +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"] = ObjectId(user) + if filter != "": + filter_options[filter] = filter_val + + print(filter_options) + flights = [] - if user == "": - async for flight in flight_collection.find().sort({sort: order}): - flights.append(FlightConciseSchema(**flight_display_helper(flight))) - else: - async for flight in flight_collection.find({"user": ObjectId(user)}).sort({sort: order}): - flights.append(FlightConciseSchema(**flight_display_helper(flight))) + async for flight in flight_collection.find(filter_options).sort({sort: order}): + flights.append(FlightConciseSchema(**flight_display_helper(flight))) + return flights @@ -37,7 +48,7 @@ async def retrieve_totals(user: str, start_date: datetime = None, end_date: date :param user: :return: """ - match = {"user": ObjectId(user)} + match: Dict[str, Union[Dict, ObjectId]] = {"user": ObjectId(user)} if start_date is not None: match.setdefault("date", {}).setdefault("$gte", start_date) @@ -122,19 +133,22 @@ async def insert_flight(body: FlightCreateSchema, id: str) -> ObjectId: :param id: ID of creating user :return: ID of inserted flight """ - try: - aircraft = await flight_collection.find_one({"_id": ObjectId(body.aircraft)}) - except InvalidId: - raise HTTPException(400, "Invalid aircraft ID") + 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 > 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) -> FlightDisplaySchema: +async def update_flight(body: FlightCreateSchema, id: str) -> str: """ Update given flight in the database @@ -147,12 +161,18 @@ async def update_flight(body: FlightCreateSchema, id: str) -> FlightDisplaySchem if flight is None: raise HTTPException(404, "Flight not found") - aircraft = await flight_collection.find_ond({"_id": ObjectId(body.aircraft)}) + 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 > 0 and body.hobbs_end != aircraft.hobbs: + await update_aircraft_field("hobbs", body.hobbs_end, aircraft.id) + + # Update flight in database updated_flight = await flight_collection.update_one({"_id": ObjectId(id)}, {"$set": body.model_dump()}) + if updated_flight is None: raise HTTPException(500, "Failed to update flight") diff --git a/api/routes/flights.py b/api/routes/flights.py index ee968d5..fc6a0cf 100644 --- a/api/routes/flights.py +++ b/api/routes/flights.py @@ -15,7 +15,8 @@ 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) -> list[ +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 @@ -23,25 +24,29 @@ async def get_flights(user: UserDisplaySchema = Depends(get_current_user), sort: :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) + 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) -> dict: + 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) + flights = await db.retrieve_flights(user.id, sort, order, filter, filter_val) flights_ordered: FlightByDateSchema = {} for flight in flights: diff --git a/api/schemas/flight.py b/api/schemas/flight.py index bcfac4e..4a02c63 100644 --- a/api/schemas/flight.py +++ b/api/schemas/flight.py @@ -71,7 +71,7 @@ class FlightConciseSchema(BaseModel): comments: Optional[str] = None -FlightByDateSchema = Dict[int, Union[List['FlightByDateSchema'], FlightConciseSchema]] +FlightByDateSchema = Dict[int, Union[Dict[int, 'FlightByDateSchema'], FlightConciseSchema]] # HELPERS # From cf9784b770b7972c6b4275d644608330129ace06 Mon Sep 17 00:00:00 2001 From: april Date: Thu, 11 Jan 2024 12:31:51 -0600 Subject: [PATCH 43/66] Validate custom filters --- api/database/flights.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/api/database/flights.py b/api/database/flights.py index 7a104f5..f2e27b9 100644 --- a/api/database/flights.py +++ b/api/database/flights.py @@ -27,6 +27,9 @@ async def retrieve_flights(user: str = "", sort: str = "date", order: int = -1, :param filter_val: Value to filter field by :return: List of flights """ + if filter not in FlightDisplaySchema.__annotations__.keys(): + raise HTTPException(400, f"Invalid filter field: {filter}") + filter_options = {} if user != "": filter_options["user"] = ObjectId(user) From 03ea9d0235ee97b00c89b55a5224f5d0220416cc Mon Sep 17 00:00:00 2001 From: april Date: Thu, 11 Jan 2024 13:12:46 -0600 Subject: [PATCH 44/66] Fix filter validation --- api/database/flights.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/api/database/flights.py b/api/database/flights.py index f2e27b9..8f187f3 100644 --- a/api/database/flights.py +++ b/api/database/flights.py @@ -27,17 +27,16 @@ async def retrieve_flights(user: str = "", sort: str = "date", order: int = -1, :param filter_val: Value to filter field by :return: List of flights """ - if filter not in FlightDisplaySchema.__annotations__.keys(): - raise HTTPException(400, f"Invalid filter field: {filter}") - filter_options = {} if user != "": filter_options["user"] = ObjectId(user) - if filter != "": + 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 - print(filter_options) - flights = [] async for flight in flight_collection.find(filter_options).sort({sort: order}): flights.append(FlightConciseSchema(**flight_display_helper(flight))) From 4122521aa3953cd799520dd8e9acb93a7b391023 Mon Sep 17 00:00:00 2001 From: april Date: Thu, 11 Jan 2024 14:54:14 -0600 Subject: [PATCH 45/66] Fix aircraft updating --- api/database/aircraft.py | 15 +++++++++++---- api/routes/aircraft.py | 2 +- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/api/database/aircraft.py b/api/database/aircraft.py index 82f91c5..1619057 100644 --- a/api/database/aircraft.py +++ b/api/database/aircraft.py @@ -68,12 +68,13 @@ async def insert_aircraft(body: AircraftCreateSchema, id: str) -> ObjectId: return aircraft.inserted_id -async def update_aircraft(body: AircraftCreateSchema, id: str) -> AircraftDisplaySchema: +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": ObjectId(id)}) @@ -81,11 +82,17 @@ async def update_aircraft(body: AircraftCreateSchema, id: str) -> AircraftDispla if aircraft is None: raise HTTPException(404, "Aircraft not found") - updated_aircraft = await aircraft_collection.update_one({"_id": ObjectId(id)}, {"$set": body.model_dump()}) + updated_aircraft = await aircraft_collection.update_one({"_id": ObjectId(id)}, + {"$set": aircraft_add_helper(body.model_dump(), user)}) if updated_aircraft is None: - raise HTTPException(500, "Failed to update flight") + raise HTTPException(500, "Failed to update aircraft") - return AircraftDisplaySchema(**body.model_dump()) + aircraft = await aircraft_collection.find_one({"_id": 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: diff --git a/api/routes/aircraft.py b/api/routes/aircraft.py index ac09d53..453fbe7 100644 --- a/api/routes/aircraft.py +++ b/api/routes/aircraft.py @@ -139,7 +139,7 @@ async def update_aircraft(aircraft_id: str, aircraft_body: AircraftCreateSchema, 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) + updated_aircraft_id = await db.update_aircraft(aircraft_body, aircraft_id, user.id) return {"id": str(updated_aircraft_id)} From e1f21de98f5d1bea9f0ba8dc665c55fdafb23ebc Mon Sep 17 00:00:00 2001 From: april Date: Thu, 11 Jan 2024 16:21:22 -0600 Subject: [PATCH 46/66] Extend stats to include totals by class --- api/database/flights.py | 105 +++++++++++++++++++++------------------- 1 file changed, 54 insertions(+), 51 deletions(-) diff --git a/api/database/flights.py b/api/database/flights.py index 8f187f3..d711a5b 100644 --- a/api/database/flights.py +++ b/api/database/flights.py @@ -6,9 +6,9 @@ from bson import ObjectId from bson.errors import InvalidId from fastapi import HTTPException -from schemas.aircraft import AircraftCreateSchema, aircraft_add_helper -from .aircraft import retrieve_aircraft_by_tail, update_aircraft, update_aircraft_field -from .db import flight_collection +from schemas.aircraft import AircraftCreateSchema, aircraft_add_helper, AircraftCategory, AircraftClass +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 @@ -57,59 +57,62 @@ async def retrieve_totals(user: str, start_date: datetime = None, end_date: date if end_date is not None: match.setdefault("date", {}).setdefault("$lte", end_date) - cursor = flight_collection.aggregate([ - {"$match": match}, + pipeline = [ + {"$match": {"user": ObjectId(user)}}, + {"$lookup": { + "from": "flight", + "let": {"aircraft": "$tail_no"}, + "pipeline": [{"$match": {"$expr": {"$eq": ["$$aircraft", "$aircraft"]}}}], + "as": "flight_data" + }}, + {"$unwind": "$flight_data"}, {"$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"}, + "_id": "$aircraft_class", + "time_total": {"$sum": "$flight_data.time_total"} + }}, + {"$project": { + "_id": 0, + "aircraft_class": "$_id", + "time_total": 1 + }}, + {"$facet": { + "by_class": [{"$match": {}}], + "totals": [ + {"$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}}, + ] + }}, + {"$project": { + "by_class": 1, + "totals": {"$arrayElemAt": ["$totals", 0]} + }} + ] - } - }, - {"$project": {"_id": 0}}, - ]) + cursor = aircraft_collection.aggregate(pipeline) - result = await cursor.to_list(length=None) + result = await cursor.to_list(None) if not result: - return { - "time_total": 0.0, - "time_solo": 0.0, - "time_night": 0.0, - "time_pic": 0.0, - "time_sic": 0.0, - "time_instrument": 0.0, - "time_sim": 0.0, - "time_xc": 0.0, - "landings_day": 0, - "landings_night": 0, - "xc_dual_recvd": 0.0, - "xc_solo": 0.0, - "xc_pic": 0.0, - "night_dual_recvd": 0.0, - "night_pic": 0.0 + return {} - } - - totals = result[0] - async for log in flight_collection.find({"user": ObjectId(user)}): - flight = FlightDisplaySchema(**flight_display_helper(log)) - totals["xc_dual_recvd"] = totals.get("xc_dual_recvd", 0) + min(flight.time_xc, flight.dual_recvd) - totals["xc_solo"] = totals.get("xc_solo", 0) + min(flight.time_xc, flight.time_solo) - totals["xc_pic"] = totals.get("xc_pic", 0) + min(flight.time_xc, flight.time_pic) - totals["night_dual_recvd"] = totals.get("night_dual_recvd", 0) + min(flight.time_night, - flight.dual_recvd) - totals["night_pic"] = totals.get("night_pic", 0) + min(flight.time_night, flight.time_pic) - - return totals + return dict(result[0]) async def retrieve_flight(id: str) -> FlightDisplaySchema: @@ -141,7 +144,7 @@ async def insert_flight(body: FlightCreateSchema, id: str) -> ObjectId: raise HTTPException(404, "Aircraft not found") # Update hobbs of aircraft to reflect new hobbs end - if body.hobbs_end > 0 and body.hobbs_end != aircraft.hobbs: + 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 From e7aead4063b6a6f1e3645b7b555f0eef6499995f Mon Sep 17 00:00:00 2001 From: april Date: Thu, 11 Jan 2024 16:40:19 -0600 Subject: [PATCH 47/66] Make totals by class use human-readable names --- api/database/flights.py | 16 ++++++++++++---- api/schemas/aircraft.py | 3 +++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/api/database/flights.py b/api/database/flights.py index d711a5b..395a78c 100644 --- a/api/database/flights.py +++ b/api/database/flights.py @@ -6,7 +6,8 @@ from bson import ObjectId from bson.errors import InvalidId from fastapi import HTTPException -from schemas.aircraft import AircraftCreateSchema, aircraft_add_helper, AircraftCategory, AircraftClass +from schemas.aircraft import AircraftCreateSchema, aircraft_add_helper, AircraftCategory, AircraftClass, \ + aircraft_class_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, \ @@ -107,12 +108,19 @@ async def retrieve_totals(user: str, start_date: datetime = None, end_date: date cursor = aircraft_collection.aggregate(pipeline) - result = await cursor.to_list(None) + result_list = await cursor.to_list(None) - if not result: + if not result_list: return {} - return dict(result[0]) + result = dict(result_list[0]) + + print(aircraft_class_dict) + + for entry in result["by_class"]: + entry["aircraft_class"] = aircraft_class_dict[entry["aircraft_class"]] + + return result async def retrieve_flight(id: str) -> FlightDisplaySchema: diff --git a/api/schemas/aircraft.py b/api/schemas/aircraft.py index 0fff7cc..cb219a7 100644 --- a/api/schemas/aircraft.py +++ b/api/schemas/aircraft.py @@ -78,6 +78,9 @@ class AircraftClass(Enum): wss = "Weight-Shift Control Sea" +aircraft_class_dict = {cls.name: cls.value for cls in AircraftClass} + + class AircraftCreateSchema(BaseModel): tail_no: str make: str From e0c342be011f4e7744dec03d27eaff376194f7bd Mon Sep 17 00:00:00 2001 From: april Date: Thu, 11 Jan 2024 17:05:35 -0600 Subject: [PATCH 48/66] Change stats to return class totals as nested dict --- api/database/flights.py | 26 ++++++++++++++++++-------- api/schemas/aircraft.py | 3 +++ 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/api/database/flights.py b/api/database/flights.py index 395a78c..4c9a3e2 100644 --- a/api/database/flights.py +++ b/api/database/flights.py @@ -7,7 +7,7 @@ from bson.errors import InvalidId from fastapi import HTTPException from schemas.aircraft import AircraftCreateSchema, aircraft_add_helper, AircraftCategory, AircraftClass, \ - aircraft_class_dict + 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, \ @@ -68,13 +68,21 @@ async def retrieve_totals(user: str, start_date: datetime = None, end_date: date }}, {"$unwind": "$flight_data"}, {"$group": { - "_id": "$aircraft_class", - "time_total": {"$sum": "$flight_data.time_total"} + # "_id": "$aircraft_category", + "_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_class": "$_id", - "time_total": 1 + "aircraft_category": "$_id", + "classes": 1, }}, {"$facet": { "by_class": [{"$match": {}}], @@ -115,10 +123,12 @@ async def retrieve_totals(user: str, start_date: datetime = None, end_date: date result = dict(result_list[0]) - print(aircraft_class_dict) + # for entry in result["by_class"]: + # entry["aircraft_category"] = aircraft_category_dict[entry["aircraft_category"]] + # for cls in entry["classes"]: + # cls["aircraft_class"] = aircraft_class_dict[cls["aircraft_class"]] - for entry in result["by_class"]: - entry["aircraft_class"] = aircraft_class_dict[entry["aircraft_class"]] + print(result) return result diff --git a/api/schemas/aircraft.py b/api/schemas/aircraft.py index cb219a7..9051b84 100644 --- a/api/schemas/aircraft.py +++ b/api/schemas/aircraft.py @@ -48,6 +48,9 @@ class AircraftCategory(Enum): weight_shift = "Weight-Shift Control" +aircraft_category_dict = {cls.name: cls.value for cls in AircraftCategory} + + class AircraftClass(Enum): # Airplane sel = "Single-Engine Land" From e3b7c15f2a7b060895369e72149f0391f273ed91 Mon Sep 17 00:00:00 2001 From: april Date: Thu, 11 Jan 2024 17:06:21 -0600 Subject: [PATCH 49/66] Reimplement human-readable class/category names in totals --- api/database/flights.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/api/database/flights.py b/api/database/flights.py index 4c9a3e2..d1a348e 100644 --- a/api/database/flights.py +++ b/api/database/flights.py @@ -123,10 +123,10 @@ async def retrieve_totals(user: str, start_date: datetime = None, end_date: date result = dict(result_list[0]) - # for entry in result["by_class"]: - # entry["aircraft_category"] = aircraft_category_dict[entry["aircraft_category"]] - # for cls in entry["classes"]: - # cls["aircraft_class"] = aircraft_class_dict[cls["aircraft_class"]] + for entry in result["by_class"]: + entry["aircraft_category"] = aircraft_category_dict[entry["aircraft_category"]] + for cls in entry["classes"]: + cls["aircraft_class"] = aircraft_class_dict[cls["aircraft_class"]] print(result) From 7a0ea052f19eac28b65f8a66ec96ab3528f848d5 Mon Sep 17 00:00:00 2001 From: april Date: Fri, 12 Jan 2024 13:35:14 -0600 Subject: [PATCH 50/66] Fix totals returning 0 every time --- api/database/flights.py | 98 +++++++++++++++++++++++------------------ 1 file changed, 55 insertions(+), 43 deletions(-) diff --git a/api/database/flights.py b/api/database/flights.py index d1a348e..38b5c34 100644 --- a/api/database/flights.py +++ b/api/database/flights.py @@ -58,77 +58,89 @@ async def retrieve_totals(user: str, start_date: datetime = None, end_date: date if end_date is not None: match.setdefault("date", {}).setdefault("$lte", end_date) - pipeline = [ + by_class_pipeline = [ {"$match": {"user": ObjectId(user)}}, {"$lookup": { "from": "flight", "let": {"aircraft": "$tail_no"}, - "pipeline": [{"$match": {"$expr": {"$eq": ["$$aircraft", "$aircraft"]}}}], + "pipeline": [ + {"$match": { + "$expr": { + "$eq": ["$$aircraft", "$aircraft"] + } + }} + ], "as": "flight_data" }}, {"$unwind": "$flight_data"}, {"$group": { - # "_id": "$aircraft_category", - "_id": {"aircraft_category": "$aircraft_category", "aircraft_class": "$aircraft_class"}, - "time_total": {"$sum": "$flight_data.time_total"}, + "_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", - }}, + "classes": { + "$push": { + "aircraft_class": "$_id.aircraft_class", + "time_total": "$time_total", + } + }, }}, {"$project": { "_id": 0, "aircraft_category": "$_id", "classes": 1, }}, - {"$facet": { - "by_class": [{"$match": {}}], - "totals": [ - {"$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}}, - ] - }}, - {"$project": { - "by_class": 1, - "totals": {"$arrayElemAt": ["$totals", 0]} - }} ] - cursor = aircraft_collection.aggregate(pipeline) + class_cursor = aircraft_collection.aggregate(by_class_pipeline) + by_class_list = await class_cursor.to_list(None) - result_list = await cursor.to_list(None) + totals_pipeline = [ + {"$match": {"user": 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}}, + ] - if not result_list: + 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 {} - result = dict(result_list[0]) + totals_dict = dict(totals_list[0]) - for entry in result["by_class"]: + 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"]] - print(result) + result = { + "by_class": by_class_list, + "totals": totals_dict + } return result From 5ab412d82ac506365477115aeca8f2778506147c Mon Sep 17 00:00:00 2001 From: april Date: Fri, 12 Jan 2024 15:48:36 -0600 Subject: [PATCH 51/66] Implement image uploads and flight patching --- api/app/api.py | 3 +- api/database/db.py | 1 + api/database/flights.py | 48 ++++++++++++++++++++++-- api/database/img.py | 82 +++++++++++++++++++++++++++++++++++++++++ api/routes/flights.py | 68 +++++++++++++++++++++++++++++++--- api/routes/img.py | 69 ++++++++++++++++++++++++++++++++++ api/schemas/flight.py | 61 +++++++++++++++++++++++++++--- 7 files changed, 316 insertions(+), 16 deletions(-) create mode 100644 api/database/img.py create mode 100644 api/routes/img.py diff --git a/api/app/api.py b/api/app/api.py index 6ebe0f5..e28ef0e 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, 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") diff --git a/api/database/db.py b/api/database/db.py index f9199a4..b6fded3 100644 --- a/api/database/db.py +++ b/api/database/db.py @@ -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"] diff --git a/api/database/flights.py b/api/database/flights.py index 38b5c34..88840dd 100644 --- a/api/database/flights.py +++ b/api/database/flights.py @@ -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 diff --git a/api/database/img.py b/api/database/img.py new file mode 100644 index 0000000..0eef60f --- /dev/null +++ b/api/database/img.py @@ -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 diff --git a/api/routes/flights.py b/api/routes/flights.py index fc6a0cf..499faa4 100644 --- a/api/routes/flights.py +++ b/api/routes/flights.py @@ -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: """ diff --git a/api/routes/img.py b/api/routes/img.py new file mode 100644 index 0000000..a0287e8 --- /dev/null +++ b/api/routes/img.py @@ -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) diff --git a/api/schemas/flight.py b/api/schemas/flight.py index 4a02c63..f7ce316 100644 --- a/api/schemas/flight.py +++ b/api/schemas/flight.py @@ -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 From d6a0eb349a6f3695ece8f9da3193ac1bfbca72cc Mon Sep 17 00:00:00 2001 From: april Date: Mon, 15 Jan 2024 09:01:21 -0600 Subject: [PATCH 52/66] Handle ObjectID conversion errors --- api/database/aircraft.py | 22 +++++++++++----------- api/database/flights.py | 36 ++++++++++++++++++------------------ api/database/img.py | 8 ++++---- api/database/users.py | 18 ++++++++++-------- api/database/utils.py | 1 + api/schemas/aircraft.py | 4 ++-- api/schemas/flight.py | 6 ++---- api/utils.py | 17 +++++++++++++++++ 8 files changed, 65 insertions(+), 47 deletions(-) create mode 100644 api/utils.py diff --git a/api/database/aircraft.py b/api/database/aircraft.py index 1619057..092e87b 100644 --- a/api/database/aircraft.py +++ b/api/database/aircraft.py @@ -1,10 +1,10 @@ from typing import Any -from bson import ObjectId 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 @@ -20,7 +20,7 @@ async def retrieve_aircraft(user: str = "") -> list[AircraftDisplaySchema]: 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)}): + async for doc in aircraft_collection.find({"user": to_objectid(user)}): aircraft.append(AircraftDisplaySchema(**aircraft_display_helper(doc))) return aircraft @@ -48,7 +48,7 @@ async def retrieve_aircraft_by_id(id: str) -> AircraftDisplaySchema: :param tail_no: Tail number of desired aircraft :return: Aircraft details """ - aircraft = await aircraft_collection.find_one({"_id": ObjectId(id)}) + aircraft = await aircraft_collection.find_one({"_id": to_objectid(id)}) if aircraft is None: raise HTTPException(404, "Aircraft not found") @@ -56,7 +56,7 @@ async def retrieve_aircraft_by_id(id: str) -> AircraftDisplaySchema: return AircraftDisplaySchema(**aircraft_display_helper(aircraft)) -async def insert_aircraft(body: AircraftCreateSchema, id: str) -> ObjectId: +async def insert_aircraft(body: AircraftCreateSchema, id: str) -> to_objectid: """ Insert a new aircraft into the database @@ -77,17 +77,17 @@ async def update_aircraft(body: AircraftCreateSchema, id: str, user: str) -> Air :param user: ID of updating user :return: Updated aircraft """ - aircraft = await aircraft_collection.find_one({"_id": ObjectId(id)}) + 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": ObjectId(id)}, + 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": ObjectId(id)}) + aircraft = await aircraft_collection.find_one({"_id": to_objectid(id)}) if aircraft is None: raise HTTPException(500, "Failed to fetch updated aircraft") @@ -104,13 +104,13 @@ async def update_aircraft_field(field: str, value: Any, id: str) -> AircraftDisp :param id: ID of aircraft to update :return: Updated aircraft """ - aircraft = await aircraft_collection.find_one({"_id": ObjectId(id)}) + 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": ObjectId(id)}, {"$set": {field: value}}) + updated_aircraft = await aircraft_collection.update_one({"_id": to_objectid(id)}, {"$set": {field: value}}) except WriteError as e: raise HTTPException(400, e.details) @@ -127,10 +127,10 @@ async def delete_aircraft(id: str) -> AircraftDisplaySchema: :param id: ID of aircraft to delete :return: Deleted aircraft information """ - aircraft = await aircraft_collection.find_one({"_id": ObjectId(id)}) + 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": ObjectId(id)}) + await aircraft_collection.delete_one({"_id": to_objectid(id)}) return AircraftDisplaySchema(**aircraft_display_helper(aircraft)) diff --git a/api/database/flights.py b/api/database/flights.py index 88840dd..ffa8039 100644 --- a/api/database/flights.py +++ b/api/database/flights.py @@ -1,15 +1,15 @@ import logging from datetime import datetime -from typing import Dict, Union, Any, get_args, List, get_origin, _type_check, get_type_hints +from typing import Dict, Union 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 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 @@ -34,7 +34,7 @@ async def retrieve_flights(user: str = "", sort: str = "date", order: int = -1, """ filter_options = {} if user != "": - filter_options["user"] = ObjectId(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}") @@ -53,7 +53,7 @@ async def retrieve_totals(user: str, start_date: datetime = None, end_date: date :param user: :return: """ - match: Dict[str, Union[Dict, ObjectId]] = {"user": ObjectId(user)} + match: Dict[str, Union[Dict, ObjectId]] = {"user": to_objectid(user)} if start_date is not None: match.setdefault("date", {}).setdefault("$gte", start_date) @@ -61,7 +61,7 @@ async def retrieve_totals(user: str, start_date: datetime = None, end_date: date match.setdefault("date", {}).setdefault("$lte", end_date) by_class_pipeline = [ - {"$match": {"user": ObjectId(user)}}, + {"$match": {"user": to_objectid(user)}}, {"$lookup": { "from": "flight", "let": {"aircraft": "$tail_no"}, @@ -104,7 +104,7 @@ async def retrieve_totals(user: str, start_date: datetime = None, end_date: date by_class_list = await class_cursor.to_list(None) totals_pipeline = [ - {"$match": {"user": ObjectId(user)}}, + {"$match": {"user": to_objectid(user)}}, {"$group": { "_id": None, "time_total": {"$sum": "$time_total"}, @@ -154,7 +154,7 @@ async def retrieve_flight(id: str) -> FlightDisplaySchema: :param id: ID of flight to retrieve :return: Flight information """ - flight = await flight_collection.find_one({"_id": ObjectId(id)}) + flight = await flight_collection.find_one({"_id": to_objectid(id)}) if flight is None: raise HTTPException(404, "Flight not found") @@ -193,7 +193,7 @@ async def update_flight(body: FlightCreateSchema, id: str) -> str: :param id: ID of flight to update :return: ID of updated flight """ - flight = await flight_collection.find_one({"_id": ObjectId(id)}) + flight = await flight_collection.find_one({"_id": to_objectid(id)}) if flight is None: raise HTTPException(404, "Flight not found") @@ -208,7 +208,7 @@ async def update_flight(body: FlightCreateSchema, id: str) -> str: await update_aircraft_field("hobbs", body.hobbs_end, aircraft.id) # Update flight in database - updated_flight = await flight_collection.update_one({"_id": ObjectId(id)}, {"$set": body.model_dump()}) + 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") @@ -228,7 +228,7 @@ async def update_flight_fields(id: str, update: dict) -> str: if field not in fs_keys: raise HTTPException(400, f"Invalid update field: {field}") - flight = await flight_collection.find_one({"_id": ObjectId(id)}) + flight = await flight_collection.find_one({"_id": to_objectid(id)}) if flight is None: raise HTTPException(404, "Flight not found") @@ -246,7 +246,7 @@ async def update_flight_fields(id: str, update: dict) -> str: if aircraft is None: raise HTTPException(404, "Aircraft not found") - updated_flight = await flight_collection.update_one({"_id": ObjectId(id)}, {"$set": update_dict}) + 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") @@ -261,10 +261,10 @@ async def delete_flight(id: str) -> FlightDisplaySchema: :param id: ID of flight to delete :return: Deleted flight information """ - flight = await flight_collection.find_one({"_id": ObjectId(id)}) + flight = await flight_collection.find_one({"_id": to_objectid(id)}) if flight is None: raise HTTPException(404, "Flight not found") - await flight_collection.delete_one({"_id": ObjectId(id)}) + await flight_collection.delete_one({"_id": to_objectid(id)}) return FlightDisplaySchema(**flight_display_helper(flight)) diff --git a/api/database/img.py b/api/database/img.py index 0eef60f..52e8b13 100644 --- a/api/database/img.py +++ b/api/database/img.py @@ -5,7 +5,7 @@ from gridfs import NoFile from .db import db_client as db, files_collection import motor.motor_asyncio -from bson import ObjectId +from utils import to_objectid from fastapi import UploadFile, File, HTTPException fs = motor.motor_asyncio.AsyncIOMotorGridFSBucket(db) @@ -35,7 +35,7 @@ async def retrieve_image_metadata(image_id: str = "") -> dict: :param image_id: ID of image to retrieve metadata of :return: Image metadata """ - info = await files_collection.find_one({"_id": ObjectId(image_id)}) + info = await files_collection.find_one({"_id": to_objectid(image_id)}) if info is None: raise HTTPException(404, "Image not found") @@ -56,7 +56,7 @@ async def retrieve_image(image_id: str = "") -> tuple[io.BytesIO, str]: stream = io.BytesIO() try: - await fs.download_to_stream(ObjectId(image_id), stream) + await fs.download_to_stream(to_objectid(image_id), stream) except NoFile: raise HTTPException(404, "Image not found") @@ -73,7 +73,7 @@ async def delete_image(image_id: str = ""): :return: True if deleted """ try: - await fs.delete(ObjectId(image_id)) + await fs.delete(to_objectid(image_id)) except NoFile: raise HTTPException(404, "Image not found") except Exception as e: diff --git a/api/database/users.py b/api/database/users.py index 7d32614..e502ff8 100644 --- a/api/database/users.py +++ b/api/database/users.py @@ -1,6 +1,8 @@ import logging from bson import ObjectId + +from utils import to_objectid from fastapi import HTTPException from .db import user_collection, flight_collection @@ -41,7 +43,7 @@ async def get_user_info_id(id: str) -> UserDisplaySchema: :param id: ID of user to retrieve :return: User information """ - user = await user_collection.find_one({"_id": ObjectId(id)}) + user = await user_collection.find_one({"_id": to_objectid(id)}) if user: return UserDisplaySchema(**user_helper(user)) @@ -65,7 +67,7 @@ async def get_user_system_info_id(id: str) -> UserSystemSchema: :param id: ID of user to retrieve :return: User information and password """ - user = await user_collection.find_one({"_id": ObjectId(id)}) + user = await user_collection.find_one({"_id": to_objectid(id)}) if user: return UserSystemSchema(**system_user_helper(user)) @@ -89,15 +91,15 @@ async def delete_user(id: str) -> UserDisplaySchema: :param id: ID of user to delete :return: Information of deleted user """ - user = await user_collection.find_one({"_id": ObjectId(id)}) + 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": ObjectId(id)}) + await user_collection.delete_one({"_id": to_objectid(id)}) # Delete all flights associated with user - await flight_collection.delete_many({"user": ObjectId(id)}) + await flight_collection.delete_many({"user": to_objectid(id)}) return UserDisplaySchema(**user_helper(user)) @@ -127,12 +129,12 @@ async def edit_profile(user_id: str, username: str = None, password: str = None, raise HTTPException(403, "Unauthorized attempt to change auth level") if username: - user_collection.update_one({"_id": ObjectId(user_id)}, {"$set": {"username": 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": ObjectId(user_id)}, {"$set": {"password": hashed_password}}) + user_collection.update_one({"_id": to_objectid(user_id)}, {"$set": {"password": hashed_password}}) if auth_level: - user_collection.update_one({"_id": ObjectId(user_id)}, {"$set": {"level": 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 diff --git a/api/database/utils.py b/api/database/utils.py index 51839d4..862fcb5 100644 --- a/api/database/utils.py +++ b/api/database/utils.py @@ -11,6 +11,7 @@ logger = logging.getLogger("api") # UTILS # + async def create_admin_user(): """ Create default admin user if no admin users are present in the database diff --git a/api/schemas/aircraft.py b/api/schemas/aircraft.py index 9051b84..ac4ec6e 100644 --- a/api/schemas/aircraft.py +++ b/api/schemas/aircraft.py @@ -1,6 +1,6 @@ from enum import Enum -from bson import ObjectId +from utils import to_objectid from pydantic import BaseModel, field_validator from pydantic_core.core_schema import ValidationInfo @@ -145,7 +145,7 @@ def aircraft_add_helper(aircraft: dict, user: str) -> dict: :param user: User that created aircraft :return: Combined dict that can be inserted into db """ - aircraft["user"] = ObjectId(user) + aircraft["user"] = to_objectid(user) aircraft["aircraft_category"] = aircraft["aircraft_category"].name aircraft["aircraft_class"] = aircraft["aircraft_class"].name diff --git a/api/schemas/flight.py b/api/schemas/flight.py index f7ce316..fd01315 100644 --- a/api/schemas/flight.py +++ b/api/schemas/flight.py @@ -1,9 +1,7 @@ import datetime -import typing from typing import Optional, Dict, Union, List -from bson import ObjectId -from fastapi import UploadFile, File +from utils import to_objectid from pydantic import BaseModel from schemas.utils import PositiveFloatNullable, PositiveFloat, PositiveInt, PyObjectId @@ -162,6 +160,6 @@ def flight_add_helper(flight: dict, user: str) -> dict: :param user: User that created flight :return: Combined dict that can be inserted into db """ - flight["user"] = ObjectId(user) + flight["user"] = to_objectid(user) return flight diff --git a/api/utils.py b/api/utils.py new file mode 100644 index 0000000..7ac8a5e --- /dev/null +++ b/api/utils.py @@ -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") From 204a12145d82b824021c6215981cddae6765fd21 Mon Sep 17 00:00:00 2001 From: april Date: Mon, 15 Jan 2024 10:48:45 -0600 Subject: [PATCH 53/66] Fix add_images endpoint --- api/database/img.py | 15 +++++++++------ api/routes/flights.py | 6 +++--- api/routes/img.py | 7 ++----- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/api/database/img.py b/api/database/img.py index 52e8b13..42d32d8 100644 --- a/api/database/img.py +++ b/api/database/img.py @@ -1,4 +1,6 @@ import io +import mimetypes +import os from gridfs import NoFile @@ -40,20 +42,21 @@ async def retrieve_image_metadata(image_id: str = "") -> dict: if info is None: raise HTTPException(404, "Image not found") - return info["metadata"] + 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]: +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 + :return: BytesIO stream of image file, ID of user that uploaded the image, file type """ metadata = await retrieve_image_metadata(image_id) - print(metadata) - stream = io.BytesIO() try: await fs.download_to_stream(to_objectid(image_id), stream) @@ -62,7 +65,7 @@ async def retrieve_image(image_id: str = "") -> tuple[io.BytesIO, str]: stream.seek(0) - return stream, metadata["user"] if metadata["user"] else "" + return stream, metadata["user"] if metadata["user"] else "", metadata["contentType"] async def delete_image(image_id: str = ""): diff --git a/api/routes/flights.py b/api/routes/flights.py index 499faa4..330c39e 100644 --- a/api/routes/flights.py +++ b/api/routes/flights.py @@ -1,6 +1,6 @@ import logging from datetime import datetime -from typing import Any +from typing import Any, List from fastapi import APIRouter, HTTPException, Depends, Form, UploadFile, File @@ -130,8 +130,8 @@ async def add_flight(flight_body: FlightSchema, user: UserDisplaySchema = Depend 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(...), +@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 diff --git a/api/routes/img.py b/api/routes/img.py index a0287e8..1df3f4d 100644 --- a/api/routes/img.py +++ b/api/routes/img.py @@ -24,15 +24,12 @@ async def get_image(user: UserDisplaySchema = Depends(get_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) + 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") - file_extension = os.path.splitext(image_id)[1] - media_type = mimetypes.types_map.get(file_extension) - - return StreamingResponse(stream, media_type=media_type) + return StreamingResponse(stream, headers={'Content-Type': media_type}) @router.post("/upload", description="Upload an image to the database") From ffc6f67f66286b9fe890514ee361f06122bc92f4 Mon Sep 17 00:00:00 2001 From: april Date: Mon, 15 Jan 2024 14:31:37 -0600 Subject: [PATCH 54/66] Fix accepted flight fields --- api/database/flights.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/database/flights.py b/api/database/flights.py index ffa8039..b491a07 100644 --- a/api/database/flights.py +++ b/api/database/flights.py @@ -16,8 +16,7 @@ from schemas.flight import FlightConciseSchema, FlightDisplaySchema, FlightCreat logger = logging.getLogger("api") -fs_keys = list(FlightCreateSchema.__annotations__.keys()) -fs_keys.extend(list(FlightDisplaySchema.__annotations__.keys())) +fs_keys = list(FlightPatchSchema.__annotations__.keys()) + list(FlightDisplaySchema.__annotations__.keys()) async def retrieve_flights(user: str = "", sort: str = "date", order: int = -1, filter: str = "", @@ -224,6 +223,7 @@ async def update_flight_fields(id: str, update: dict) -> str: :param update: Dictionary of fields and values to update :return: ID of updated flight """ + print(fs_keys) for field in update.keys(): if field not in fs_keys: raise HTTPException(400, f"Invalid update field: {field}") From cf157656f42e1ea940c7861e1d9a7846c3581ce7 Mon Sep 17 00:00:00 2001 From: april Date: Mon, 15 Jan 2024 15:27:39 -0600 Subject: [PATCH 55/66] Handle missing hobbs value --- api/database/flights.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/database/flights.py b/api/database/flights.py index b491a07..b50c7ed 100644 --- a/api/database/flights.py +++ b/api/database/flights.py @@ -203,7 +203,7 @@ async def update_flight(body: FlightCreateSchema, id: str) -> str: raise HTTPException(404, "Aircraft not found") # Update hobbs of aircraft to reflect new hobbs end - if body.hobbs_end > 0 and body.hobbs_end != aircraft.hobbs: + 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 From bb2388fac88aabb34f5fe4de76bc9f1fd062477c Mon Sep 17 00:00:00 2001 From: april Date: Mon, 15 Jan 2024 16:58:44 -0600 Subject: [PATCH 56/66] Remove debug log --- api/database/flights.py | 1 - 1 file changed, 1 deletion(-) diff --git a/api/database/flights.py b/api/database/flights.py index b50c7ed..d3c319b 100644 --- a/api/database/flights.py +++ b/api/database/flights.py @@ -223,7 +223,6 @@ async def update_flight_fields(id: str, update: dict) -> str: :param update: Dictionary of fields and values to update :return: ID of updated flight """ - print(fs_keys) for field in update.keys(): if field not in fs_keys: raise HTTPException(400, f"Invalid update field: {field}") From b65805aed5094f2108e675a2f7dce39f4dfd09ef Mon Sep 17 00:00:00 2001 From: april Date: Mon, 15 Jan 2024 17:10:13 -0600 Subject: [PATCH 57/66] Delete associated images when deleting flight log --- api/database/flights.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/api/database/flights.py b/api/database/flights.py index d3c319b..592649b 100644 --- a/api/database/flights.py +++ b/api/database/flights.py @@ -13,6 +13,7 @@ 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 +from .img import delete_image logger = logging.getLogger("api") @@ -265,5 +266,9 @@ async def delete_flight(id: str) -> FlightDisplaySchema: if flight is None: raise HTTPException(404, "Flight not found") + # Delete associated images + for image in flight.images: + await delete_image(image) + await flight_collection.delete_one({"_id": to_objectid(id)}) return FlightDisplaySchema(**flight_display_helper(flight)) From c59bf2fcc98a600a64ad013b7d76de38ba3063e4 Mon Sep 17 00:00:00 2001 From: april Date: Mon, 15 Jan 2024 17:12:26 -0600 Subject: [PATCH 58/66] Fix automatic image deletion --- api/database/flights.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/api/database/flights.py b/api/database/flights.py index 592649b..7582b91 100644 --- a/api/database/flights.py +++ b/api/database/flights.py @@ -267,8 +267,9 @@ async def delete_flight(id: str) -> FlightDisplaySchema: raise HTTPException(404, "Flight not found") # Delete associated images - for image in flight.images: - await delete_image(image) + 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)) From 9a2aef0e958922b43478a90d8ac3528fcb5792a9 Mon Sep 17 00:00:00 2001 From: April Petersen <58403923+azpsen@users.noreply.github.com> Date: Mon, 15 Jan 2024 17:16:03 -0600 Subject: [PATCH 59/66] Update roadmap --- api/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/README.md b/api/README.md index 0ad8246..f4d1bcc 100644 --- a/api/README.md +++ b/api/README.md @@ -111,9 +111,9 @@ Once the server is running, full API documentation is available at `localhost:80 - [x] Multi-user authentication - [x] Basic flight logging CRUD endpoints +- [x] Basic flight logging CRUD endpoints +- [ ] GPS track recording - [ ] Implement JWT refresh tokens -- [ ] Attach photos to log entries - [ ] PDF Export - [ ] Import from other log applications - [ ] Integrate database of airports and waypoints that can be queried to find nearest -- [ ] GPS track recording From 995c47e9bb63b23d16fe2313344e6a34ee7dfbc7 Mon Sep 17 00:00:00 2001 From: April Petersen <58403923+azpsen@users.noreply.github.com> Date: Mon, 15 Jan 2024 17:30:14 -0600 Subject: [PATCH 60/66] Fix roadmap --- api/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/api/README.md b/api/README.md index f4d1bcc..72e4500 100644 --- a/api/README.md +++ b/api/README.md @@ -111,7 +111,8 @@ Once the server is running, full API documentation is available at `localhost:80 - [x] Multi-user authentication - [x] Basic flight logging CRUD endpoints -- [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 From 9790ecf5f61f6d3289569ab6972b8ba9e31e3b48 Mon Sep 17 00:00:00 2001 From: april Date: Thu, 18 Jan 2024 13:08:23 -0600 Subject: [PATCH 61/66] Add MyFlightBook importer --- api/database/aircraft.py | 2 +- api/database/flights.py | 4 +- api/database/import_flights.py | 68 ++++++++++++++++++++++++++++++++++ api/routes/flights.py | 15 ++++++++ api/schemas/flight.py | 6 ++- 5 files changed, 90 insertions(+), 5 deletions(-) create mode 100644 api/database/import_flights.py diff --git a/api/database/aircraft.py b/api/database/aircraft.py index 092e87b..78755b3 100644 --- a/api/database/aircraft.py +++ b/api/database/aircraft.py @@ -117,7 +117,7 @@ async def update_aircraft_field(field: str, value: Any, id: str) -> AircraftDisp if updated_aircraft is None: raise HTTPException(500, "Failed to update flight") - return AircraftDisplaySchema(**aircraft.model_dump()) + return AircraftDisplaySchema(**aircraft_display_helper(aircraft)) async def delete_aircraft(id: str) -> AircraftDisplaySchema: diff --git a/api/database/flights.py b/api/database/flights.py index 7582b91..27d779b 100644 --- a/api/database/flights.py +++ b/api/database/flights.py @@ -12,13 +12,11 @@ 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 + flight_add_helper, FlightPatchSchema, fs_keys from .img import delete_image logger = logging.getLogger("api") -fs_keys = list(FlightPatchSchema.__annotations__.keys()) + list(FlightDisplaySchema.__annotations__.keys()) - async def retrieve_flights(user: str = "", sort: str = "date", order: int = -1, filter: str = "", filter_val: str = "") -> list[FlightConciseSchema]: diff --git a/api/database/import_flights.py b/api/database/import_flights.py new file mode 100644 index 0000000..c515790 --- /dev/null +++ b/api/database/import_flights.py @@ -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()) diff --git a/api/routes/flights.py b/api/routes/flights.py index 330c39e..2d83d98 100644 --- a/api/routes/flights.py +++ b/api/routes/flights.py @@ -8,6 +8,7 @@ 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 @@ -221,3 +222,17 @@ async def delete_flight(flight_id: str, user: UserDisplaySchema = Depends(get_cu 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) diff --git a/api/schemas/flight.py b/api/schemas/flight.py index fd01315..5e7a111 100644 --- a/api/schemas/flight.py +++ b/api/schemas/flight.py @@ -1,5 +1,5 @@ import datetime -from typing import Optional, Dict, Union, List +from typing import Optional, Dict, Union, List, get_args from utils import to_objectid from pydantic import BaseModel @@ -123,6 +123,10 @@ class FlightConciseSchema(BaseModel): 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 # From c22d62950b7bc7175c315e6c0e544f30723e647b Mon Sep 17 00:00:00 2001 From: April Petersen <58403923+azpsen@users.noreply.github.com> Date: Thu, 18 Jan 2024 14:42:48 -0600 Subject: [PATCH 62/66] Add technologies --- api/README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/api/README.md b/api/README.md index 72e4500..97f192e 100644 --- a/api/README.md +++ b/api/README.md @@ -1,3 +1,5 @@ +![MongoDB](https://img.shields.io/badge/MongoDB-%234ea94b.svg?style=for-the-badge&logo=mongodb&logoColor=white) +

Tailfin Logo @@ -9,6 +11,12 @@

A self-hosted digital flight logbook

+

+ Python + MongoDB + FastAPI +

+ ## Table of Contents + [About](#about) From 601f66e222cd2a7db5237624b6cb3dc3f3339b87 Mon Sep 17 00:00:00 2001 From: April Petersen <58403923+azpsen@users.noreply.github.com> Date: Thu, 18 Jan 2024 14:45:22 -0600 Subject: [PATCH 63/66] Add license --- api/LICENSE | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 api/LICENSE diff --git a/api/LICENSE b/api/LICENSE new file mode 100644 index 0000000..adce694 --- /dev/null +++ b/api/LICENSE @@ -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. From a1ab5ae34735e79f1913fd447a806b42bdc63583 Mon Sep 17 00:00:00 2001 From: April Petersen <58403923+azpsen@users.noreply.github.com> Date: Thu, 18 Jan 2024 14:49:05 -0600 Subject: [PATCH 64/66] Add license to readme --- api/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/api/README.md b/api/README.md index 97f192e..ae5c479 100644 --- a/api/README.md +++ b/api/README.md @@ -12,6 +12,7 @@

A self-hosted digital flight logbook

+ Python MongoDB FastAPI From 8b131d5de8666507d2b3841775be61f44a7cb1ca Mon Sep 17 00:00:00 2001 From: April Petersen <58403923+azpsen@users.noreply.github.com> Date: Thu, 18 Jan 2024 14:49:23 -0600 Subject: [PATCH 65/66] Fix README.md --- api/README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/api/README.md b/api/README.md index ae5c479..f97e14f 100644 --- a/api/README.md +++ b/api/README.md @@ -1,5 +1,3 @@ -![MongoDB](https://img.shields.io/badge/MongoDB-%234ea94b.svg?style=for-the-badge&logo=mongodb&logoColor=white) -

Tailfin Logo From e920b31211058cf2742d2d152b181cda02f34eea Mon Sep 17 00:00:00 2001 From: April Petersen <58403923+azpsen@users.noreply.github.com> Date: Fri, 19 Jan 2024 14:49:31 -0600 Subject: [PATCH 66/66] Update README.md --- api/README.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/api/README.md b/api/README.md index f97e14f..93922ae 100644 --- a/api/README.md +++ b/api/README.md @@ -3,11 +3,9 @@ Tailfin Logo

-

Tailfin

+

Tailfin

---- - -

A self-hosted digital flight logbook

+

A self-hosted digital flight logbook