From 72d0c0b67f5523ef47281354f7cf537d518f901a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Loipf=C3=BChrer?= Date: Sat, 23 May 2026 13:40:41 +0200 Subject: [PATCH] refactor(backend): switch to pydantic from marshmallow --- backend/app/config.py | 3 +- .../app/controller/auth/auth_controller.py | 47 ++--- backend/app/controller/auth/schemas.py | 95 +++------ .../category/category_controller.py | 24 +-- backend/app/controller/category/schemas.py | 27 ++- .../controller/expense/expense_controller.py | 168 ++++++++------- backend/app/controller/expense/schemas.py | 145 ++++++------- .../exportimport/import_controller.py | 20 +- .../app/controller/exportimport/schemas.py | 116 +++++------ .../household/household_controller.py | 78 +++---- backend/app/controller/household/schemas.py | 69 +++---- .../app/controller/item/item_controller.py | 50 ++--- backend/app/controller/item/schemas.py | 75 +++---- .../onboarding/onboarding_controller.py | 8 +- backend/app/controller/onboarding/schemas.py | 30 ++- .../controller/planner/planner_controller.py | 30 +-- backend/app/controller/planner/schemas.py | 31 +-- .../controller/recipe/recipe_controller.py | 195 +++++++++--------- backend/app/controller/recipe/schemas.py | 123 ++++++----- .../controller/report/report_controller.py | 14 +- backend/app/controller/report/schemas.py | 22 +- backend/app/controller/settings/schemas.py | 4 +- .../app/controller/shoppinglist/schemas.py | 74 +++---- .../shoppinglist/shoppinglist_controller.py | 62 +++--- backend/app/controller/tag/schemas.py | 18 +- backend/app/controller/tag/tag_controller.py | 14 +- backend/app/controller/user/schemas.py | 83 +++----- .../app/controller/user/user_controller.py | 70 +++---- backend/app/helpers/validate_args.py | 9 +- backend/app/helpers/validate_socket_args.py | 6 +- backend/app/helpers/validators.py | 9 + backend/app/sockets/schemas.py | 16 +- backend/app/sockets/shoppinglist_socket.py | 16 +- backend/app/util/__init__.py | 1 - backend/app/util/multi_dict_list.py | 8 - backend/pyproject.toml | 10 +- backend/tests/api/test_api_planner.py | 4 +- backend/uv.lock | 40 +++- 38 files changed, 827 insertions(+), 987 deletions(-) create mode 100644 backend/app/helpers/validators.py delete mode 100644 backend/app/util/multi_dict_list.py diff --git a/backend/app/config.py b/backend/app/config.py index 4920aa611..a72fc3133 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -5,7 +5,6 @@ from sqlalchemy.engine import URL from sqlalchemy.event import listen from apispec import APISpec -from apispec.ext.marshmallow import MarshmallowPlugin from apispec_webframeworks.flask import FlaskPlugin from prometheus_client import multiprocess from prometheus_client.core import CollectorRegistry @@ -210,7 +209,7 @@ def get_secret(env_var: str, default: str = None) -> str | None: "description": "Find more info at the official documentation", "url": "https://docs.kitchenowl.org", }, - plugins=[FlaskPlugin(), MarshmallowPlugin()], + plugins=[FlaskPlugin()], ) api_spec.components.security_scheme( "bearerAuth", {"type": "http", "scheme": "bearer", "bearerFormat": "JWT"} diff --git a/backend/app/controller/auth/auth_controller.py b/backend/app/controller/auth/auth_controller.py index da0a2bc6b..7a7bf9972 100644 --- a/backend/app/controller/auth/auth_controller.py +++ b/backend/app/controller/auth/auth_controller.py @@ -59,7 +59,7 @@ def user_lookup_callback(_jwt_header, jwt_data) -> User | None: @auth.route("", methods=["POST"]) @validate_args(Login) - def login(args): + def login(args: Login): """Authenticate user with username/email and password. --- post: @@ -89,14 +89,14 @@ def login(args): description: Validation error security: [] """ - username = args["username"].lower().replace(" ", "") + username = args.username.lower().replace(" ", "") user = None if "@" not in username: user = User.find_by_username(username) else: user = User.find_by_email(username) - if not user or not user.check_password(args["password"]): + if not user or not user.check_password(args.password): raise UnauthorizedRequest( message="Unauthorized: IP {} login attempt with wrong username or password".format( getClientIp() @@ -104,7 +104,7 @@ def login(args): ) device = "Unkown" if "device" in args: - device = args["device"] + device = args.device # Create refresh token refreshToken, refreshModel = Token.create_refresh_token(user, device) @@ -125,7 +125,7 @@ def login(args): @auth.route("signup", methods=["POST"]) @validate_args(Signup) - def signup(args): + def signup(args: Signup): """Register new user account. --- post: @@ -153,22 +153,19 @@ def signup(args): description: Invalid username/email security: [] """ - username = args["username"].lower().replace(" ", "").replace("@", "") + username = args.username.lower().replace(" ", "").replace("@", "") user = User.find_by_username(username) if user: return "Request invalid: username", 400 - if "email" in args: - user = User.find_by_email(args["email"]) + if args.email: + user = User.find_by_email(args.email) if user: return "Request invalid: email", 400 user = User.create( - username=username, - name=args["name"], - password=args["password"], - email=args["email"] if "email" in args else None, + username=username, name=args.name, password=args.password, email=args.email ) - if "email" in args and mail.mailConfigured(): + if args.email and mail.mailConfigured(): gevent.spawn( mail.sendVerificationMail, user.id, @@ -177,7 +174,7 @@ def signup(args): device = "Unkown" if "device" in args: - device = args["device"] + device = args.device # Create refresh token refreshToken, refreshModel = Token.create_refresh_token(user, device) @@ -297,7 +294,7 @@ def logout(id): @auth.route("llt", methods=["POST"]) @jwt_required() @validate_args(CreateLongLivedToken) -def createLongLivedToken(args): +def createLongLivedToken(args: CreateLongLivedToken): """Generate long-lived authentication token. --- post: @@ -326,7 +323,7 @@ def createLongLivedToken(args): if not user: raise UnauthorizedRequest(message="Unauthorized: IP {}".format(getClientIp())) - llToken, _ = Token.create_longlived_token(user, args["device"]) + llToken, _ = Token.create_longlived_token(user, args.device) return jsonify({"longlived_token": llToken}) @@ -378,7 +375,7 @@ def deleteLongLivedToken(id): @auth.route("oidc", methods=["GET"]) @jwt_required(optional=True) @validate_args(GetOIDCLoginUrl) - def getOIDCLoginUrl(args): + def getOIDCLoginUrl(args: GetOIDCLoginUrl): """Generate OIDC authentication URL. --- get: @@ -416,7 +413,7 @@ def getOIDCLoginUrl(args): - bearerAuth: [] - {} """ - provider = args["provider"] if "provider" in args else "custom" + provider = args.provider if args.provider else "custom" if provider not in oidc_clients: raise NotFoundRequest() client = oidc_clients[provider] @@ -430,7 +427,7 @@ def getOIDCLoginUrl(args): nonce = rndstr() redirect_uri = ( ("kitchenowl:" + ("" if OIDC_RFC_COMPLIANT_REDIRECT else "//")) - if "kitchenowl_scheme" in args and args["kitchenowl_scheme"] + if args.kitchenowl_scheme else FRONT_URL ) + "/signin/redirect" args = { @@ -456,7 +453,7 @@ def getOIDCLoginUrl(args): @auth.route("callback", methods=["POST"]) @jwt_required(optional=True) @validate_args(LoginOIDC) - def loginWithOIDC(args): + def loginWithOIDC(args: LoginOIDC): """Handle OIDC authentication callback. --- post: @@ -492,7 +489,7 @@ def loginWithOIDC(args): - {} """ # Validate oidc login - oidc_request = OIDCRequest.find_by_state(args["state"]) + oidc_request = OIDCRequest.find_by_state(args.state) if not oidc_request: raise UnauthorizedRequest( message="Unauthorized: IP {} login attempt with unknown OIDC state".format( @@ -521,7 +518,7 @@ def loginWithOIDC(args): client.parse_response( AuthorizationResponse, - info={"code": args["code"], "state": oidc_request.state}, + info={"code": args.code, "state": oidc_request.state}, sformat="dict", ) @@ -529,7 +526,7 @@ def loginWithOIDC(args): scope=["openid", "profile", "email"], state=oidc_request.state, request_args={ - "code": args["code"], + "code": args.code, "redirect_uri": oidc_request.redirect_uri, }, authn_method="client_secret_post", @@ -622,8 +619,8 @@ def loginWithOIDC(args): # login user device = "Unkown" - if "device" in args: - device = args["device"] + if args.device: + device = args.device # Create refresh token refreshToken, refreshModel = Token.create_refresh_token(user, device) diff --git a/backend/app/controller/auth/schemas.py b/backend/app/controller/auth/schemas.py index d5735cf72..e607558a7 100644 --- a/backend/app/controller/auth/schemas.py +++ b/backend/app/controller/auth/schemas.py @@ -1,77 +1,42 @@ -from marshmallow import fields, Schema +from typing import Annotated +from pydantic import AfterValidator, BaseModel, EmailStr, StringConstraints from app.config import EMAIL_MANDATORY +from app.helpers.validators import validate_non_emty_no_at -class Login(Schema): - username = fields.String(required=True, validate=lambda a: a and not a.isspace()) - password = fields.String( - required=True, - validate=lambda a: a and not a.isspace(), - load_only=True, - ) - device = fields.String( - required=False, - validate=lambda a: a and not a.isspace(), - load_only=True, - ) +class Login(BaseModel): + username: Annotated[str, StringConstraints(min_length=1, strip_whitespace=True)] + password: Annotated[str, StringConstraints(min_length=1, strip_whitespace=True)] + device: Annotated[ + str | None, StringConstraints(min_length=1, strip_whitespace=True) + ] = None -class Signup(Schema): - username = fields.String( - required=True, validate=lambda a: a and not a.isspace() and "@" not in a - ) - email = fields.String( - required=EMAIL_MANDATORY, - validate=lambda a: a and not a.isspace() and "@" in a, - load_only=True, - ) - name = fields.String(required=True, validate=lambda a: a and not a.isspace()) - password = fields.String( - required=True, - validate=lambda a: a and not a.isspace(), - load_only=True, - ) - device = fields.String( - required=False, - validate=lambda a: a and not a.isspace(), - load_only=True, - ) +class Signup(BaseModel): + username: Annotated[str, AfterValidator(validate_non_emty_no_at)] + email: Annotated[ + EmailStr | None, AfterValidator(lambda x: EMAIL_MANDATORY or x is not None) + ] = None + name: Annotated[str, StringConstraints(min_length=1, strip_whitespace=True)] + password: Annotated[str, StringConstraints(min_length=1, strip_whitespace=True)] + device: Annotated[ + str | None, StringConstraints(min_length=1, strip_whitespace=True) + ] = None -class CreateLongLivedToken(Schema): - device = fields.String( - required=True, - validate=lambda a: a and not a.isspace(), - load_only=True, - ) +class CreateLongLivedToken(BaseModel): + device: Annotated[str, StringConstraints(min_length=1, strip_whitespace=True)] -class GetOIDCLoginUrl(Schema): - provider = fields.String( - validate=lambda a: a and not a.isspace(), - load_only=True, - ) - kitchenowl_scheme = fields.Boolean( - required=False, - load_default=False, - load_only=True, - ) +class GetOIDCLoginUrl(BaseModel): + provider: Annotated[str, StringConstraints(min_length=1, strip_whitespace=True)] + kitchenowl_scheme: bool = False -class LoginOIDC(Schema): - state = fields.String( - validate=lambda a: a and not a.isspace(), - required=True, - load_only=True, - ) - code = fields.String( - validate=lambda a: a and not a.isspace(), - required=True, - load_only=True, - ) - device = fields.String( - validate=lambda a: a and not a.isspace(), - required=False, - load_only=True, - ) +class LoginOIDC(BaseModel): + state: Annotated[str, StringConstraints(min_length=1, strip_whitespace=True)] + code: Annotated[str, StringConstraints(min_length=1, strip_whitespace=True)] + device: Annotated[ + str | None, StringConstraints(min_length=1, strip_whitespace=True) + ] = None diff --git a/backend/app/controller/category/category_controller.py b/backend/app/controller/category/category_controller.py index 865fa89a7..f395c9be6 100644 --- a/backend/app/controller/category/category_controller.py +++ b/backend/app/controller/category/category_controller.py @@ -30,9 +30,9 @@ def getCategory(id): @jwt_required() @authorize_household() @validate_args(AddCategory) -def addCategory(args, household_id): +def addCategory(args: AddCategory, household_id): category = Category() - category.name = args["name"] + category.name = args.name category.household_id = household_id category.save() return jsonify(category.obj_to_dict()) @@ -41,20 +41,20 @@ def addCategory(args, household_id): @category.route("/", methods=["POST", "PATCH"]) @jwt_required() @validate_args(UpdateCategory) -def updateCategory(args, id): +def updateCategory(args: UpdateCategory, id): category = Category.find_by_id(id) if not category: raise NotFoundRequest() category.checkAuthorized() - if "name" in args: - category.name = args["name"] - if "ordering" in args and category.ordering != args["ordering"]: - category.reorder(args["ordering"]) + if args.name is not None: + category.name = args.name + if args.ordering is not None and category.ordering != args.ordering: + category.reorder(args.ordering) category.save() - if "merge_category_id" in args and args["merge_category_id"] != id: - mergeCategory = Category.find_by_id(args["merge_category_id"]) + if args.merge_category_id is not None and args.merge_category_id != id: + mergeCategory = Category.find_by_id(args.merge_category_id) if mergeCategory: category.merge(mergeCategory) @@ -77,9 +77,9 @@ def deleteCategoryById(id): @jwt_required() @authorize_household() @validate_args(DeleteCategory) -def deleteCategoryByName(args, household_id): - if "name" in args: - category = Category.find_by_name(args["name"], household_id) +def deleteCategoryByName(args: DeleteCategory, household_id): + if args.name is not None: + category = Category.find_by_name(args.name, household_id) if category: category.delete() return jsonify({"msg": "DONE"}) diff --git a/backend/app/controller/category/schemas.py b/backend/app/controller/category/schemas.py index bef43cd0b..e475e7ca0 100644 --- a/backend/app/controller/category/schemas.py +++ b/backend/app/controller/category/schemas.py @@ -1,23 +1,20 @@ -from marshmallow import fields, Schema, EXCLUDE +from typing import Annotated +from pydantic import BaseModel, StringConstraints, PositiveInt -class AddCategory(Schema): - class Meta: - unknown = EXCLUDE +class AddCategory(BaseModel): + name: Annotated[str, StringConstraints(min_length=1, strip_whitespace=True)] - name = fields.String(required=True, validate=lambda a: a and not a.isspace()) - -class UpdateCategory(Schema): - name = fields.String(validate=lambda a: a and not a.isspace()) - ordering = fields.Integer(validate=lambda i: i >= 0) +class UpdateCategory(BaseModel): + name: Annotated[ + str | None, StringConstraints(min_length=1, strip_whitespace=True) + ] = None + ordering: PositiveInt | None = None # if set this merges the specified category into this category thus combining them to one - merge_category_id = fields.Integer( - validate=lambda a: a > 0, - allow_none=True, - ) + merge_category_id: PositiveInt | None = None -class DeleteCategory(Schema): - name = fields.String(required=True, validate=lambda a: a and not a.isspace()) +class DeleteCategory(BaseModel): + name: Annotated[str, StringConstraints(min_length=1, strip_whitespace=True)] diff --git a/backend/app/controller/expense/expense_controller.py b/backend/app/controller/expense/expense_controller.py index 5e283d901..fd959a224 100644 --- a/backend/app/controller/expense/expense_controller.py +++ b/backend/app/controller/expense/expense_controller.py @@ -29,22 +29,22 @@ @jwt_required() @authorize_household() @validate_args(GetExpenses) -def getAllExpenses(args, household_id): +def getAllExpenses(args: GetExpenses, household_id): filter = [Expense.household_id == household_id] - if "startAfterId" in args: - filter.append(Expense.id < args["startAfterId"]) - if "startAfterDate" in args: + if args.startAfterId is not None: + filter.append(Expense.id < args.startAfterId) + if args.startAfterDate is not None: filter.append( Expense.date - < datetime.fromtimestamp(args["startAfterDate"] / 1000, timezone.utc) + < datetime.fromtimestamp(args.startAfterDate / 1000, timezone.utc) ) - if "endBeforeDate" in args: + if args.endBeforeDate is not None: filter.append( Expense.date - > datetime.fromtimestamp(args["endBeforeDate"] / 1000, timezone.utc) + > datetime.fromtimestamp(args.endBeforeDate / 1000, timezone.utc) ) - if "view" in args and args["view"] == 1: + if args.view is not None and args.view == 1: subquery = ( db.session.query(ExpensePaidFor.expense_id) .filter(ExpensePaidFor.user_id == current_user.id) @@ -52,23 +52,19 @@ def getAllExpenses(args, household_id): ) filter.append(Expense.id.in_(subquery)) - if "filter" in args: - if None in args["filter"]: + if args.filter is not None: + if None in args.filter: filter.append( - or_( - Expense.category_id == None, Expense.category_id.in_(args["filter"]) - ) + or_(Expense.category_id == None, Expense.category_id.in_(args.filter)) ) else: - filter.append(Expense.category_id.in_(args["filter"])) + filter.append(Expense.category_id.in_(args.filter)) - if "search" in args and args["search"]: - if "*" in args["search"] or "_" in args["search"]: - query = ( - args["search"].replace("_", "__").replace("*", "%").replace("?", "_") - ) + if args.search is not None and args.search: + if "*" in args.search or "_" in args.search: + query = args.search.replace("_", "__").replace("*", "%").replace("?", "_") else: - query = "%{0}%".format(args["search"]) + query = "%{0}%".format(args.search) filter.append(Expense.name.ilike(query)) return jsonify( @@ -97,39 +93,39 @@ def getExpenseById(id): @jwt_required() @authorize_household() @validate_args(AddExpense) -def addExpense(args, household_id): - member = HouseholdMember.find_by_ids(household_id, args["paid_by"]["id"]) +def addExpense(args: AddExpense, household_id): + member = HouseholdMember.find_by_ids(household_id, args.paid_by.id) if not member: raise NotFoundRequest() expense = Expense() - expense.name = args["name"] - expense.amount = args["amount"] + expense.name = args.name + expense.amount = args.amount expense.household_id = household_id - if "description" in args: - expense.description = args["description"] - if "date" in args: - expense.date = datetime.fromtimestamp(args["date"] / 1000, timezone.utc) - if "photo" in args and args["photo"] != expense.photo: - expense.photo = file_has_access_or_download(args["photo"], expense.photo) - if "category" in args: - if args["category"] is not None: - category = ExpenseCategory.find_by_id(args["category"]) + if args.description is not None: + expense.description = args.description + if args.date is not None: + expense.date = datetime.fromtimestamp(args.date / 1000, timezone.utc) + if args.photo is not None and args.photo != expense.photo: + expense.photo = file_has_access_or_download(args.photo, expense.photo) + if args.category is not None: + if args.category is not None: + category = ExpenseCategory.find_by_id(args.category) expense.category = category - if "exclude_from_statistics" in args: - expense.exclude_from_statistics = args["exclude_from_statistics"] + if args.exclude_from_statistics is not None: + expense.exclude_from_statistics = args.exclude_from_statistics expense.paid_by_id = member.user_id expense.save() member.expense_balance = (member.expense_balance or 0) + expense.amount member.save() factor_sum = 0 - for user_data in args["paid_for"]: - if HouseholdMember.find_by_ids(household_id, user_data["id"]): - factor_sum += user_data["factor"] - for user_data in args["paid_for"]: - member_for = HouseholdMember.find_by_ids(household_id, user_data["id"]) + for user_data in args.paid_for: + if HouseholdMember.find_by_ids(household_id, user_data.id): + factor_sum += user_data.factor + for user_data in args.paid_for: + member_for = HouseholdMember.find_by_ids(household_id, user_data.id) if member_for: con = ExpensePaidFor( - factor=user_data["factor"], + factor=user_data.factor, ) con.user_id = member_for.user_id con.expense = expense @@ -144,52 +140,50 @@ def addExpense(args, household_id): @expense.route("/", methods=["POST"]) @jwt_required() @validate_args(UpdateExpense) -def updateExpense(args, id): # noqa: C901 +def updateExpense(args: UpdateExpense, id): # noqa: C901 expense = Expense.find_by_id(id) if not expense: raise NotFoundRequest() expense.checkAuthorized() - if "name" in args: - expense.name = args["name"] - if "amount" in args: - expense.amount = args["amount"] - if "description" in args: - expense.description = args["description"] - if "date" in args: - expense.date = datetime.fromtimestamp(args["date"] / 1000, timezone.utc) - if "photo" in args and args["photo"] != expense.photo: - expense.photo = file_has_access_or_download(args["photo"], expense.photo) - if "category" in args: - if args["category"] is not None: - category = ExpenseCategory.find_by_id(args["category"]) + if args.name is not None: + expense.name = args.name + if args.amount is not None: + expense.amount = args.amount + if args.description is not None: + expense.description = args.description + if args.date is not None: + expense.date = datetime.fromtimestamp(args.date / 1000, timezone.utc) + if args.photo is not None and args.photo != expense.photo: + expense.photo = file_has_access_or_download(args.photo, expense.photo) + if args.category is not None: + if args.category is not None: + category = ExpenseCategory.find_by_id(args.category) expense.category = category else: expense.category = None - if "exclude_from_statistics" in args: - expense.exclude_from_statistics = args["exclude_from_statistics"] - if "paid_by" in args: - member = HouseholdMember.find_by_ids( - expense.household_id, args["paid_by"]["id"] - ) + if args.exclude_from_statistics is not None: + expense.exclude_from_statistics = args.exclude_from_statistics + if args.paid_by is not None: + member = HouseholdMember.find_by_ids(expense.household_id, args.paid_by.id) if member: expense.paid_by_id = member.user_id expense.save() - if "paid_for" in args: + if args.paid_for is not None: for con in expense.paid_for: - user_ids = [e["id"] for e in args["paid_for"]] + user_ids = [e.id for e in args.paid_for] if con.user.id not in user_ids: con.delete() - for user_data in args["paid_for"]: - member = HouseholdMember.find_by_ids(expense.household_id, user_data["id"]) + for user_data in args.paid_for: + member = HouseholdMember.find_by_ids(expense.household_id, user_data.id) if member: con = ExpensePaidFor.find_by_ids(expense.id, member.user_id) if con: - if "factor" in user_data and user_data["factor"]: - con.factor = user_data["factor"] + if user_data.factor is not None: + con.factor = user_data.factor else: con = ExpensePaidFor( - factor=user_data["factor"], + factor=user_data.factor, ) con.expense = expense con.user_id = member.user_id @@ -234,12 +228,12 @@ def getExpenseCategories(household_id): @jwt_required() @authorize_household() @validate_args(GetExpenseOverview) -def getExpenseOverview(args, household_id): +def getExpenseOverview(args: GetExpenseOverview, household_id): thisMonthStart = datetime.now(timezone.utc).date().replace(day=1) - steps = args["steps"] if "steps" in args else 5 - frame = args["frame"] if "frame" in args and args["frame"] is not None else 2 - page = args["page"] if "page" in args and args["page"] is not None else 0 + steps = args.steps if args.steps is not None else 5 + frame = args.frame if args.frame is not None else 2 + page = args.page if args.page is not None else 0 factor = 1 by_category_query = ( @@ -266,7 +260,7 @@ def getExpenseOverview(args, household_id): else func.strftime(groupByStr, Expense.date) ) - if "view" in args and args["view"] == 1: + if args.view == 1: filterQuery = ( db.session.query(ExpensePaidFor.expense_id) .filter(ExpensePaidFor.user_id == current_user.id) @@ -357,12 +351,12 @@ def getOverviewForStepAgo(stepAgo: int): @jwt_required() @authorize_household() @validate_args(AddExpenseCategory) -def addExpenseCategory(args, household_id): +def addExpenseCategory(args: AddExpenseCategory, household_id): category = ExpenseCategory() - category.name = args["name"] - category.color = args["color"] - if "budget" in args: - category.budget = args["budget"] + category.name = args.name + category.color = args.color + if args.budget is not None: + category.budget = args.budget category.household_id = household_id category.save() return jsonify(category.obj_to_dict()) @@ -382,23 +376,23 @@ def deleteExpenseCategoryById(id): @expense.route("/categories/", methods=["POST"]) @jwt_required() @validate_args(UpdateExpenseCategory) -def updateExpenseCategory(args, id): +def updateExpenseCategory(args: UpdateExpenseCategory, id): category = ExpenseCategory.find_by_id(id) if not category: raise NotFoundRequest() category.checkAuthorized() - if "name" in args: - category.name = args["name"] - if "color" in args: - category.color = args["color"] - if "budget" in args: - category.budget = args["budget"] + if args.name is not None: + category.name = args.name + if args.color is not None: + category.color = args.color + if args.budget is not None: + category.budget = args.budget category.save() - if "merge_category_id" in args and args["merge_category_id"] != id: - mergeCategory = ExpenseCategory.find_by_id(args["merge_category_id"]) + if args.merge_category_id is not None and args.merge_category_id != id: + mergeCategory = ExpenseCategory.find_by_id(args.merge_category_id) if mergeCategory: category.merge(mergeCategory) diff --git a/backend/app/controller/expense/schemas.py b/backend/app/controller/expense/schemas.py index f4d9ca3c5..1e8bf6a43 100644 --- a/backend/app/controller/expense/schemas.py +++ b/backend/app/controller/expense/schemas.py @@ -1,87 +1,74 @@ -from marshmallow import fields, Schema - -from app.util import MultiDictList - - -class CustomInteger(fields.Integer): - def _deserialize(self, value, attr, data, **kwargs): - if not value: - return None - return super()._deserialize(value, attr, data, **kwargs) - - -class GetExpenses(Schema): - view = fields.Integer() - startAfterId = fields.Integer(validate=lambda a: a >= 0) - startAfterDate = fields.Integer(validate=lambda a: a >= 0) - endBeforeDate = fields.Integer(validate=lambda a: a >= 0) - filter = MultiDictList(CustomInteger(allow_none=True)) - search = fields.String(allow_none=True) - - -class AddExpense(Schema): - class User(Schema): - id = fields.Integer(required=True, validate=lambda a: a > 0) - name = fields.String(validate=lambda a: a and not a.isspace()) - factor = fields.Integer(load_default=1) - - name = fields.String(required=True) - amount = fields.Float(required=True) - description = fields.String() - date = fields.Integer() - photo = fields.String() - category = fields.Integer(allow_none=True) - paid_by = fields.Nested(User(), required=True) - paid_for = fields.List( - fields.Nested(User()), required=True, validate=lambda a: len(a) > 0 - ) - exclude_from_statistics = fields.Boolean() - - -class UpdateExpense(Schema): - class User(Schema): - id = fields.Integer(required=True, validate=lambda a: a > 0) - name = fields.String(validate=lambda a: a and not a.isspace()) - factor = fields.Integer(load_default=1) - - name = fields.String() - amount = fields.Float() - description = fields.String() - date = fields.Integer() - photo = fields.String() - category = fields.Integer(allow_none=True) - paid_by = fields.Nested(User()) - paid_for = fields.List(fields.Nested(User())) - exclude_from_statistics = fields.Boolean() - - -class AddExpenseCategory(Schema): - name = fields.String(required=True, validate=lambda a: a and not a.isspace()) - color = fields.Integer(validate=lambda i: i >= 0, allow_none=True) - budget = fields.Float(allow_none=True) - - -class UpdateExpenseCategory(Schema): - name = fields.String(validate=lambda a: a and not a.isspace()) - color = fields.Integer(validate=lambda i: i >= 0, allow_none=True) - budget = fields.Float(allow_none=True) +from typing import Annotated + +from pydantic import BaseModel, Field, PositiveInt, StringConstraints + + +class GetExpenses(BaseModel): + view: int + startAfterId: PositiveInt | None = None + startAfterDate: PositiveInt | None = None + endBeforeDate: PositiveInt | None = None + filter: list[int | None] = [] + search: str | None = None + + +class AddExpense(BaseModel): + class User(BaseModel): + id: PositiveInt + name: Annotated[str, StringConstraints(min_length=1, strip_whitespace=True)] + factor: int = 1 + + name: str + amount: float + description: str | None = None + date: int | None = None + photo: str | None = None + category: int | None = None + paid_by: User + paid_for: list[User] = Field(min_length=1) + exclude_from_statistics: bool = False + + +class UpdateExpense(BaseModel): + class User(BaseModel): + id: PositiveInt + name: Annotated[ + str | None, StringConstraints(min_length=1, strip_whitespace=True) + ] = None + factor: int = 1 + + name: str | None = None + amount: float | None = None + description: str | None = None + date: int | None = None + photo: str | None = None + category: int | None = None + paid_by: User | None = None + paid_for: list[User] | None = None + exclude_from_statistics: bool | None = None + + +class AddExpenseCategory(BaseModel): + name: Annotated[str, StringConstraints(min_length=1, strip_whitespace=True)] + color: int | None = Field(ge=0) + budget: float | None = None + + +class UpdateExpenseCategory(BaseModel): + name: Annotated[str, StringConstraints(min_length=1, strip_whitespace=True)] + color: int | None = Field(ge=0, default=None) + budget: float | None = None # if set this merges the specified category into this category thus combining them to one - merge_category_id = fields.Integer( - validate=lambda a: a > 0, - allow_none=True, - ) + merge_category_id: int | None = Field(gt=0, default=None) -class GetExpenseOverview(Schema): +class GetExpenseOverview(BaseModel): # household = 0, personal = 1 - view = fields.Integer() + view: int # daily = 0, weekly = 1, montly = 2, yearly = 3 - frame = fields.Integer(validate=lambda a: a >= 0 and a <= 3) + frame: int = Field(ge=0, le=3) # how many frames are looked at - steps = fields.Integer(validate=lambda a: a > 0) + steps: int = Field(gt=0) # used for pagination (i.e. start of steps, now=0) - page = fields.Integer( - validate=lambda a: a >= 0, - allow_none=True, - ) + page: int | None = Field(ge=0, default=None) diff --git a/backend/app/controller/exportimport/import_controller.py b/backend/app/controller/exportimport/import_controller.py index 8cdae92bf..4a1b266c3 100644 --- a/backend/app/controller/exportimport/import_controller.py +++ b/backend/app/controller/exportimport/import_controller.py @@ -20,7 +20,7 @@ @jwt_required() @authorize_household() @validate_args(ImportSchema) -def importData(args, household_id): +def importData(args: ImportSchema, household_id): household = Household.find_by_id(household_id) if not household: return @@ -28,25 +28,25 @@ def importData(args, household_id): app.logger.info("Starting import...") t0 = time.time() - if "items" in args: - for item in args["items"]: + if args.items: + for item in args.items: importItem(household, item) - if "recipes" in args: - for recipe in args["recipes"]: + if args.recipes: + for recipe in args.recipes: importRecipe( household_id, recipe, - args["recipe_overwrite"] if "recipe_overwrite" in args else False, + args.recipe_overwrite if args.recipe_overwrite is not None else False, ) - if "expenses" in args: - for expense in args["expenses"]: + if args.expenses: + for expense in args.expenses: importExpense(household, expense) recalculateBalances(household.id) - if "shoppinglists" in args: - for shoppinglist in args["shoppinglists"]: + if args.shoppinglists: + for shoppinglist in args.shoppinglists: importShoppinglist(household, shoppinglist) app.logger.info(f"Import took: {(time.time() - t0):.3f}s") diff --git a/backend/app/controller/exportimport/schemas.py b/backend/app/controller/exportimport/schemas.py index 47a077ed9..99bf0e1d8 100644 --- a/backend/app/controller/exportimport/schemas.py +++ b/backend/app/controller/exportimport/schemas.py @@ -1,64 +1,52 @@ -from marshmallow import EXCLUDE, fields, Schema - - -class ImportSchema(Schema): - class Meta: - unknown = EXCLUDE - - class Item(Schema): - name = fields.String(required=True, validate=lambda a: a and not a.isspace()) - category = fields.String(validate=lambda a: a and not a.isspace()) - icon = fields.String() - - class Recipe(Schema): - class Meta: - unknown = EXCLUDE - - class RecipeItem(Schema): - name = fields.String( - required=True, validate=lambda a: a and not a.isspace() - ) - optional = fields.Boolean(load_default=False) - description = fields.String(load_default="") - - name = fields.String(required=True, validate=lambda a: a and not a.isspace()) - description = fields.String(load_default="") - time = fields.Integer(allow_none=True) - cook_time = fields.Integer(allow_none=True) - prep_time = fields.Integer(allow_none=True) - yields = fields.Integer(allow_none=True) - source = fields.String(allow_none=True) - photo = fields.String(allow_none=True) - items = fields.List(fields.Nested(RecipeItem)) - tags = fields.List(fields.String()) - - class Expense(Schema): - class Meta: - unknown = EXCLUDE - - class PaidFor(Schema): - username = fields.String( - required=True, validate=lambda a: a and not a.isspace() - ) - factor = fields.Integer(load_default=1) - - class Category(Schema): - name = fields.String( - required=True, validate=lambda a: a and not a.isspace() - ) - color = fields.Integer(allow_none=True) - - name = fields.String(required=True, validate=lambda a: a and not a.isspace()) - amount = fields.Float(required=True) - date = fields.Integer() - paid_by = fields.String(required=True, validate=lambda a: a and not a.isspace()) - paid_for = fields.List(fields.Nested(PaidFor)) - photo = fields.String(allow_none=True) - category = fields.Nested(Category) - - items = fields.List(fields.Nested(Item)) - recipes = fields.List(fields.Nested(Recipe)) - recipe_overwrite = fields.Boolean() - expenses = fields.List(fields.Nested(Expense)) - member = fields.List(fields.String()) - shoppinglists = fields.List(fields.String()) +from typing import Annotated +from pydantic import BaseModel, PositiveInt, StringConstraints + + +class ImportSchema(BaseModel): + class Item(BaseModel): + name: Annotated[str, StringConstraints(min_length=1, strip_whitespace=True)] + category: Annotated[str, StringConstraints(min_length=1, strip_whitespace=True)] + icon: str | None + + class Recipe(BaseModel): + class RecipeItem(BaseModel): + name: Annotated[str, StringConstraints(min_length=1, strip_whitespace=True)] + optional: bool = False + description: str = "" + + name: Annotated[str, StringConstraints(min_length=1, strip_whitespace=True)] + description: str = "" + time: PositiveInt | None = None + cook_time: PositiveInt | None = None + prep_time: PositiveInt | None = None + yields: PositiveInt | None = None + source: str | None = None + photo: str | None = None + items: list[RecipeItem] + tags: list[str] + + class Expense(BaseModel): + class PaidFor(BaseModel): + username: Annotated[ + str, StringConstraints(min_length=1, strip_whitespace=True) + ] + factor: int = 1 + + class Category(BaseModel): + name: Annotated[str, StringConstraints(min_length=1, strip_whitespace=True)] + color: int | None = None + + name: Annotated[str, StringConstraints(min_length=1, strip_whitespace=True)] + amount: float + date: int | None + paid_by: Annotated[str, StringConstraints(min_length=1, strip_whitespace=True)] + paid_for: list[PaidFor] + photo: str | None = None + category: Category | None = None + + items: list[Item] + recipes: list[Recipe] + recipe_overwrite: bool + expenses: list[Expense] + member: list[str] + shoppinglists: list[str] diff --git a/backend/app/controller/household/household_controller.py b/backend/app/controller/household/household_controller.py index ddcbfe7fd..c71976e79 100644 --- a/backend/app/controller/household/household_controller.py +++ b/backend/app/controller/household/household_controller.py @@ -42,23 +42,23 @@ def getHousehold(household_id): @household.route("", methods=["POST"]) @jwt_required() @validate_args(AddHousehold) -def addHousehold(args): +def addHousehold(args: AddHousehold): household = Household() - household.name = args["name"] - if "photo" in args and args["photo"] != household.photo: - household.photo = file_has_access_or_download(args["photo"], household.photo) - if "language" in args and args["language"] in SUPPORTED_LANGUAGES: - household.language = args["language"] - if "planner_feature" in args: - household.planner_feature = args["planner_feature"] - if "expenses_feature" in args: - household.expenses_feature = args["expenses_feature"] - if "view_ordering" in args: - household.view_ordering = args["view_ordering"] - if "link" in args: - household.link = args["link"] - if "description" in args: - household.link = args["description"] + household.name = args.name + if args.photo != household.photo: + household.photo = file_has_access_or_download(args.photo, household.photo) + if args.language and args.language in SUPPORTED_LANGUAGES: + household.language = args.language + if args.planner_feature is not None: + household.planner_feature = args.planner_feature + if args.expenses_feature is not None: + household.expenses_feature = args.expenses_feature + if args.view_ordering is not None: + household.view_ordering = args.view_ordering + if args.link is not None: + household.link = args.link + if args.description is not None: + household.description = args.description household.save() @@ -68,8 +68,8 @@ def addHousehold(args): member.owner = True member.save() - if "member" in args: - for uid in args["member"]: + if args.member: + for uid in args.member: if uid == current_user.id: continue if not User.find_by_id(uid): @@ -91,32 +91,32 @@ def addHousehold(args): @jwt_required() @authorize_household(required=RequiredRights.ADMIN) @validate_args(UpdateHousehold) -def updateHousehold(args, household_id): +def updateHousehold(args: UpdateHousehold, household_id): household = Household.find_by_id(household_id) if not household: raise NotFoundRequest() - if "name" in args: - household.name = args["name"] - if "photo" in args and args["photo"] != household.photo: - household.photo = file_has_access_or_download(args["photo"], household.photo) + if args.name: + household.name = args.name + if args.photo and args.photo != household.photo: + household.photo = file_has_access_or_download(args.photo, household.photo) if ( - "language" in args + args.language and not household.language - and args["language"] in SUPPORTED_LANGUAGES + and args.language in SUPPORTED_LANGUAGES ): - household.language = args["language"] + household.language = args.language gevent.spawn(importLanguage, household.id, household.language) - if "planner_feature" in args: - household.planner_feature = args["planner_feature"] - if "expenses_feature" in args: - household.expenses_feature = args["expenses_feature"] - if "view_ordering" in args: - household.view_ordering = args["view_ordering"] - if "link" in args: - household.link = args["link"].strip()[:255] - if "description" in args: - household.description = args["description"].strip()[:255] + if args.planner_feature is not None: + household.planner_feature = args.planner_feature + if args.expenses_feature is not None: + household.expenses_feature = args.expenses_feature + if args.view_ordering is not None: + household.view_ordering = args.view_ordering + if args.link is not None: + household.link = args.link.strip()[:255] + if args.description is not None: + household.description = args.description.strip()[:255] household.save() return jsonify(household.obj_to_dict()) @@ -138,7 +138,7 @@ def deleteHouseholdById(household_id: int): @jwt_required() @authorize_household(required=RequiredRights.ADMIN) @validate_args(UpdateHouseholdMember) -def putHouseholdMember(args, household_id: int, user_id: int): +def putHouseholdMember(args: UpdateHouseholdMember, household_id: int, user_id: int): hm = HouseholdMember.find_by_ids(household_id, user_id) if not hm: household = Household.find_by_id(household_id) @@ -148,8 +148,8 @@ def putHouseholdMember(args, household_id: int, user_id: int): hm.household_id = household_id hm.user_id = user_id - if "admin" in args: - hm.admin = args["admin"] + if args.admin is not None: + hm.admin = args.admin hm.save() diff --git a/backend/app/controller/household/schemas.py b/backend/app/controller/household/schemas.py index a6472ae2f..990629fd4 100644 --- a/backend/app/controller/household/schemas.py +++ b/backend/app/controller/household/schemas.py @@ -1,37 +1,32 @@ -from marshmallow import fields, Schema, EXCLUDE - - -class AddHousehold(Schema): - class Meta: - unknown = EXCLUDE - - name = fields.String(required=True, validate=lambda a: a and not a.isspace()) - photo = fields.String() - link = fields.String() - description = fields.String() - language = fields.String() - planner_feature = fields.Boolean() - expenses_feature = fields.Boolean() - view_ordering = fields.List(fields.String) - member = fields.List(fields.Integer) - - -class UpdateHousehold(Schema): - class Meta: - unknown = EXCLUDE - - name = fields.String(validate=lambda a: a and not a.isspace()) - photo = fields.String() - link = fields.String() - description = fields.String() - language = fields.String() - planner_feature = fields.Boolean() - expenses_feature = fields.Boolean() - view_ordering = fields.List(fields.String) - - -class UpdateHouseholdMember(Schema): - class Meta: - unknown = EXCLUDE - - admin = fields.Boolean() +from typing import Annotated + +from pydantic import BaseModel, StringConstraints + + +class AddHousehold(BaseModel): + name: Annotated[str, StringConstraints(min_length=1, strip_whitespace=True)] + photo: str | None = None + link: str | None = None + description: str | None = None + language: str | None = None + planner_feature: bool | None = None + expenses_feature: bool | None = None + view_ordering: list[str] = [] + member: list[int] = [] + + +class UpdateHousehold(BaseModel): + name: Annotated[ + str | None, StringConstraints(min_length=1, strip_whitespace=True) + ] = None + photo: str | None = None + link: str | None = None + description: str | None = None + language: str | None = None + planner_feature: bool | None = None + expenses_feature: bool | None = None + view_ordering: list[str] | None = None + + +class UpdateHouseholdMember(BaseModel): + admin: bool diff --git a/backend/app/controller/item/item_controller.py b/backend/app/controller/item/item_controller.py index 3613f31c8..868bc81d9 100644 --- a/backend/app/controller/item/item_controller.py +++ b/backend/app/controller/item/item_controller.py @@ -21,7 +21,7 @@ def getAllItems(household_id): @item.route("/", methods=["GET"]) @jwt_required() -def getItem(id): +def getItem(id: int): item = Item.find_by_id(id) if not item: raise NotFoundRequest() @@ -31,7 +31,7 @@ def getItem(id): @item.route("//recipes", methods=["GET"]) @jwt_required() -def getItemRecipes(id): +def getItemRecipes(id: int): item = Item.find_by_id(id) if not item: raise NotFoundRequest() @@ -47,7 +47,7 @@ def getItemRecipes(id): @item.route("/", methods=["DELETE"]) @jwt_required() -def deleteItemById(id): +def deleteItemById(id: int): item = Item.find_by_id(id) if not item: raise NotFoundRequest() @@ -60,8 +60,8 @@ def deleteItemById(id): @jwt_required() @authorize_household() @validate_args(SearchByNameRequest) -def searchItemByName(args, household_id): - query, description = description_splitter.split(args["query"]) +def searchItemByName(args: SearchByNameRequest, household_id: int): + query, description = description_splitter.split(args.query) return jsonify( [ e.obj_to_dict() | {"description": description} @@ -74,21 +74,16 @@ def searchItemByName(args, household_id): @jwt_required() @authorize_household() @validate_args(AddItem) -def addItem(args, household_id): - name: str = args["name"].strip()[:128] +def addItem(args: AddItem, household_id: int): + name: str = args.name.strip()[:128] if Item.find_by_name(household_id, name): raise InvalidUsage() item = Item(household_id=household_id, name=name) - if "category" in args: - if not args["category"]: - item.category = None - elif "id" in args["category"]: - item.category = Category.find_by_id(args["category"]["id"]) - else: - raise InvalidUsage() - if "icon" in args: - item.icon = args["icon"] + if args.category is not None: + item.category = Category.find_by_id(args.category.id) + if args.icon is not None: + item.icon = args.icon item.save() return jsonify(item.obj_to_dict()) @@ -97,29 +92,24 @@ def addItem(args, household_id): @item.route("/", methods=["POST"]) @jwt_required() @validate_args(UpdateItem) -def updateItem(args, id): +def updateItem(args: UpdateItem, id: int): item = Item.find_by_id(id) if not item: raise NotFoundRequest() item.checkAuthorized() - if "category" in args: - if not args["category"]: - item.category = None - elif "id" in args["category"]: - item.category = Category.find_by_id(args["category"]["id"]) - else: - raise InvalidUsage() - if "icon" in args: - item.icon = args["icon"] - if "name" in args and args["name"] != item.name: - newName: str = args["name"].strip()[:128] + if args.category is not None: + item.category = Category.find_by_id(args.category.id) + if args.icon is not None: + item.icon = args.icon + if args.name is not None and args.name != item.name: + newName: str = args.name.strip()[:128] if not Item.find_by_name(item.household_id, newName): item.name = newName item.save() - if "merge_item_id" in args and args["merge_item_id"] != id: - mergeItem = Item.find_by_id(args["merge_item_id"]) + if args.merge_item_id is not None and args.merge_item_id != id: + mergeItem = Item.find_by_id(args.merge_item_id) if mergeItem: item.merge(mergeItem) diff --git a/backend/app/controller/item/schemas.py b/backend/app/controller/item/schemas.py index 7dfb13ca8..6ae5563f7 100644 --- a/backend/app/controller/item/schemas.py +++ b/backend/app/controller/item/schemas.py @@ -1,56 +1,35 @@ -from marshmallow import fields, Schema, EXCLUDE +from typing import Annotated +from pydantic import BaseModel, PositiveInt, StringConstraints -class SearchByNameRequest(Schema): - query = fields.String(required=True, validate=lambda a: a and not a.isspace()) +class SearchByNameRequest(BaseModel): + query: Annotated[ + str | None, StringConstraints(min_length=1, strip_whitespace=True) + ] = None -class UpdateItem(Schema): - class Meta: - unknown = EXCLUDE +class UpdateItem(BaseModel): + class Category(BaseModel): + id: PositiveInt + name: Annotated[ + str | None, StringConstraints(min_length=1, strip_whitespace=True) + ] = None - class Category(Schema): - class Meta: - unknown = EXCLUDE + category: Category | None = None + icon: str | None = None + name: Annotated[ + str | None, StringConstraints(min_length=1, strip_whitespace=True) + ] = None - id = fields.Integer(required=True, validate=lambda a: a > 0) - name = fields.String(validate=lambda a: not a or a and not a.isspace()) + # if set this merges the specified item into this item thus combining them to one + merge_item_id: PositiveInt | None = None - category = fields.Nested(Category(), allow_none=True) - icon = fields.String( - validate=lambda a: not a or not a.isspace(), - allow_none=True, - ) - name = fields.String( - validate=lambda a: not a or not a.isspace(), - allow_none=True, - ) - # if set this merges the specified item into this item thus combining them to one - merge_item_id = fields.Integer( - validate=lambda a: a > 0, - allow_none=True, - ) - - -class AddItem(Schema): - class Meta: - unknown = EXCLUDE - - class Category(Schema): - class Meta: - unknown = EXCLUDE - - id = fields.Integer(required=True, validate=lambda a: a > 0) - name = fields.String(validate=lambda a: not a or a and not a.isspace()) - - category = fields.Nested(Category(), allow_none=True) - icon = fields.String( - validate=lambda a: not a or not a.isspace(), - allow_none=True, - ) - name = fields.String( - validate=lambda a: not a or not a.isspace(), - allow_none=False, - required=True, - ) +class AddItem(BaseModel): + class Category(BaseModel): + id: PositiveInt + name: Annotated[str, StringConstraints(min_length=1, strip_whitespace=True)] + + category: Category | None = None + icon: Annotated[str | None, StringConstraints(min_length=1, strip_whitespace=True)] + name: Annotated[str, StringConstraints(min_length=1, strip_whitespace=True)] diff --git a/backend/app/controller/onboarding/onboarding_controller.py b/backend/app/controller/onboarding/onboarding_controller.py index 258dd3690..4fd052f34 100644 --- a/backend/app/controller/onboarding/onboarding_controller.py +++ b/backend/app/controller/onboarding/onboarding_controller.py @@ -17,15 +17,15 @@ def isOnboarding(): @onboarding.route("", methods=["POST"]) @validate_args(OnboardSchema) -def onboard(args): +def onboard(args: OnboardSchema): if User.count() > 0 or DISABLE_ONBOARDING: return jsonify({"msg": "Onboarding not allowed"}), 403 - user = User.create(args["username"], args["password"], args["name"], admin=True) + user = User.create(args.username, args.password, args.name, admin=True) device = "Unkown" - if "device" in args: - device = args["device"] + if args.device is not None: + device = args.device # Create refresh token refreshToken, refreshModel = Token.create_refresh_token(user, device) diff --git a/backend/app/controller/onboarding/schemas.py b/backend/app/controller/onboarding/schemas.py index 8b49feacb..5bc3e9deb 100644 --- a/backend/app/controller/onboarding/schemas.py +++ b/backend/app/controller/onboarding/schemas.py @@ -1,19 +1,15 @@ -from marshmallow import fields, Schema +from typing import Annotated +from pydantic import AfterValidator, BaseModel, StringConstraints -class OnboardSchema(Schema): - name = fields.String(required=True, validate=lambda a: a and not a.isspace()) - username = fields.String( - required=True, - validate=lambda a: a and not a.isspace() and "@" not in a, - ) - password = fields.String( - required=True, - validate=lambda a: a and not a.isspace(), - load_only=True, - ) - device = fields.String( - required=False, - validate=lambda a: a and not a.isspace(), - load_only=True, - ) +from app.helpers.validators import validate_non_emty_no_at + + +class OnboardSchema(BaseModel): + name: Annotated[str, StringConstraints(min_length=1, strip_whitespace=True)] + # TODO: load only for username and password and device + username: Annotated[str, AfterValidator(validate_non_emty_no_at)] + password: Annotated[str, StringConstraints(min_length=1, strip_whitespace=True)] + device: Annotated[ + str | None, StringConstraints(min_length=1, strip_whitespace=True) + ] = None diff --git a/backend/app/controller/planner/planner_controller.py b/backend/app/controller/planner/planner_controller.py index 113163972..767220d5d 100644 --- a/backend/app/controller/planner/planner_controller.py +++ b/backend/app/controller/planner/planner_controller.py @@ -73,18 +73,18 @@ def getPlanner(household_id): @jwt_required() @authorize_household() @validate_args(AddPlannedRecipe) -def addPlannedRecipe(args, household_id): - recipe = Recipe.find_by_id(args["recipe_id"]) +def addPlannedRecipe(args: AddPlannedRecipe, household_id: int): + recipe = Recipe.find_by_id(args.recipe_id) if not recipe: raise NotFoundRequest() cooking_date = ( - datetime.fromtimestamp(args["cooking_date"] / 1000, timezone.utc).date() - if "cooking_date" in args + datetime.fromtimestamp(args.cooking_date / 1000, timezone.utc).date() + if args.cooking_date is not None else date.min ) - if "day" in args: + if args.day is not None: # if outdated "day" was used, transform it into next date with that weekday - cooking_date = next_weekday(args["day"]) + cooking_date = next_weekday(args.day) planner = Planner.find_by_datetime( household_id=household_id, recipe_id=recipe.id, cooking_date=cooking_date ) @@ -101,8 +101,8 @@ def addPlannedRecipe(args, household_id): planner.recipe_id = recipe.id planner.household_id = household_id planner.cooking_date = datetime.combine(cooking_date, datetime.min.time()) - if "yields" in args: - planner.yields = args["yields"] + if args.yields is not None: + planner.yields = args.yields planner.save() RecipeHistory.create_added(recipe, household_id) @@ -114,19 +114,19 @@ def addPlannedRecipe(args, household_id): @jwt_required() @authorize_household() @validate_args(RemovePlannedRecipe) -def removePlannedRecipeById(args, household_id, id): +def removePlannedRecipeById(args: RemovePlannedRecipe, household_id: int, id: int): recipe = Recipe.find_by_id(id) if not recipe: raise NotFoundRequest() cooking_date = ( - datetime.fromtimestamp(args["cooking_date"] / 1000, timezone.utc).date() - if "cooking_date" in args + datetime.fromtimestamp(args.cooking_date / 1000, timezone.utc).date() + if args.cooking_date is not None else date.min ) - if "day" in args: + if args.day is not None: # if outdated "day" was used, transform it into next date with that weekday - cooking_date = next_weekday(args["day"]) + cooking_date = next_weekday(args.day) planner = Planner.find_by_datetime( household_id, recipe_id=recipe.id, cooking_date=cooking_date ) @@ -140,7 +140,7 @@ def removePlannedRecipeById(args, household_id, id): @plannerHousehold.route("/recent-recipes/", methods=["GET"]) @jwt_required() @authorize_household() -def getRecentRecipes(household_id, page): +def getRecentRecipes(household_id: int, page: int): recipes = RecipeHistory.get_recent(household_id, page) return jsonify([e.recipe.obj_to_full_dict() for e in recipes]) @@ -149,7 +149,7 @@ def getRecentRecipes(household_id, page): @plannerHousehold.route("/suggested-recipes/", methods=["GET"]) @jwt_required() @authorize_household() -def getSuggestedRecipes(household_id, page): +def getSuggestedRecipes(household_id: int, page: int): # all suggestions suggested_recipes = Recipe.find_suggestions(household_id, page) # remove recipes on recent list diff --git a/backend/app/controller/planner/schemas.py b/backend/app/controller/planner/schemas.py index 836331aa3..d94b1ed4b 100644 --- a/backend/app/controller/planner/schemas.py +++ b/backend/app/controller/planner/schemas.py @@ -1,26 +1,13 @@ -from marshmallow import fields, Schema, EXCLUDE -from marshmallow.validate import Range +from pydantic import BaseModel, Field, PositiveInt -class AddPlannedRecipe(Schema): - class Meta: - unknown = EXCLUDE +class AddPlannedRecipe(BaseModel): + recipe_id: int + cooking_date: int + day: int | None = Field(ge=0, le=6, default=None) + yields: PositiveInt | None = None - recipe_id = fields.Integer( - required=True, - ) - cooking_date = fields.Integer() - day = fields.Integer( - validate=Range(min=0, min_inclusive=True, max=6, max_inclusive=True) - ) - yields = fields.Integer() - -class RemovePlannedRecipe(Schema): - class Meta: - unknown = EXCLUDE - - cooking_date = fields.Integer() - day = fields.Integer( - validate=Range(min=0, min_inclusive=True, max=6, max_inclusive=True) - ) +class RemovePlannedRecipe(BaseModel): + cooking_date: int + day: int | None = Field(ge=0, le=6, default=None) diff --git a/backend/app/controller/recipe/recipe_controller.py b/backend/app/controller/recipe/recipe_controller.py index 5211ef906..b5ac5a1d3 100644 --- a/backend/app/controller/recipe/recipe_controller.py +++ b/backend/app/controller/recipe/recipe_controller.py @@ -73,21 +73,21 @@ def getRecipeById(id): @jwt_required() @authorize_household() @validate_args(AddRecipe) -def addRecipe(args, household_id): +def addRecipe(args: AddRecipe, household_id: int): recipe = Recipe() - recipe.name = args["name"].strip()[:128] - recipe.description = args["description"] + recipe.name = args.name.strip()[:128] + recipe.description = args.description recipe.household_id = household_id - if "time" in args: - recipe.time = args["time"] - if "cook_time" in args: - recipe.cook_time = args["cook_time"] - if "prep_time" in args: - recipe.prep_time = args["prep_time"] - if "yields" in args: - recipe.yields = args["yields"] - if "source" in args: - recipe.source = args["source"] + if args.time is not None: + recipe.time = args.time + if args.cook_time is not None: + recipe.cook_time = args.cook_time + if args.prep_time is not None: + recipe.prep_time = args.prep_time + if args.yields is not None: + recipe.yields = args.yields + if args.source is not None: + recipe.source = args.source localMatch = re.match( r"(kitchenowl:\/\/|" + re.escape((FRONT_URL or "").removesuffix("/")) @@ -100,26 +100,26 @@ def addRecipe(args, household_id): if sourceRecipe: sourceRecipe.server_scrapes = sourceRecipe.server_scrapes + 1 sourceRecipe.save() - if "visibility" in args: - recipe.visibility = RecipeVisibility(args["visibility"]) - if "photo" in args and args["photo"] != recipe.photo: - recipe.photo = file_has_access_or_download(args["photo"], recipe.photo) - if "server_curated" in args and current_user.admin: - recipe.server_curated = args["server_curated"] + if args.visibility is not None: + recipe.visibility = RecipeVisibility(args.visibility) + if args.photo is not None and args.photo != recipe.photo: + recipe.photo = file_has_access_or_download(args.photo, recipe.photo) + if args.server_curated is not None and current_user.admin: + recipe.server_curated = args.server_curated recipe.save() - if "items" in args: - for recipeItem in args["items"]: - item = Item.find_by_name(household_id, recipeItem["name"]) + if args.items is not None: + for recipeItem in args.items: + item = Item.find_by_name(household_id, recipeItem.name) if not item: - item = Item.create_by_name(household_id, recipeItem["name"]) + item = Item.create_by_name(household_id, recipeItem.name) con = RecipeItems( - description=recipeItem["description"], optional=recipeItem["optional"] + description=recipeItem.description, optional=recipeItem.optional ) con.item = item con.recipe = recipe con.save() - if "tags" in args: - for tagName in args["tags"]: + if args.tags is not None: + for tagName in args.tags: tag = Tag.find_by_name(household_id, tagName) if not tag: tag = Tag.create_by_name(household_id, tagName) @@ -133,61 +133,61 @@ def addRecipe(args, household_id): @recipe.route("/", methods=["POST"]) @jwt_required() @validate_args(UpdateRecipe) -def updateRecipe(args, id): # noqa: C901 +def updateRecipe(args: UpdateRecipe, id: int): # noqa: C901 recipe = Recipe.find_by_id(id) if not recipe: raise NotFoundRequest() recipe.checkAuthorized() - if "name" in args: - recipe.name = args["name"].strip()[:128] - if "description" in args: - recipe.description = args["description"] - if "time" in args: - recipe.time = args["time"] - if "cook_time" in args: - recipe.cook_time = args["cook_time"] - if "prep_time" in args: - recipe.prep_time = args["prep_time"] - if "yields" in args: - recipe.yields = args["yields"] - if "source" in args: - recipe.source = args["source"] - if "visibility" in args: - recipe.visibility = RecipeVisibility(args["visibility"]) - if "photo" in args and args["photo"] != recipe.photo: - recipe.photo = file_has_access_or_download(args["photo"], recipe.photo) - if "server_curated" in args and current_user.admin: - recipe.server_curated = args["server_curated"] + if args.name is not None: + recipe.name = args.name.strip()[:128] + if args.description is not None: + recipe.description = args.description + if args.time is not None: + recipe.time = args.time + if args.cook_time is not None: + recipe.cook_time = args.cook_time + if args.prep_time is not None: + recipe.prep_time = args.prep_time + if args.yields is not None: + recipe.yields = args.yields + if args.source is not None: + recipe.source = args.source + if args.visibility is not None: + recipe.visibility = RecipeVisibility(args.visibility) + if args.photo is not None and args.photo != recipe.photo: + recipe.photo = file_has_access_or_download(args.photo, recipe.photo) + if args.server_curated is not None and current_user.admin: + recipe.server_curated = args.server_curated recipe.save() - if "items" in args: + if args.items is not None: for con in recipe.items: - item_names = [e["name"] for e in args["items"]] + item_names = [e.name for e in args.items] if con.item.name not in item_names: con.delete() - for recipeItem in args["items"]: - item = Item.find_by_name(recipe.household_id, recipeItem["name"]) + for recipeItem in args.items: + item = Item.find_by_name(recipe.household_id, recipeItem.name) if not item: - item = Item.create_by_name(recipe.household_id, recipeItem["name"]) + item = Item.create_by_name(recipe.household_id, recipeItem.name) con = RecipeItems.find_by_ids(recipe.id, item.id) if con: - if "description" in recipeItem: - con.description = recipeItem["description"] - if "optional" in recipeItem: - con.optional = recipeItem["optional"] + if recipeItem.description is not None: + con.description = recipeItem.description + if recipeItem.optional is not None: + con.optional = recipeItem.optional else: con = RecipeItems( - description=recipeItem["description"], - optional=recipeItem["optional"], + description=recipeItem.description, + optional=recipeItem.optional, ) con.item = item con.recipe = recipe con.save() - if "tags" in args: + if args.tags is not None: for con in recipe.tags: - if con.tag.name not in args["tags"]: + if con.tag.name not in args.tags: con.delete() - for recipeTag in args["tags"]: + for recipeTag in args.tags: tag = Tag.find_by_name(recipe.household_id, recipeTag) if not tag: tag = Tag.create_by_name(recipe.household_id, recipeTag) @@ -215,11 +215,11 @@ def deleteRecipeById(id): @jwt_required() @authorize_household() @validate_args(SearchByNameRequest) -def searchRecipeInHouseholdByName(args, household_id): - if "only_ids" in args and args["only_ids"]: - return jsonify([e.id for e in Recipe.search_name(args["query"], household_id)]) +def searchRecipeInHouseholdByName(args: SearchByNameRequest, household_id: int): + if args.only_ids is not None and args.only_ids: + return jsonify([e.id for e in Recipe.search_name(args.query, household_id)]) return jsonify( - [e.obj_to_full_dict() for e in Recipe.search_name(args["query"], household_id)] + [e.obj_to_full_dict() for e in Recipe.search_name(args.query, household_id)] ) @@ -227,11 +227,11 @@ def searchRecipeInHouseholdByName(args, household_id): @jwt_required() @authorize_household() @validate_args(GetAllFilterRequest) -def getAllFiltered(args, household_id): +def getAllFiltered(args: GetAllFilterRequest, household_id: int): return jsonify( [ e.obj_to_full_dict() - for e in Recipe.all_by_name_with_filter(household_id, args["filter"]) + for e in Recipe.all_by_name_with_filter(household_id, args.filter) ] ) @@ -240,12 +240,12 @@ def getAllFiltered(args, household_id): @jwt_required() @authorize_household() @validate_args(ScrapeRecipe) -def scrapeRecipe(args, household_id): +def scrapeRecipe(args: ScrapeRecipe, household_id: int): household = Household.find_by_id(household_id) if not household: raise NotFoundRequest() - res = scrape(args["url"], household) + res = scrape(args.url, household) if res: return jsonify(res) return "Unsupported website", 400 @@ -254,11 +254,11 @@ def scrapeRecipe(args, household_id): @recipe.route("/discover", methods=["GET"]) @jwt_required() @validate_args(SuggestionsRecipe) -def suggestedRecipes(args): +def suggestedRecipes(args: SuggestionsRecipe): queryFilter = [Recipe.visibility == RecipeVisibility.PUBLIC] - if "language" in args: - queryFilter.append(Household.language == args["language"]) + if args.language is not None: + queryFilter.append(Household.language == args.language) tags = ( RecipeTags.query.join(RecipeTags.tag) @@ -309,11 +309,11 @@ def suggestedRecipes(args): @recipe.route("/discover/curated/", methods=["GET"]) @jwt_required() @validate_args(SuggestionsRecipe) -def curatedRecipes(args, page): +def curatedRecipes(args: SuggestionsRecipe, page: int): queryFilter = [Recipe.visibility == RecipeVisibility.PUBLIC] - if "language" in args: - queryFilter.append(Household.language == args["language"]) + if args.language is not None: + queryFilter.append(Household.language == args.language) return jsonify( [ @@ -332,11 +332,11 @@ def curatedRecipes(args, page): @recipe.route("/discover/popular/", methods=["GET"]) @jwt_required() @validate_args(SuggestionsRecipe) -def popularRecipes(args, page): +def popularRecipes(args: SuggestionsRecipe, page: int): queryFilter = [Recipe.visibility == RecipeVisibility.PUBLIC] - if "language" in args: - queryFilter.append(Household.language == args["language"]) + if args.language is not None: + queryFilter.append(Household.language == args.language) return jsonify( [ @@ -356,11 +356,11 @@ def popularRecipes(args, page): @recipe.route("/discover/newest/", methods=["GET"]) @jwt_required() @validate_args(SuggestionsRecipe) -def newestRecipes(args, page): +def newestRecipes(args: SuggestionsRecipe, page: int): queryFilter = [Recipe.visibility == RecipeVisibility.PUBLIC] - if "language" in args: - queryFilter.append(Household.language == args["language"]) + if args.language is not None: + queryFilter.append(Household.language == args.language) return jsonify( [ @@ -378,15 +378,15 @@ def newestRecipes(args, page): @recipe.route("/search", methods=["GET"]) @jwt_required() @validate_args(SearchByNameRequest) -def searchAllRecipeByName(args): - if "only_ids" in args and args["only_ids"]: +def searchAllRecipeByName(args: SearchByNameRequest): + if args.only_ids is not None and args.only_ids: return jsonify( [ e.id for e in Recipe.search_name( - args["query"], - page=args["page"], - language=args["language"] if "language" in args else None, + args.query, + page=args.page, + language=args.language if args.language is not None else None, ) ] ) @@ -394,9 +394,9 @@ def searchAllRecipeByName(args): [ e.obj_to_full_dict() for e in Recipe.search_name( - args["query"], - page=args["page"], - language=args["language"] if "language" in args else None, + args.query, + page=args.page, + language=args.language if args.language is not None else None, ) ] ) @@ -405,28 +405,21 @@ def searchAllRecipeByName(args): @recipe.route("/search-tag", methods=["GET"]) @jwt_required() @validate_args(SearchByTagRequest) -def searchAllRecipeByTag(args): +def searchAllRecipeByTag(args: SearchByTagRequest): query = Recipe.query.filter( Recipe.visibility == RecipeVisibility.PUBLIC, Recipe.tags.any( RecipeTags.tag_id.in_( - db.session.query(Tag.id) - .filter(Tag.name == args["tag"]) - .scalar_subquery() + db.session.query(Tag.id).filter(Tag.name == args.tag).scalar_subquery() ) ), ) - if "language" in args: - query = query.join(Recipe.household).filter( - Household.language == args["language"] - ) + if args.language is not None: + query = query.join(Recipe.household).filter(Household.language == args.language) return jsonify( [ e.obj_to_full_dict() - for e in query.order_by(Recipe.name) - .offset(args["page"] * 10) - .limit(10) - .all() + for e in query.order_by(Recipe.name).offset(args.page * 10).limit(10).all() ] ) diff --git a/backend/app/controller/recipe/schemas.py b/backend/app/controller/recipe/schemas.py index c323aaae6..c3120e717 100644 --- a/backend/app/controller/recipe/schemas.py +++ b/backend/app/controller/recipe/schemas.py @@ -1,85 +1,78 @@ -from marshmallow import EXCLUDE, fields, Schema +from typing import Annotated +from pydantic import BaseModel, PositiveInt, StringConstraints -class AddRecipe(Schema): - class Meta: - unknown = EXCLUDE +class AddRecipe(BaseModel): + class RecipeItem(BaseModel): + name: Annotated[str, StringConstraints(min_length=1, strip_whitespace=True)] + description: str = "" + optional: bool = True - class RecipeItem(Schema): - name = fields.String(required=True, validate=lambda a: a and not a.isspace()) - description = fields.String(load_default="") - optional = fields.Boolean(load_default=True) + name: Annotated[str, StringConstraints(min_length=1, strip_whitespace=True)] + description: str + time: PositiveInt | None = None + cook_time: PositiveInt | None = None + prep_time: PositiveInt | None = None + yields: PositiveInt | None = None + source: str | None = None + server_curated: bool | None = None + photo: str | None = None + visibility: PositiveInt | None = None + items: list[RecipeItem] = [] + tags: list[str] = [] - name = fields.String(required=True, validate=lambda a: a and not a.isspace()) - description = fields.String(validate=lambda a: a is not None) - time = fields.Integer(validate=lambda a: a >= 0) - cook_time = fields.Integer(validate=lambda a: a >= 0) - prep_time = fields.Integer(validate=lambda a: a >= 0) - yields = fields.Integer(validate=lambda a: a >= 0) - source = fields.String() - server_curated = fields.Boolean() - photo = fields.String() - visibility = fields.Integer(validate=lambda a: a >= 0) - items = fields.List(fields.Nested(RecipeItem())) - tags = fields.List(fields.String()) +class UpdateRecipe(BaseModel): + class RecipeItem(BaseModel): + name: Annotated[str, StringConstraints(min_length=1, strip_whitespace=True)] + description: str + optional: bool = True -class UpdateRecipe(Schema): - class Meta: - unknown = EXCLUDE + name: Annotated[ + str | None, StringConstraints(min_length=1, strip_whitespace=True) + ] = None + description: str | None = None + time: PositiveInt | None = None + cook_time: PositiveInt | None = None + prep_time: PositiveInt | None = None + yields: PositiveInt | None = None + source: str | None = None + server_curated: bool | None = None + photo: str | None = None + visibility: PositiveInt | None = None + items: list[RecipeItem] | None = None + tags: list[str] | None = None - class RecipeItem(Schema): - name = fields.String(required=True, validate=lambda a: a and not a.isspace()) - description = fields.String() - optional = fields.Boolean(load_default=True) - name = fields.String(validate=lambda a: a and not a.isspace()) - description = fields.String(validate=lambda a: a is not None) - time = fields.Integer(validate=lambda a: a >= 0) - cook_time = fields.Integer(validate=lambda a: a >= 0) - prep_time = fields.Integer(validate=lambda a: a >= 0) - yields = fields.Integer(validate=lambda a: a >= 0) - source = fields.String() - server_curated = fields.Boolean() - photo = fields.String() - visibility = fields.Integer(validate=lambda a: a >= 0) - items = fields.List(fields.Nested(RecipeItem())) - tags = fields.List(fields.String()) +class SearchByNameRequest(BaseModel): + query: Annotated[str, StringConstraints(min_length=1, strip_whitespace=True)] + page: PositiveInt = 0 + language: str | None = None + only_ids: bool = False -class SearchByNameRequest(Schema): - query = fields.String(required=True, validate=lambda a: a and not a.isspace()) - page = fields.Integer(validate=lambda a: a >= 0, load_default=0) - language = fields.String() - only_ids = fields.Boolean( - load_default=False, - ) +class SearchByTagRequest(BaseModel): + tag: Annotated[str, StringConstraints(min_length=1, strip_whitespace=True)] + page: PositiveInt = 0 + language: str | None -class SearchByTagRequest(Schema): - tag = fields.String(required=True, validate=lambda a: a and not a.isspace()) - page = fields.Integer(validate=lambda a: a >= 0, load_default=0) - language = fields.String() +class GetAllFilterRequest(BaseModel): + filter: list[str] -class GetAllFilterRequest(Schema): - filter = fields.List(fields.String()) +class AddItemByName(BaseModel): + name: str + description: str | None -class AddItemByName(Schema): - name = fields.String(required=True) - description = fields.String() +class RemoveItem(BaseModel): + item_id: int -class RemoveItem(Schema): - item_id = fields.Integer( - required=True, - ) +class ScrapeRecipe(BaseModel): + url: Annotated[str, StringConstraints(min_length=1, strip_whitespace=True)] -class ScrapeRecipe(Schema): - url = fields.String(required=True, validate=lambda a: a and not a.isspace()) - - -class SuggestionsRecipe(Schema): - language = fields.String() +class SuggestionsRecipe(BaseModel): + language: str | None diff --git a/backend/app/controller/report/report_controller.py b/backend/app/controller/report/report_controller.py index 674fc3963..176f0f2f3 100644 --- a/backend/app/controller/report/report_controller.py +++ b/backend/app/controller/report/report_controller.py @@ -41,16 +41,16 @@ def deleteReportById(id): @reportBlueprint.route("", methods=["POST"]) @jwt_required() @validate_args(AddReport) -def addReport(args): +def addReport(args: AddReport): report = Report( created_by_id=current_user.id, ) - if "description" in args: - report.description = args["description"] - if "user_id" in args: - report.user_id = args["user_id"] - elif "recipe_id" in args: - report.recipe_id = args["recipe_id"] + if args.description is not None: + report.description = args.description + if args.user_id is not None: + report.user_id = args.user_id + elif args.recipe_id is not None: + report.recipe_id = args.recipe_id else: raise InvalidUsage() report.save() diff --git a/backend/app/controller/report/schemas.py b/backend/app/controller/report/schemas.py index e0e5f5919..72e428558 100644 --- a/backend/app/controller/report/schemas.py +++ b/backend/app/controller/report/schemas.py @@ -1,17 +1,11 @@ -from marshmallow import fields, Schema, EXCLUDE +from typing import Annotated +from pydantic import BaseModel, StringConstraints -class AddReport(Schema): - class Meta: - unknown = EXCLUDE - description = fields.String( - validate=lambda a: not a or not a.isspace(), - allow_none=True, - ) - recipe_id = fields.Integer( - allow_none=True, - ) - user_id = fields.Integer( - allow_none=True, - ) +class AddReport(BaseModel): + description: Annotated[ + str | None, StringConstraints(min_length=1, strip_whitespace=True) + ] = None + recipe_id: int | None = None + user_id: int | None = None diff --git a/backend/app/controller/settings/schemas.py b/backend/app/controller/settings/schemas.py index a401ae6f4..0edf1b5d6 100644 --- a/backend/app/controller/settings/schemas.py +++ b/backend/app/controller/settings/schemas.py @@ -1,5 +1,5 @@ -from marshmallow import Schema +from pydantic import BaseModel -class SetSettingsSchema(Schema): +class SetSettingsSchema(BaseModel): pass diff --git a/backend/app/controller/shoppinglist/schemas.py b/backend/app/controller/shoppinglist/schemas.py index 5b7c3bf4d..02de6e687 100644 --- a/backend/app/controller/shoppinglist/schemas.py +++ b/backend/app/controller/shoppinglist/schemas.py @@ -1,65 +1,57 @@ -from marshmallow import fields, Schema, EXCLUDE +from typing import Annotated +from pydantic import BaseModel, StringConstraints, Field -class GetShoppingLists(Schema): - orderby = fields.Integer() - recent_limit = fields.Integer(load_default=9, validate=lambda x: x > 0 and x <= 120) +class GetShoppingLists(BaseModel): + orderby: int | None = None + recent_limit: Annotated[int, Field(gt=0, le=120)] = 9 -class AddItemByName(Schema): - name = fields.String(required=True) - description = fields.String() +class AddItemByName(BaseModel): + name: str + description: str | None = None -class AddRecipeItems(Schema): - class RecipeItem(Schema): - class Meta: - unknown = EXCLUDE - id = fields.Integer(required=True) - name = fields.String(required=True, validate=lambda a: a and not a.isspace()) - description = fields.String(load_default="") - optional = fields.Boolean(load_default=True) +class AddRecipeItems(BaseModel): + class RecipeItem(BaseModel): + id: int + name: Annotated[str, StringConstraints(min_length=1, strip_whitespace=True)] + description: str = "" + optional: bool = True - items = fields.List(fields.Nested(RecipeItem)) + items: list[RecipeItem] -class CreateList(Schema): - name = fields.String(required=True, validate=lambda a: a and not a.isspace()) +class CreateList(BaseModel): + name: Annotated[str, StringConstraints(min_length=1, strip_whitespace=True)] -class UpdateList(Schema): - name = fields.String(validate=lambda a: a and not a.isspace()) +class UpdateList(BaseModel): + name: Annotated[str, StringConstraints(min_length=1, strip_whitespace=True)] -class GetItems(Schema): - orderby = fields.Integer() +class GetItems(BaseModel): + orderby: int | None = None -class GetRecentItems(Schema): +class GetRecentItems(BaseModel): # Align deprecated endpoint limit with list endpoint (<=120) - limit = fields.Integer(load_default=9, validate=lambda x: x > 0 and x <= 120) + limit: Annotated[int, Field(gt=0, le=120)] = 9 -class UpdateDescription(Schema): - description = fields.String(required=True) +class UpdateDescription(BaseModel): + description: str -class RemoveItem(Schema): - item_id = fields.Integer( - required=True, - ) - removed_at = fields.Integer() +class RemoveItem(BaseModel): + item_id: int + removed_at: int | None = None -class RemoveItems(Schema): - class RecipeItem(Schema): - class Meta: - unknown = EXCLUDE +class RemoveItems(BaseModel): + class RecipeItem(BaseModel): + item_id: int + removed_at: int | None = None - item_id = fields.Integer( - required=True, - ) - removed_at = fields.Integer() - - items = fields.List(fields.Nested(RecipeItem)) + items: list[RecipeItem] diff --git a/backend/app/controller/shoppinglist/shoppinglist_controller.py b/backend/app/controller/shoppinglist/shoppinglist_controller.py index 64cdf47aa..3f5c9dca7 100644 --- a/backend/app/controller/shoppinglist/shoppinglist_controller.py +++ b/backend/app/controller/shoppinglist/shoppinglist_controller.py @@ -36,8 +36,8 @@ @jwt_required() @authorize_household() @validate_args(CreateList) -def createShoppinglist(args, household_id): - shoppinglist = Shoppinglist(name=args["name"], household_id=household_id) +def createShoppinglist(args: CreateList, household_id): + shoppinglist = Shoppinglist(name=args.name, household_id=household_id) shoppinglist.save() shoppinglist_dict = shoppinglist.obj_to_dict() socketio.emit( @@ -52,17 +52,17 @@ def createShoppinglist(args, household_id): @jwt_required() @authorize_household() @validate_args(GetShoppingLists) -def getShoppinglists(args, household_id): +def getShoppinglists(args: GetShoppingLists, household_id: int): shoppinglists = Shoppinglist.all_from_household(household_id) recentItems = {} for shoppinglist in shoppinglists: recentItems[shoppinglist.id] = [ e.item.obj_to_dict() | {"description": e.description} - for e in History.get_recent(shoppinglist.id, args["recent_limit"]) + for e in History.get_recent(shoppinglist.id, args.recent_limit) ] orderby = [Item.name] - if "orderby" in args and args["orderby"] == 1: + if args.orderby == 1: orderby = [Item.ordering == 0, Item.ordering] items = {} @@ -91,14 +91,14 @@ def getShoppinglists(args, household_id): @shoppinglist.route("/", methods=["POST"]) @jwt_required() @validate_args(UpdateList) -def updateShoppinglist(args, id): +def updateShoppinglist(args: UpdateList, id): shoppinglist = Shoppinglist.find_by_id(id) if not shoppinglist: raise NotFoundRequest() shoppinglist.checkAuthorized() - if "name" in args: - shoppinglist.name = args["name"] + if args.name is not None: + shoppinglist.name = args.name shoppinglist.save() return jsonify(shoppinglist.obj_to_dict()) @@ -126,7 +126,7 @@ def deleteShoppinglist(id): @shoppinglist.route("//item/", methods=["POST", "PUT"]) @jwt_required() @validate_args(UpdateDescription) -def updateItemDescription(args, id: int, item_id: int): +def updateItemDescription(args: UpdateDescription, id: int, item_id: int): con = ShoppinglistItems.find_by_ids(id, item_id) if not con: shoppinglist = Shoppinglist.find_by_id(id) @@ -141,7 +141,7 @@ def updateItemDescription(args, id: int, item_id: int): con.created_by = current_user.id con.shoppinglist.checkAuthorized() - con.description = args["description"] or "" + con.description = args.description or "" con.save() socketio.emit( "shoppinglist_item:add", @@ -157,7 +157,7 @@ def updateItemDescription(args, id: int, item_id: int): @shoppinglist.route("//items", methods=["GET"]) @jwt_required() @validate_args(GetItems) -def getAllShoppingListItems(args, id): +def getAllShoppingListItems(args: GetItems, id: int): """ Deprecated in favor of including it directly in the shopping list """ @@ -167,10 +167,10 @@ def getAllShoppingListItems(args, id): shoppinglist.checkAuthorized() orderby = [Item.name] - if "orderby" in args: - if args["orderby"] == 1: + if args.orderby is not None: + if args.orderby == 1: orderby = [Item.ordering == 0, Item.ordering] - elif args["orderby"] == 2: + elif args.orderby == 2: orderby = [Item.name] items = ( @@ -185,7 +185,7 @@ def getAllShoppingListItems(args, id): @shoppinglist.route("//recent-items", methods=["GET"]) @jwt_required() @validate_args(GetRecentItems) -def getRecentItems(args, id): +def getRecentItems(args: GetRecentItems, id): """ Deprecated in favor of including it directly in the shopping list """ @@ -194,7 +194,7 @@ def getRecentItems(args, id): raise NotFoundRequest() shoppinglist.checkAuthorized() - items = History.get_recent(id, args["limit"]) + items = History.get_recent(id, args.limit) return jsonify( [e.item.obj_to_dict() | {"description": e.description} for e in items] ) @@ -283,19 +283,19 @@ def getSuggestedItems(id): @shoppinglist.route("//add-item-by-name", methods=["POST"]) @jwt_required() @validate_args(AddItemByName) -def addShoppinglistItemByName(args, id): +def addShoppinglistItemByName(args: AddItemByName, id): shoppinglist = Shoppinglist.find_by_id(id) if not shoppinglist: raise NotFoundRequest() shoppinglist.checkAuthorized() - item = Item.find_by_name(shoppinglist.household_id, args["name"]) + item = Item.find_by_name(shoppinglist.household_id, args.name) if not item: - item = Item.create_by_name(shoppinglist.household_id, args["name"]) + item = Item.create_by_name(shoppinglist.household_id, args.name) con = ShoppinglistItems.find_by_ids(shoppinglist.id, item.id) if not con: - description = args["description"] if "description" in args else "" + description = args.description if args.description is not None else "" con = ShoppinglistItems(description=description) con.created_by = current_user.id con.item = item @@ -319,7 +319,7 @@ def addShoppinglistItemByName(args, id): @shoppinglist.route("//item", methods=["DELETE"]) @jwt_required() @validate_args(RemoveItem) -def removeShoppinglistItem(args, id: int): +def removeShoppinglistItem(args: RemoveItem, id: int): shoppinglist = Shoppinglist.find_by_id(id) if not shoppinglist: raise NotFoundRequest() @@ -327,8 +327,8 @@ def removeShoppinglistItem(args, id: int): con = removeShoppinglistItemFunc( shoppinglist, - args["item_id"], - args["removed_at"] if "removed_at" in args else None, + args.item_id, + args.removed_at if args.removed_at is not None else None, ) if con: socketio.emit( @@ -346,17 +346,17 @@ def removeShoppinglistItem(args, id: int): @shoppinglist.route("//items", methods=["DELETE"]) @jwt_required() @validate_args(RemoveItems) -def removeShoppinglistItems(args, id: int): +def removeShoppinglistItems(args: RemoveItems, id: int): shoppinglist = Shoppinglist.find_by_id(id) if not shoppinglist: raise NotFoundRequest() shoppinglist.checkAuthorized() - for arg in args["items"]: + for arg in args.items: con = removeShoppinglistItemFunc( shoppinglist, - arg["item_id"], - arg["removed_at"] if "removed_at" in arg else None, + arg.item_id, + arg.removed_at if arg.removed_at is not None else None, ) if con: socketio.emit( @@ -394,18 +394,18 @@ def removeShoppinglistItemFunc( @shoppinglist.route("//recipeitems", methods=["POST"]) @jwt_required() @validate_args(AddRecipeItems) -def addRecipeItems(args, id): +def addRecipeItems(args: AddRecipeItems, id: int): shoppinglist = Shoppinglist.find_by_id(id) if not shoppinglist: raise NotFoundRequest() shoppinglist.checkAuthorized() try: - for recipeItem in args["items"]: - item = Item.find_by_id(recipeItem["id"]) + for recipeItem in args.items: + item = Item.find_by_id(recipeItem.id) if item: item.checkAuthorized() - description = recipeItem["description"] + description = recipeItem.description con = ShoppinglistItems.find_by_ids(shoppinglist.id, item.id) if con: # merge descriptions diff --git a/backend/app/controller/tag/schemas.py b/backend/app/controller/tag/schemas.py index f0e2de7cc..5b6043b46 100644 --- a/backend/app/controller/tag/schemas.py +++ b/backend/app/controller/tag/schemas.py @@ -1,15 +1,15 @@ -from marshmallow import fields, Schema +from typing import Annotated +from pydantic import BaseModel, StringConstraints, PositiveInt -class AddTag(Schema): - name = fields.String(required=True, validate=lambda a: a and not a.isspace()) +class AddTag(BaseModel): + name: Annotated[str, StringConstraints(min_length=1, strip_whitespace=True)] -class UpdateTag(Schema): - name = fields.String(validate=lambda a: a and not a.isspace()) +class UpdateTag(BaseModel): + name: Annotated[ + str | None, StringConstraints(min_length=1, strip_whitespace=True) + ] = None # if set this merges the specified tag into this tag thus combining them to one - merge_tag_id = fields.Integer( - validate=lambda a: a > 0, - allow_none=True, - ) + merge_tag_id: PositiveInt | None = None diff --git a/backend/app/controller/tag/tag_controller.py b/backend/app/controller/tag/tag_controller.py index 749f12054..6bf8c4b6c 100644 --- a/backend/app/controller/tag/tag_controller.py +++ b/backend/app/controller/tag/tag_controller.py @@ -49,9 +49,9 @@ def getTagRecipes(id): @jwt_required() @authorize_household() @validate_args(AddTag) -def addTag(args, household_id): +def addTag(args: AddTag, household_id): tag = Tag() - tag.name = args["name"] + tag.name = args.name tag.household_id = household_id tag.save() return jsonify(tag.obj_to_dict()) @@ -60,19 +60,19 @@ def addTag(args, household_id): @tag.route("/", methods=["POST"]) @jwt_required() @validate_args(UpdateTag) -def updateTag(args, id): +def updateTag(args: UpdateTag, id: int): tag = Tag.find_by_id(id) if not tag: raise NotFoundRequest() tag.checkAuthorized() - if "name" in args: - tag.name = args["name"] + if args.name is not None: + tag.name = args.name tag.save() - if "merge_tag_id" in args and args["merge_tag_id"] != id: - mergeTag = Tag.find_by_id(args["merge_tag_id"]) + if args.merge_tag_id is not None and args.merge_tag_id != id: + mergeTag = Tag.find_by_id(args.merge_tag_id) if mergeTag: tag.merge(mergeTag) diff --git a/backend/app/controller/user/schemas.py b/backend/app/controller/user/schemas.py index d6e8a8d9e..e699dfdf1 100644 --- a/backend/app/controller/user/schemas.py +++ b/backend/app/controller/user/schemas.py @@ -1,65 +1,42 @@ -from marshmallow import fields, Schema +from typing import Annotated +from pydantic import BaseModel, AfterValidator, EmailStr, StringConstraints -class CreateUser(Schema): - name = fields.String(required=True, validate=lambda a: a and not a.isspace()) - username = fields.String( - required=True, - validate=lambda a: a and not a.isspace() and "@" not in a, - load_only=True, - ) - email = fields.String( - required=False, - validate=lambda a: a and not a.isspace() and "@" in a, - load_only=True, - ) - password = fields.String( - required=True, - validate=lambda a: a and not a.isspace(), - load_only=True, - ) +from app.helpers.validators import validate_non_emty_no_at -class UpdateUser(Schema): - name = fields.String(validate=lambda a: a and not a.isspace()) - photo = fields.String() - username = fields.String( - validate=lambda a: a and not a.isspace() and "@" not in a, - load_only=True, - ) - email = fields.String( - validate=lambda a: a and not a.isspace() and "@" in a, - load_only=True, - ) - password = fields.String( - validate=lambda a: a and not a.isspace(), - load_only=True, - ) - admin = fields.Boolean( - load_only=True, - ) +class CreateUser(BaseModel): + name: Annotated[str, StringConstraints(min_length=1, strip_whitespace=True)] + username: Annotated[str, AfterValidator(validate_non_emty_no_at)] + email: EmailStr | None = None + password: Annotated[str, StringConstraints(min_length=1, strip_whitespace=True)] -class SearchByNameRequest(Schema): - query = fields.String(required=True, validate=lambda a: a and not a.isspace()) +class UpdateUser(BaseModel): + name: Annotated[ + str | None, StringConstraints(min_length=1, strip_whitespace=True) + ] = None + photo: str | None = None + username: Annotated[str | None, AfterValidator(validate_non_emty_no_at)] = None + email: EmailStr | None = None + password: Annotated[ + str | None, StringConstraints(min_length=1, strip_whitespace=True) + ] = None + admin: bool | None = None -class ConfirmMail(Schema): - token = fields.String(required=True, validate=lambda a: a and not a.isspace()) +class SearchByNameRequest(BaseModel): + query: Annotated[str, StringConstraints(min_length=1, strip_whitespace=True)] -class ResetPassword(Schema): - token = fields.String(required=True, validate=lambda a: a and not a.isspace()) - password = fields.String( - required=True, - validate=lambda a: a and not a.isspace(), - load_only=True, - ) +class ConfirmMail(BaseModel): + token: Annotated[str, StringConstraints(min_length=1, strip_whitespace=True)] -class ForgotPassword(Schema): - email = fields.String( - required=True, - validate=lambda a: a and not a.isspace() and "@" in a, - load_only=True, - ) +class ResetPassword(BaseModel): + token: Annotated[str, StringConstraints(min_length=1, strip_whitespace=True)] + password: Annotated[str, StringConstraints(min_length=1, strip_whitespace=True)] + + +class ForgotPassword(BaseModel): + email: EmailStr diff --git a/backend/app/controller/user/user_controller.py b/backend/app/controller/user/user_controller.py index 6060ff578..3bd2e6689 100644 --- a/backend/app/controller/user/user_controller.py +++ b/backend/app/controller/user/user_controller.py @@ -76,14 +76,14 @@ def updateUser(args): user: User = current_user if not user: raise NotFoundRequest() - if "name" in args: - user.name = args["name"].strip() - if "password" in args: - user.set_password(args["password"]) - if "email" in args and args["email"].strip() != user.email: - if user.find_by_email(args["email"].strip()): + if args.name is not None: + user.name = args.name.strip() + if args.password is not None: + user.set_password(args.password) + if args.email is not None and args.email.strip() != user.email: + if user.find_by_email(args.email.strip()): return "Request invalid: email", 400 - user.email = args["email"].strip() + user.email = args.email.strip() user.email_verified = False ChallengeMailVerify.delete_by_user(user) if mail.mailConfigured(): @@ -92,8 +92,8 @@ def updateUser(args): user.id, ChallengeMailVerify.create_challenge(user), ) - if "photo" in args and user.photo != args["photo"]: - user.photo = file_has_access_or_download(args["photo"], user.photo) + if args.photo is not None and user.photo != args.photo: + user.photo = file_has_access_or_download(args.photo, user.photo) user.save() return jsonify({"msg": "DONE"}) @@ -102,24 +102,24 @@ def updateUser(args): @jwt_required() @server_admin_required() @validate_args(UpdateUser) -def updateUserById(args, id): +def updateUserById(args: UpdateUser, id: int): user = User.find_by_id(id) if not user: raise NotFoundRequest() - if "name" in args: - user.name = args["name"].strip() - if "password" in args: - user.set_password(args["password"]) - if "email" in args and args["email"].strip() != user.email: - if user.find_by_email(args["email"].strip()): + if args.name is not None: + user.name = args.name.strip() + if args.password is not None: + user.set_password(args.password) + if args.email is not None and args.email.strip() != user.email: + if user.find_by_email(args.email.strip()): return "Request invalid: email", 400 - user.email = args["email"].strip() + user.email = args.email.strip() user.email_verified = True ChallengeMailVerify.delete_by_user(user) - if "photo" in args and user.photo != args["photo"]: - user.photo = file_has_access_or_download(args["photo"], user.photo) - if "admin" in args: - user.admin = args["admin"] + if args.photo is not None and user.photo != args.photo: + user.photo = file_has_access_or_download(args.photo, user.photo) + if args.admin is not None: + user.admin = args.admin user.save() return jsonify({"msg": "DONE"}) @@ -128,12 +128,12 @@ def updateUserById(args, id): @jwt_required() @server_admin_required() @validate_args(CreateUser) -def createUser(args): +def createUser(args: CreateUser): User.create( - args["username"].replace(" ", ""), - args["password"], - args["name"], - email=args["email"] if "email" in args else None, + args.username.replace(" ", ""), + args.password, + args.name, + email=args.email, ) return jsonify({"msg": "DONE"}) @@ -141,8 +141,8 @@ def createUser(args): @user.route("/search", methods=["GET"]) @jwt_required() @validate_args(SearchByNameRequest) -def searchUser(args): - return jsonify([e.obj_to_dict() for e in User.search_name(args["query"])]) +def searchUser(args: SearchByNameRequest): + return jsonify([e.obj_to_dict() for e in User.search_name(args.query)]) @user.route("/resend-verification-mail", methods=["POST"]) @@ -162,8 +162,8 @@ def resendVerificationMail(): @user.route("/confirm-mail", methods=["POST"]) @validate_args(ConfirmMail) -def confirmMail(args): - challenge = ChallengeMailVerify.find_by_challenge(args["token"]) +def confirmMail(args: ConfirmMail): + challenge = ChallengeMailVerify.find_by_challenge(args.token) if not challenge: raise NotFoundRequest() user: User = challenge.user @@ -176,12 +176,12 @@ def confirmMail(args): @user.route("/reset-password", methods=["POST"]) @validate_args(ResetPassword) -def resetPassword(args): - challenge = ChallengePasswordReset.find_by_challenge(args["token"]) +def resetPassword(args: ResetPassword): + challenge = ChallengePasswordReset.find_by_challenge(args.token) if not challenge: raise NotFoundRequest() user: User = challenge.user - user.set_password(args["password"]) + user.set_password(args.password) user.save() ChallengePasswordReset.delete_by_user(user) return jsonify({"msg": "DONE"}) @@ -189,11 +189,11 @@ def resetPassword(args): @user.route("/forgot-password", methods=["POST"]) @validate_args(ForgotPassword) -def forgotPassword(args): +def forgotPassword(args: ForgotPassword): if not mail.mailConfigured(): raise Exception("Mail service not configured") - user = User.find_by_email(args["email"]) + user = User.find_by_email(args.email) if not user: return jsonify({"msg": "DONE"}) diff --git a/backend/app/helpers/validate_args.py b/backend/app/helpers/validate_args.py index c46193d08..2e3e79191 100644 --- a/backend/app/helpers/validate_args.py +++ b/backend/app/helpers/validate_args.py @@ -1,19 +1,18 @@ -from marshmallow import Schema -from marshmallow.exceptions import ValidationError +from pydantic import BaseModel, ValidationError from app.errors import InvalidUsage from flask import request from functools import wraps -def validate_args(schema_cls: type[Schema]): +def validate_args(schema_cls: type[BaseModel]): def validate(func): @wraps(func) def func_wrapper(*args, **kwargs): try: if request.method == "GET": - arguments = schema_cls().load(request.args) + arguments = schema_cls.model_validate(request.args) else: - arguments = schema_cls().loads(request.data.decode("utf-8")) + arguments = schema_cls.model_validate_json(request.data) except ValidationError as exc: raise InvalidUsage("{}".format(exc)) diff --git a/backend/app/helpers/validate_socket_args.py b/backend/app/helpers/validate_socket_args.py index 25065148e..c38b924c7 100644 --- a/backend/app/helpers/validate_socket_args.py +++ b/backend/app/helpers/validate_socket_args.py @@ -1,9 +1,9 @@ -from marshmallow.exceptions import ValidationError +from pydantic import BaseModel, ValidationError from app.errors import InvalidUsage from functools import wraps -def validate_socket_args(schema_cls): +def validate_socket_args(schema_cls: type[BaseModel]): def validate(func): @wraps(func) def func_wrapper(*args, **kwargs): @@ -11,7 +11,7 @@ def func_wrapper(*args, **kwargs): raise Exception("Invalid usage. Schema class missing") try: - arguments = schema_cls().load(args[0]) + arguments = schema_cls.model_validate_json(args[0]) except ValidationError as exc: raise InvalidUsage("{}".format(exc)) diff --git a/backend/app/helpers/validators.py b/backend/app/helpers/validators.py new file mode 100644 index 000000000..62881d1cd --- /dev/null +++ b/backend/app/helpers/validators.py @@ -0,0 +1,9 @@ +from pydantic import ValidationError + + +def validate_non_emty_no_at(value: str) -> str: + if not value or value.isspace() or "@" in value: + raise ValidationError( + "Value must be non-empty, not just whitespace, and must not contain '@'" + ) + return value diff --git a/backend/app/sockets/schemas.py b/backend/app/sockets/schemas.py index 8fde11ec6..b2ec4a32c 100644 --- a/backend/app/sockets/schemas.py +++ b/backend/app/sockets/schemas.py @@ -1,12 +1,12 @@ -from marshmallow import Schema, fields +from pydantic import BaseModel -class shoppinglist_item_add(Schema): - shoppinglist_id = fields.Integer(required=True) - name = fields.String(required=True) - description = fields.String() +class shoppinglist_item_add(BaseModel): + shoppinglist_id: int + name: str + description: str | None -class shoppinglist_item_remove(Schema): - shoppinglist_id = fields.Integer(required=True) - item_id = fields.Integer(required=True) +class shoppinglist_item_remove(BaseModel): + shoppinglist_id: int + item_id: int diff --git a/backend/app/sockets/shoppinglist_socket.py b/backend/app/sockets/shoppinglist_socket.py index 34ab6ad5a..7393f9955 100644 --- a/backend/app/sockets/shoppinglist_socket.py +++ b/backend/app/sockets/shoppinglist_socket.py @@ -12,19 +12,19 @@ @socketio.on("shoppinglist_item:add") @socket_jwt_required() @validate_socket_args(shoppinglist_item_add) -def on_add(args): - shoppinglist = Shoppinglist.find_by_id(args["shoppinglist_id"]) +def on_add(args: shoppinglist_item_add): + shoppinglist = Shoppinglist.find_by_id(args.shoppinglist_id) if not shoppinglist: raise NotFoundRequest() shoppinglist.checkAuthorized() - item = Item.find_by_name(shoppinglist.household_id, args["name"]) + item = Item.find_by_name(shoppinglist.household_id, args.name) if not item: - item = Item.create_by_name(shoppinglist.household_id, args["name"]) + item = Item.create_by_name(shoppinglist.household_id, args.name) con = ShoppinglistItems.find_by_ids(shoppinglist.id, item.id) if not con: - description = args["description"] if "description" in args else "" + description = args.description if args.description else "" con = ShoppinglistItems(description=description) con.created_by = current_user.id con.item = item @@ -46,13 +46,13 @@ def on_add(args): @socketio.on("shoppinglist_item:remove") @socket_jwt_required() @validate_socket_args(shoppinglist_item_remove) -def on_remove(args): - shoppinglist = Shoppinglist.find_by_id(args["shoppinglist_id"]) +def on_remove(args: shoppinglist_item_remove): + shoppinglist = Shoppinglist.find_by_id(args.shoppinglist_id) if not shoppinglist: raise NotFoundRequest() shoppinglist.checkAuthorized() - con = removeShoppinglistItem(shoppinglist, args["item_id"]) + con = removeShoppinglistItem(shoppinglist, args.item_id) if con: emit( "shoppinglist_item:remove", diff --git a/backend/app/util/__init__.py b/backend/app/util/__init__.py index 4bba60df0..79ac2f91a 100644 --- a/backend/app/util/__init__.py +++ b/backend/app/util/__init__.py @@ -1,2 +1 @@ from .kitchenowl_json_provider import KitchenOwlJSONProvider -from .multi_dict_list import MultiDictList diff --git a/backend/app/util/multi_dict_list.py b/backend/app/util/multi_dict_list.py deleted file mode 100644 index 4c455fd9f..000000000 --- a/backend/app/util/multi_dict_list.py +++ /dev/null @@ -1,8 +0,0 @@ -import marshmallow - - -class MultiDictList(marshmallow.fields.List): - def _deserialize(self, value, attr, data, **kwargs): - if isinstance(data, dict) and hasattr(data, "getlist"): - value = data.getlist(attr) - return super()._deserialize(value, attr, data, **kwargs) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 5c4e25d99..0454cd963 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -5,7 +5,6 @@ description = "Add your description here" readme = "README.md" requires-python = ">=3.14" dependencies = [ - "apispec[marshmallow]>=6.8.1", "apispec-webframeworks>=1.2.0", "apscheduler>=3.11.0", "blurhash-python>=1.2.2", @@ -42,16 +41,11 @@ dependencies = [ "psycopg2-binary>=2.9.10", "uWSGI>=2.0.28", "uwsgi-tools>=1.1.1", + "pydantic[email]>=2.13.4", ] [dependency-groups] -dev = [ - "pytest", - "pytest-cov", - "ruff", - "pyright", - "pre-commit", -] +dev = ["pytest", "pytest-cov", "ruff", "pyright", "pre-commit"] [tool.pyright] reportMissingTypeStubs = true diff --git a/backend/tests/api/test_api_planner.py b/backend/tests/api/test_api_planner.py index 172e32714..5499d2e48 100644 --- a/backend/tests/api/test_api_planner.py +++ b/backend/tests/api/test_api_planner.py @@ -24,8 +24,8 @@ def test_meal_planning_cooking_date_field( planned_meals = response.get_json() actual = datetime.fromtimestamp( planned_meals[0]["cooking_date"] / 1000, timezone.utc - ).replace(tzinfo=None) - expected = datetime.fromtimestamp(pytest.FIX_DATETIME / 1000, None) + ) + expected = datetime.fromtimestamp(pytest.FIX_DATETIME / 1000, timezone.utc) assert actual == expected diff --git a/backend/uv.lock b/backend/uv.lock index 143ef5a4a..6571e2a6d 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -148,9 +148,6 @@ wheels = [ ] [package.optional-dependencies] -marshmallow = [ - { name = "marshmallow" }, -] yaml = [ { name = "pyyaml" }, ] @@ -633,6 +630,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + [[package]] name = "extruct" version = "0.18.0" @@ -1248,7 +1267,6 @@ name = "kitchenowl-backend" version = "0.7.8" source = { virtual = "." } dependencies = [ - { name = "apispec", extra = ["marshmallow"] }, { name = "apispec-webframeworks" }, { name = "apscheduler" }, { name = "blurhash-python" }, @@ -1276,6 +1294,7 @@ dependencies = [ { name = "prometheus-client" }, { name = "prometheus-flask-exporter" }, { name = "psycopg2-binary" }, + { name = "pydantic", extra = ["email"] }, { name = "python-socketio" }, { name = "recipe-scrapers" }, { name = "requests" }, @@ -1298,7 +1317,6 @@ dev = [ [package.metadata] requires-dist = [ - { name = "apispec", extras = ["marshmallow"], specifier = ">=6.8.1" }, { name = "apispec-webframeworks", specifier = ">=1.2.0" }, { name = "apscheduler", specifier = ">=3.11.0" }, { name = "blurhash-python", specifier = ">=1.2.2" }, @@ -1326,6 +1344,7 @@ requires-dist = [ { name = "prometheus-client", specifier = ">=0.21.1" }, { name = "prometheus-flask-exporter", specifier = ">=0.23.2" }, { name = "psycopg2-binary", specifier = ">=2.9.10" }, + { name = "pydantic", extras = ["email"], specifier = ">=2.13.4" }, { name = "python-socketio", specifier = ">=5.16.2" }, { name = "recipe-scrapers", specifier = ">=15.6.0" }, { name = "requests", specifier = ">=2.32.3" }, @@ -1410,7 +1429,7 @@ wheels = [ [[package]] name = "litellm" -version = "1.85.1" +version = "1.85.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, @@ -1426,9 +1445,9 @@ dependencies = [ { name = "tiktoken" }, { name = "tokenizers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/da/55/aebffceaa08688a989e9c68b3edc3a520a1f8338eb0346668774bd66ad88/litellm-1.85.1.tar.gz", hash = "sha256:3b8ef0c89ff2736cbd27109f17ff31f1bd0ab59dee9be8cadb28ec3cb167ce0d", size = 15346324, upload-time = "2026-05-21T02:30:38.185Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/d5/3c9b560db2ffa9e498655d0dfd74f408bc5b32ede858b5731c2a5fa4c752/litellm-1.85.0.tar.gz", hash = "sha256:babdd569809af913d08a08a7eb55df1ed3e6a3960ee365c6cef4ad031c9bc72a", size = 15344387, upload-time = "2026-05-17T01:59:15.97Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/17/a0/263a13c2253201aa11563a69d9a87f3510030aa765a16f57fc40ceefcdf5/litellm-1.85.1-py3-none-any.whl", hash = "sha256:c89eb5dfd18cce3d40b59e79c74f7f645bc7814a417c6ab25e53c786f0a6ab7b", size = 16980080, upload-time = "2026-05-21T02:30:35.096Z" }, + { url = "https://files.pythonhosted.org/packages/1c/38/e6a4abb062e039d18d59538cc4e6fc370c2c10cd2bff4a2e546acb69dcb9/litellm-1.85.0-py3-none-any.whl", hash = "sha256:2bb449153610691faffd76f5b94a8c29e4b66fc5394156ebf54fd4fe92759b1a", size = 16978229, upload-time = "2026-05-17T01:59:11.902Z" }, ] [[package]] @@ -2017,6 +2036,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, ] +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + [[package]] name = "pydantic-core" version = "2.46.4"